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
5cb630bd
Commit
5cb630bd
authored
Nov 05, 2015
by
chrisndodge
Browse files
Options
Browse Files
Download
Plain Diff
Merge pull request #206 from edx/cdodge/analytic-events
Emit Analytics Events
parents
fc072f90
9ec6b99d
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 @
5cb630bd
...
...
@@ -25,6 +25,7 @@ from edx_proctoring.exceptions import (
StudentExamAttemptedAlreadyStarted
,
ProctoredExamIllegalStatusTransition
,
ProctoredExamPermissionDenied
,
ProctoredExamNotActiveException
,
)
from
edx_proctoring.models
import
(
ProctoredExam
,
...
...
@@ -40,7 +41,8 @@ from edx_proctoring.serializers import (
)
from
edx_proctoring.utils
import
(
humanized_time
,
has_client_app_shutdown
has_client_app_shutdown
,
emit_event
)
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
)
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
...
...
@@ -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
:
proctored_exam
.
is_active
=
is_active
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
...
...
@@ -194,7 +205,22 @@ def add_allowance_for_user(exam_id, user_info, key, value):
)
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
):
...
...
@@ -222,6 +248,17 @@ def remove_allowance_for_user(exam_id, user_id, key):
if
student_allowance
is
not
None
:
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
):
"""
...
...
@@ -601,6 +638,8 @@ def update_attempt_status(exam_id, user_id, to_status, raise_if_not_found=True,
else
:
return
exam
=
get_exam_by_id
(
exam_id
)
#
# 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
...
...
@@ -646,7 +685,6 @@ def update_attempt_status(exam_id, user_id, to_status, raise_if_not_found=True,
# trigger credit workflow, as needed
credit_service
=
get_runtime_service
(
'credit'
)
exam
=
get_exam_by_id
(
exam_id
)
if
to_status
==
ProctoredExamStudentAttemptStatus
.
verified
:
credit_requirement_status
=
'satisfied'
elif
to_status
==
ProctoredExamStudentAttemptStatus
.
submitted
:
...
...
@@ -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
# updated to reflect a declined status
# 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
,
active_only
=
True
)
# we just want other exams which are proctored and are not practice
exams
=
[
_exam
for
_exam
in
_exams
other_
exams
=
[
other
_exam
for
other_exam
in
all_other
_exams
if
(
_exam
.
content_id
!=
exam_attempt_obj
.
proctored_exam
.
content_id
and
_exam
.
is_proctored
and
not
_exam
.
is_practice_exam
other
_exam
.
content_id
!=
exam_attempt_obj
.
proctored_exam
.
content_id
and
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
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'
]):
# don't touch any completed statuses
# we won't revoke those
continue
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_attempt_status
(
exam
.
id
,
other_
exam
.
id
,
user_id
,
ProctoredExamStudentAttemptStatus
.
declined
,
cascade_effects
=
False
...
...
@@ -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'
))
)
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
):
...
...
@@ -857,6 +903,12 @@ def remove_exam_attempt(attempt_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
):
"""
...
...
@@ -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'
else
:
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
:
# when we're taking the exam we should not override the view
return
None
elif
attempt_status
==
ProctoredExamStudentAttemptStatus
.
expired
:
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
()
student_view_template
=
'proctored_exam/instructions.html'
context
.
update
({
...
...
edx_proctoring/backends/software_secure.py
View file @
5cb630bd
...
...
@@ -23,13 +23,16 @@ from edx_proctoring.exceptions import (
ProctoredExamReviewAlreadyExists
,
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
(
ProctoredExamSoftwareSecureReview
,
ProctoredExamSoftwareSecureComment
,
ProctoredExamStudentAttemptStatus
,
)
from
edx_proctoring.serializers
import
(
ProctoredExamSerializer
,
ProctoredExamStudentAttemptSerializer
,
)
log
=
logging
.
getLogger
(
__name__
)
...
...
@@ -241,6 +244,20 @@ class SoftwareSecureBackendProvider(ProctoringBackendProvider):
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
"""
called when a review has been save - either through API (on_review_callback) or via Django Admin panel
...
...
edx_proctoring/models.py
View file @
5cb630bd
# pylint: disable=too-many-lines
"""
Data models for the proctoring subsystem
"""
...
...
@@ -120,6 +121,10 @@ class ProctoredExamStudentAttemptStatus(object):
# been started
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
# user to acknowledge that he/she wants to start the exam
ready_to_start
=
'ready_to_start'
...
...
@@ -184,7 +189,8 @@ class ProctoredExamStudentAttemptStatus(object):
Returns a boolean if the passed in status is in an "incomplete" state.
"""
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
...
...
@@ -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
.
value
=
value
student_allowance
.
save
()
action
=
"updated"
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
def
is_allowance_value_valid
(
cls
,
allowance_type
,
allowance_value
):
...
...
edx_proctoring/serializers.py
View file @
5cb630bd
...
...
@@ -76,7 +76,7 @@ class ProctoredExamStudentAttemptSerializer(serializers.ModelSerializer):
"id"
,
"created"
,
"modified"
,
"user"
,
"started_at"
,
"completed_at"
,
"external_id"
,
"status"
,
"proctored_exam"
,
"allowed_time_limit_mins"
,
"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 @
5cb630bd
...
...
@@ -8,6 +8,7 @@ var edx = edx || {};
var
examStatusReadableFormat
=
{
eligible
:
gettext
(
'Eligible'
),
created
:
gettext
(
'Created'
),
download_software_clicked
:
gettext
(
'Download Software Clicked'
),
ready_to_start
:
gettext
(
'Ready to start'
),
started
:
gettext
(
'Started'
),
ready_to_submit
:
gettext
(
'Ready to submit'
),
...
...
edx_proctoring/templates/proctored_exam/instructions.html
View file @
5cb630bd
...
...
@@ -26,7 +26,7 @@
{% endblocktrans %}
</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>
{% blocktrans %}
...
...
@@ -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>
edx_proctoring/tests/test_api.py
View file @
5cb630bd
...
...
@@ -66,6 +66,7 @@ from .utils import (
from
edx_proctoring.tests.test_services
import
(
MockCreditService
,
MockInstructorService
,
MockAnalyticsService
,
)
from
edx_proctoring.runtime
import
set_runtime_service
,
get_runtime_service
...
...
@@ -124,6 +125,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
set_runtime_service
(
'credit'
,
MockCreditService
())
set_runtime_service
(
'instructor'
,
MockInstructorService
(
is_user_course_staff
=
True
))
set_runtime_service
(
'analytics'
,
MockAnalyticsService
())
self
.
prerequisites
=
[
{
...
...
@@ -657,7 +659,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
proctored_exam_student_attempt
=
self
.
_create_unstarted_exam_attempt
()
self
.
assertIsNone
(
proctored_exam_student_attempt
.
completed_at
)
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
)
...
...
@@ -746,7 +748,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
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
proctored_exam_student_attempt
.
proctored_exam
.
id
,
self
.
user_id
)
self
.
assertEqual
(
proctored_exam_student_attempt
.
id
,
proctored_exam_attempt_id
)
...
...
@@ -1038,6 +1040,27 @@ class ProctoredExamApiTests(LoggedInTestCase):
self
.
assertIn
(
self
.
chose_proctored_exam_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
):
"""
Test for get_student_view Practice exam which has not started yet.
...
...
@@ -1844,6 +1867,8 @@ class ProctoredExamApiTests(LoggedInTestCase):
(
ProctoredExamStudentAttemptStatus
.
declined
,
ProctoredExamStudentAttemptStatus
.
eligible
),
(
ProctoredExamStudentAttemptStatus
.
timed_out
,
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
.
verified
,
ProctoredExamStudentAttemptStatus
.
started
),
(
ProctoredExamStudentAttemptStatus
.
rejected
,
ProctoredExamStudentAttemptStatus
.
started
),
...
...
@@ -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
,
{
'status'
:
ProctoredExamStudentAttemptStatus
.
ready_to_start
,
'short_description'
:
'Taking As Proctored Exam'
,
...
...
@@ -2295,6 +2328,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
@ddt.data
(
ProctoredExamStudentAttemptStatus
.
eligible
,
ProctoredExamStudentAttemptStatus
.
created
,
ProctoredExamStudentAttemptStatus
.
download_software_clicked
,
ProctoredExamStudentAttemptStatus
.
ready_to_start
,
ProctoredExamStudentAttemptStatus
.
started
,
ProctoredExamStudentAttemptStatus
.
ready_to_submit
,
...
...
@@ -2357,6 +2391,7 @@ class ProctoredExamApiTests(LoggedInTestCase):
@ddt.data
(
ProctoredExamStudentAttemptStatus
.
eligible
,
ProctoredExamStudentAttemptStatus
.
created
,
ProctoredExamStudentAttemptStatus
.
download_software_clicked
,
ProctoredExamStudentAttemptStatus
.
submitted
,
ProctoredExamStudentAttemptStatus
.
verified
,
ProctoredExamStudentAttemptStatus
.
rejected
,
...
...
edx_proctoring/tests/test_models.py
View file @
5cb630bd
...
...
@@ -121,6 +121,21 @@ class ProctoredExamStudentAttemptTests(LoggedInTestCase):
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
"""
Deleting the proctored exam attempt creates an entry in the history table.
...
...
@@ -132,6 +147,7 @@ class ProctoredExamStudentAttemptTests(LoggedInTestCase):
external_id
=
'123aXqe3'
,
time_limit_mins
=
90
)
attempt
=
ProctoredExamStudentAttempt
.
objects
.
create
(
proctored_exam_id
=
proctored_exam
.
id
,
user_id
=
1
,
...
...
edx_proctoring/tests/test_services.py
View file @
5cb630bd
...
...
@@ -109,6 +109,17 @@ class MockInstructorService(object):
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
):
"""
Tests for ProctoringService
...
...
edx_proctoring/tests/test_views.py
View file @
5cb630bd
...
...
@@ -1030,6 +1030,47 @@ class TestStudentProctoredExamAttempt(LoggedInTestCase):
response_data
=
json
.
loads
(
response
.
content
)
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
(
(
'submit'
,
ProctoredExamStudentAttemptStatus
.
submitted
),
(
'decline'
,
ProctoredExamStudentAttemptStatus
.
declined
)
...
...
@@ -1498,13 +1539,17 @@ class TestStudentProctoredExamAttempt(LoggedInTestCase):
attempt_data
)
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
(
reverse
(
'edx_proctoring.proctored_exam.attempt.collection'
)
)
self
.
assertEqual
(
response
.
status_code
,
200
)
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
):
"""
...
...
edx_proctoring/utils.py
View file @
5cb630bd
...
...
@@ -16,6 +16,7 @@ from edx_proctoring.models import (
ProctoredExamStudentAttemptHistory
,
)
from
edx_proctoring
import
constants
from
edx_proctoring.runtime
import
get_runtime_service
log
=
logging
.
getLogger
(
__name__
)
...
...
@@ -128,3 +129,69 @@ def has_client_app_shutdown(attempt):
elapsed_time
=
(
datetime
.
now
(
pytz
.
UTC
)
-
attempt
[
'last_poll_timestamp'
])
.
total_seconds
()
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 @
5cb630bd
...
...
@@ -424,6 +424,12 @@ class StudentProctoredExamAttempt(AuthenticatedAPIView):
request
.
user
.
id
,
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'
:
exam_attempt_id
=
update_attempt_status
(
attempt
[
'proctored_exam'
][
'id'
],
...
...
@@ -569,7 +575,7 @@ class StudentProctoredExamAttemptCollection(AuthenticatedAPIView):
else
:
response_dict
=
{
'in_timed_exam'
:
False
,
'is_proctored'
:
False
,
'is_proctored'
:
False
}
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