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
0de557b0
Commit
0de557b0
authored
Jul 21, 2015
by
Chris Dodge
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Build out review callback support for SoftwareSecure
parent
aae446e6
Expand all
Hide whitespace changes
Inline
Side-by-side
Showing
12 changed files
with
632 additions
and
9 deletions
+632
-9
edx_proctoring/backends/backend.py
+7
-0
edx_proctoring/backends/null.py
+5
-0
edx_proctoring/backends/software_secure.py
+115
-2
edx_proctoring/backends/tests/test_backend.py
+15
-0
edx_proctoring/backends/tests/test_review_payload.py
+98
-0
edx_proctoring/backends/tests/test_software_secure.py
+211
-6
edx_proctoring/callbacks.py
+29
-0
edx_proctoring/exceptions.py
+14
-0
edx_proctoring/migrations/0004_auto__add_proctoredexamsoftwaresecurecomment__add_proctoredexamsoftwar.py
+0
-0
edx_proctoring/models.py
+78
-0
edx_proctoring/tests/test_views.py
+55
-1
edx_proctoring/urls.py
+5
-0
No files found.
edx_proctoring/backends/backend.py
View file @
0de557b0
...
...
@@ -43,3 +43,10 @@ class ProctoringBackendProvider(object):
the corresponding desktop software
"""
raise
NotImplementedError
()
@abc.abstractmethod
def
on_review_callback
(
self
,
payload
):
"""
Called when the reviewing 3rd party service posts back the results
"""
raise
NotImplementedError
()
edx_proctoring/backends/null.py
View file @
0de557b0
...
...
@@ -36,3 +36,8 @@ class NullBackendProvider(ProctoringBackendProvider):
the corresponding desktop software
"""
return
None
def
on_review_callback
(
self
,
payload
):
"""
Called when the reviewing 3rd party service posts back the results
"""
edx_proctoring/backends/software_secure.py
View file @
0de557b0
...
...
@@ -13,8 +13,19 @@ import json
import
logging
from
edx_proctoring.backends.backend
import
ProctoringBackendProvider
from
edx_proctoring.exceptions
import
BackendProvideCannotRegisterAttempt
from
edx_proctoring.exceptions
import
(
BackendProvideCannotRegisterAttempt
,
StudentExamAttemptDoesNotExistsException
,
ProctoredExamSuspiciousLookup
,
ProctoredExamReviewAlreadyExists
,
)
from
edx_proctoring
.
models
import
(
ProctoredExamSoftwareSecureReview
,
ProctoredExamSoftwareSecureComment
,
ProctoredExamStudentAttempt
,
ProctoredExamStudentAttemptHistory
)
log
=
logging
.
getLogger
(
__name__
)
...
...
@@ -97,6 +108,108 @@ class SoftwareSecureBackendProvider(ProctoringBackendProvider):
"""
return
self
.
software_download_url
def
on_review_callback
(
self
,
payload
):
"""
Called when the reviewing 3rd party service posts back the results
Documentation on the data format can be found from SoftwareSecure's
documentation named "Reviewer Data Transfer"
"""
log_msg
=
(
'Received callback from SoftwareSecure with review data: {payload}'
.
format
(
payload
=
payload
)
)
log
.
info
(
log_msg
)
# payload from SoftwareSecure is a JSON payload
# which has been converted to a dict by our caller
data
=
payload
[
'payload'
]
# what we consider the external_id is SoftwareSecure's 'ssiRecordLocator'
external_id
=
data
[
'examMetaData'
][
'ssiRecordLocator'
]
# what we consider the attempt_code is SoftwareSecure's 'examCode'
attempt_code
=
data
[
'examMetaData'
][
'examCode'
]
# do a lookup on the attempt by examCode, and compare the
# passed in ssiRecordLocator and make sure it matches
# what we recorded as the external_id. We need to look in both
# the attempt table as well as the archive table
attempt_obj
=
ProctoredExamStudentAttempt
.
objects
.
get_exam_attempt_by_code
(
attempt_code
)
if
not
attempt_obj
:
# try archive table
attempt_obj
=
ProctoredExamStudentAttemptHistory
.
get_exam_attempt_by_code
(
attempt_code
)
if
not
attempt_obj
:
# still can't find, error out
err_msg
=
(
'Could not locate attempt_code: {attempt_code}'
.
format
(
attempt_code
=
attempt_code
)
)
raise
StudentExamAttemptDoesNotExistsException
(
err_msg
)
# then make sure we have the right external_id
if
attempt_obj
.
external_id
!=
external_id
:
err_msg
=
(
'Found attempt_code {attempt_code}, but the recorded external_id did not '
'match the ssiRecordLocator that had been recorded previously. Has {existing} '
'but received {received}!'
.
format
(
attempt_code
=
attempt_code
,
existing
=
attempt_obj
.
external_id
,
received
=
external_id
)
)
raise
ProctoredExamSuspiciousLookup
(
err_msg
)
# do we already have a review for this attempt?!? It should not be updated!
review
=
ProctoredExamSoftwareSecureReview
.
get_review_by_attempt_code
(
attempt_code
)
if
review
:
err_msg
=
(
'We already have a review submitted from SoftwareSecure regarding '
'attempt_code {attempt_code}. We do not allow for updates!'
.
format
(
attempt_code
=
attempt_code
)
)
raise
ProctoredExamReviewAlreadyExists
(
err_msg
)
# do some limited parsing of the JSON payload
review_status
=
data
[
'reviewStatus'
]
video_review_link
=
data
[
'videoReviewLink'
]
# make a new record in the review table
review
=
ProctoredExamSoftwareSecureReview
(
attempt_code
=
attempt_code
,
raw_data
=
json
.
dumps
(
payload
),
review_status
=
review_status
,
video_url
=
video_review_link
,
)
review
.
save
()
# go through and populate all of the specific comments
for
comment
in
data
[
'webCamComments'
]:
self
.
_save_review_comment
(
review
,
comment
)
for
comment
in
data
[
'desktopComments'
]:
self
.
_save_review_comment
(
review
,
comment
)
def
_save_review_comment
(
self
,
review
,
comment
):
"""
Helper method to save a review comment
"""
comment
=
ProctoredExamSoftwareSecureComment
(
review
=
review
,
start_time
=
comment
[
'eventStart'
],
stop_time
=
comment
[
'eventFinish'
],
duration
=
comment
[
'duration'
],
comment
=
comment
[
'comments'
],
status
=
comment
[
'eventStatus'
]
)
comment
.
save
()
def
_encrypt_password
(
self
,
key
,
pwd
):
"""
Encrypt the exam passwork with the given key
...
...
edx_proctoring/backends/tests/test_backend.py
View file @
0de557b0
...
...
@@ -39,6 +39,11 @@ class TestBackendProvider(ProctoringBackendProvider):
"""
return
None
def
on_review_callback
(
self
,
payload
):
"""
Called when the reviewing 3rd party service posts back the results
"""
class
PassthroughBackendProvider
(
ProctoringBackendProvider
):
"""
...
...
@@ -81,6 +86,12 @@ class PassthroughBackendProvider(ProctoringBackendProvider):
"""
return
super
(
PassthroughBackendProvider
,
self
)
.
get_software_download_url
()
def
on_review_callback
(
self
,
payload
):
"""
Called when the reviewing 3rd party service posts back the results
"""
return
super
(
PassthroughBackendProvider
,
self
)
.
on_review_callback
(
payload
)
class
TestBackends
(
TestCase
):
"""
...
...
@@ -106,6 +117,9 @@ class TestBackends(TestCase):
with
self
.
assertRaises
(
NotImplementedError
):
provider
.
get_software_download_url
()
with
self
.
assertRaises
(
NotImplementedError
):
provider
.
on_review_callback
(
None
)
def
test_null_provider
(
self
):
"""
Assert that the Null provider does nothing
...
...
@@ -117,3 +131,4 @@ class TestBackends(TestCase):
self
.
assertIsNone
(
provider
.
start_exam_attempt
(
None
,
None
))
self
.
assertIsNone
(
provider
.
stop_exam_attempt
(
None
,
None
))
self
.
assertIsNone
(
provider
.
get_software_download_url
())
self
.
assertIsNone
(
provider
.
on_review_callback
(
None
))
edx_proctoring/backends/tests/test_review_payload.py
0 → 100644
View file @
0de557b0
"""
Some canned data for SoftwareSecure callback testing.
"""
TEST_REVIEW_PAYLOAD
=
'''
{
"orgCallbackURL": "http://reds.rpexams.com/reviewerdatatransfer",
"payload": {
"examDate": "Jul 15 2015 1:13AM",
"examProcessingStatus": "Review Completed",
"examTakerEmail": "4d07a01a-1502-422e-b943-93ac04dc6ced",
"examTakerFirstName": "John",
"examTakerLastName": "Doe",
"keySetVersion": "",
"examApiData": {
"duration": 1,
"examCode": "4d07a01a-1502-422e-b943-93ac04dc6ced",
"examName": "edX Exams",
"examPassword": "hQxvA8iUKKlsqKt0fQVBaXqmAziGug4NfxUChg94eGacYDcFwaIyBA==",
"examSponsor": "edx LMS",
"examUrl": "http://localhost:8000/api/edx_proctoring/proctoring_launch_callback/start_exam/4d07a01a-1502-422e-b943-93ac04dc6ced",
"orgExtra": {
"courseID": "edX/DemoX/Demo_Course",
"examEndDate": "Wed, 15 Jul 2015 05:11:31 GMT",
"examID": 6,
"examStartDate": "Wed, 15 Jul 2015 05:10:31 GMT",
"noOfStudents": 1
},
"organization": "edx",
"reviewedExam": true,
"reviewerNotes": "Closed Book",
"ssiProduct": "rp-now"
},
"overAllComments": ";Candidates should always wear suit and tie for exams.",
"reviewStatus": "Clean",
"userPhotoBase64String": "",
"videoReviewLink": "http://www.remoteproctor.com/AdminSite/Account/Reviewer/DirectLink-Generic.aspx?ID=foo",
"examMetaData": {
"examCode": "$attempt_code",
"examName": "edX Exams",
"examSponsor": "edx LMS",
"organization": "edx",
"reviewedExam": "True",
"reviewerNotes": "Closed Book",
"simulatedExam": "False",
"ssiExamToken": "4E44F7AA-275D-4741-B531-73AE2407ECFB",
"ssiProduct": "rp-now",
"ssiRecordLocator": "$external_id"
},
"desktopComments": [
{
"comments": "Browsing other websites",
"duration": 88,
"eventFinish": 88,
"eventStart": 12,
"eventStatus": "Suspicious"
},
{
"comments": "Browsing local computer",
"duration": 88,
"eventFinish": 88,
"eventStart": 15,
"eventStatus": "Rules Violation"
},
{
"comments": "Student never entered the exam.",
"duration": 88,
"eventFinish": 88,
"eventStart": 87,
"eventStatus": "Clean"
}
],
"webCamComments": [
{
"comments": "Photo ID not provided",
"duration": 796,
"eventFinish": 796,
"eventStart": 0,
"eventStatus": "Suspicious"
},
{
"comments": "Exam environment not confirmed",
"duration": 796,
"eventFinish": 796,
"eventStart": 10,
"eventStatus": "Rules Violation"
},
{
"comments": "Looking away from computer",
"duration": 796,
"eventFinish": 796,
"eventStart": 107,
"eventStatus": "Rules Violation"
}
]
}
}
'''
edx_proctoring/backends/tests/test_software_secure.py
View file @
0de557b0
...
...
@@ -2,9 +2,10 @@
Tests for the software_secure module
"""
import
json
from
string
import
Template
# pylint: disable=deprecated-module
from
mock
import
patch
from
httmock
import
all_requests
,
HTTMock
import
json
from
django.test
import
TestCase
from
django.contrib.auth.models
import
User
...
...
@@ -17,11 +18,23 @@ from edx_proctoring.api import (
get_exam_attempt_by_id
,
create_exam
,
create_exam_attempt
,
get_exam_attempt_by_id
,
remove_exam_attempt
,
)
from
edx_proctoring.exceptions
import
(
StudentExamAttemptDoesNotExistsException
,
ProctoredExamSuspiciousLookup
,
ProctoredExamReviewAlreadyExists
,
)
from
edx_proctoring
.
models
import
(
ProctoredExamSoftwareSecureReview
,
ProctoredExamSoftwareSecureComment
,
)
from
edx_proctoring.backends.tests.test_review_payload
import
TEST_REVIEW_PAYLOAD
@all_requests
def
response_content
(
url
,
request
):
# pylint: disable=unused-argument
def
mock_
response_content
(
url
,
request
):
# pylint: disable=unused-argument
"""
Mock HTTP response from SoftwareSecure
"""
...
...
@@ -34,7 +47,7 @@ def response_content(url, request): # pylint: disable=unused-argument
@all_requests
def
response_error
(
url
,
request
):
# pylint: disable=unused-argument
def
mock_
response_error
(
url
,
request
):
# pylint: disable=unused-argument
"""
Mock HTTP response from SoftwareSecure
"""
...
...
@@ -114,7 +127,7 @@ class SoftwareSecureTests(TestCase):
is_proctored
=
True
)
with
HTTMock
(
response_content
):
with
HTTMock
(
mock_
response_content
):
attempt_id
=
create_exam_attempt
(
exam_id
,
self
.
user
.
id
,
taking_as_proctored
=
True
)
self
.
assertIsNotNone
(
attempt_id
)
...
...
@@ -143,7 +156,7 @@ class SoftwareSecureTests(TestCase):
is_proctored
=
True
)
with
HTTMock
(
response_content
):
with
HTTMock
(
mock_
response_content
):
attempt_id
=
create_exam_attempt
(
exam_id
,
self
.
user
.
id
,
taking_as_proctored
=
True
)
self
.
assertIsNotNone
(
attempt_id
)
...
...
@@ -161,7 +174,7 @@ class SoftwareSecureTests(TestCase):
)
# now try a failing request
with
HTTMock
(
response_error
):
with
HTTMock
(
mock_
response_error
):
with
self
.
assertRaises
(
BackendProvideCannotRegisterAttempt
):
create_exam_attempt
(
exam_id
,
self
.
user
.
id
,
taking_as_proctored
=
True
)
...
...
@@ -202,3 +215,195 @@ class SoftwareSecureTests(TestCase):
provider
=
get_backend_provider
()
self
.
assertIsNone
(
provider
.
stop_exam_attempt
(
None
,
None
))
def
test_review_callback
(
self
):
"""
Simulates a happy path when SoftwareSecure calls us back with a payload
"""
provider
=
get_backend_provider
()
exam_id
=
create_exam
(
course_id
=
'foo/bar/baz'
,
content_id
=
'content'
,
exam_name
=
'Sample Exam'
,
time_limit_mins
=
10
,
is_proctored
=
True
)
# be sure to use the mocked out SoftwareSecure handlers
with
HTTMock
(
mock_response_content
):
attempt_id
=
create_exam_attempt
(
exam_id
,
self
.
user
.
id
,
taking_as_proctored
=
True
)
attempt
=
get_exam_attempt_by_id
(
attempt_id
)
self
.
assertIsNotNone
(
attempt
[
'external_id'
])
test_payload
=
Template
(
TEST_REVIEW_PAYLOAD
)
.
substitute
(
attempt_code
=
attempt
[
'attempt_code'
],
external_id
=
attempt
[
'external_id'
]
)
provider
.
on_review_callback
(
json
.
loads
(
test_payload
))
# make sure that what we have in the Database matches what we expect
review
=
ProctoredExamSoftwareSecureReview
.
get_review_by_attempt_code
(
attempt
[
'attempt_code'
])
self
.
assertIsNotNone
(
review
)
self
.
assertEqual
(
review
.
review_status
,
'Clean'
)
self
.
assertEqual
(
review
.
video_url
,
'http://www.remoteproctor.com/AdminSite/Account/Reviewer/DirectLink-Generic.aspx?ID=foo'
)
self
.
assertIsNotNone
(
review
.
raw_data
)
# now check the comments that were stored
comments
=
ProctoredExamSoftwareSecureComment
.
objects
.
filter
(
review_id
=
review
.
id
)
self
.
assertEqual
(
len
(
comments
),
6
)
def
test_review_bad_code
(
self
):
"""
Asserts raising of an exception if we get a report for
an attempt code which does not exist
"""
provider
=
get_backend_provider
()
test_payload
=
Template
(
TEST_REVIEW_PAYLOAD
)
.
substitute
(
attempt_code
=
'not-here'
,
external_id
=
'also-not-here'
)
with
self
.
assertRaises
(
StudentExamAttemptDoesNotExistsException
):
provider
.
on_review_callback
(
json
.
loads
(
test_payload
))
def
test_review_mistmatched_tokens
(
self
):
"""
Asserts raising of an exception if we get a report for
an attempt code which has a external_id which does not
match the report
"""
provider
=
get_backend_provider
()
exam_id
=
create_exam
(
course_id
=
'foo/bar/baz'
,
content_id
=
'content'
,
exam_name
=
'Sample Exam'
,
time_limit_mins
=
10
,
is_proctored
=
True
)
# be sure to use the mocked out SoftwareSecure handlers
with
HTTMock
(
mock_response_content
):
attempt_id
=
create_exam_attempt
(
exam_id
,
self
.
user
.
id
,
taking_as_proctored
=
True
)
attempt
=
get_exam_attempt_by_id
(
attempt_id
)
self
.
assertIsNotNone
(
attempt
[
'external_id'
])
test_payload
=
Template
(
TEST_REVIEW_PAYLOAD
)
.
substitute
(
attempt_code
=
attempt
[
'attempt_code'
],
external_id
=
'bogus'
)
with
self
.
assertRaises
(
ProctoredExamSuspiciousLookup
):
provider
.
on_review_callback
(
json
.
loads
(
test_payload
))
def
test_review_on_archived_attempt
(
self
):
"""
Make sure we can process a review report for
an attempt which has been archived
"""
provider
=
get_backend_provider
()
exam_id
=
create_exam
(
course_id
=
'foo/bar/baz'
,
content_id
=
'content'
,
exam_name
=
'Sample Exam'
,
time_limit_mins
=
10
,
is_proctored
=
True
)
# be sure to use the mocked out SoftwareSecure handlers
with
HTTMock
(
mock_response_content
):
attempt_id
=
create_exam_attempt
(
exam_id
,
self
.
user
.
id
,
taking_as_proctored
=
True
)
attempt
=
get_exam_attempt_by_id
(
attempt_id
)
self
.
assertIsNotNone
(
attempt
[
'external_id'
])
test_payload
=
Template
(
TEST_REVIEW_PAYLOAD
)
.
substitute
(
attempt_code
=
attempt
[
'attempt_code'
],
external_id
=
attempt
[
'external_id'
]
)
# now delete the attempt, which puts it into the archive table
remove_exam_attempt
(
attempt_id
)
# now process the report
provider
.
on_review_callback
(
json
.
loads
(
test_payload
))
# make sure that what we have in the Database matches what we expect
review
=
ProctoredExamSoftwareSecureReview
.
get_review_by_attempt_code
(
attempt
[
'attempt_code'
])
self
.
assertIsNotNone
(
review
)
self
.
assertEqual
(
review
.
review_status
,
'Clean'
)
self
.
assertEqual
(
review
.
video_url
,
'http://www.remoteproctor.com/AdminSite/Account/Reviewer/DirectLink-Generic.aspx?ID=foo'
)
self
.
assertIsNotNone
(
review
.
raw_data
)
# now check the comments that were stored
comments
=
ProctoredExamSoftwareSecureComment
.
objects
.
filter
(
review_id
=
review
.
id
)
self
.
assertEqual
(
len
(
comments
),
6
)
def
test_review_resubmission
(
self
):
"""
Tests that an exception is raised if a review report is resubmitted for the same
attempt
"""
provider
=
get_backend_provider
()
exam_id
=
create_exam
(
course_id
=
'foo/bar/baz'
,
content_id
=
'content'
,
exam_name
=
'Sample Exam'
,
time_limit_mins
=
10
,
is_proctored
=
True
)
# be sure to use the mocked out SoftwareSecure handlers
with
HTTMock
(
mock_response_content
):
attempt_id
=
create_exam_attempt
(
exam_id
,
self
.
user
.
id
,
taking_as_proctored
=
True
)
attempt
=
get_exam_attempt_by_id
(
attempt_id
)
self
.
assertIsNotNone
(
attempt
[
'external_id'
])
test_payload
=
Template
(
TEST_REVIEW_PAYLOAD
)
.
substitute
(
attempt_code
=
attempt
[
'attempt_code'
],
external_id
=
attempt
[
'external_id'
]
)
provider
.
on_review_callback
(
json
.
loads
(
test_payload
))
# now call again
with
self
.
assertRaises
(
ProctoredExamReviewAlreadyExists
):
provider
.
on_review_callback
(
json
.
loads
(
test_payload
))
edx_proctoring/callbacks.py
View file @
0de557b0
...
...
@@ -2,14 +2,22 @@
Various callback paths
"""
import
logging
from
django.template
import
Context
,
loader
from
django.http
import
HttpResponse
from
rest_framework.views
import
APIView
from
rest_framework.response
import
Response
from
edx_proctoring.api
import
(
get_exam_attempt_by_code
,
mark_exam_attempt_as_ready
,
)
from
edx_proctoring.backends
import
get_backend_provider
log
=
logging
.
getLogger
(
__name__
)
def
start_exam_callback
(
request
,
attempt_code
):
# pylint: disable=unused-argument
"""
...
...
@@ -34,3 +42,24 @@ def start_exam_callback(request, attempt_code): # pylint: disable=unused-argume
template
=
loader
.
get_template
(
'proctoring/proctoring_launch_callback.html'
)
return
HttpResponse
(
template
.
render
(
Context
({})))
class
ExamReviewCallback
(
APIView
):
"""
This endpoint is called by a 3rd party proctoring review service when
there are results available for us to record
"""
def
post
(
self
,
request
):
"""
Post callback handler
"""
provider
=
get_backend_provider
()
# call down into the underlying provider code
provider
.
on_review_callback
(
request
.
DATA
)
return
Response
(
data
=
'OK'
,
status
=
200
)
edx_proctoring/exceptions.py
View file @
0de557b0
...
...
@@ -55,3 +55,17 @@ class ProctoredExamPermissionDenied(ProctoredBaseException):
"""
Raised when the calling user does not have access to the requested object.
"""
class
ProctoredExamSuspiciousLookup
(
ProctoredBaseException
):
"""
Raised when a lookup on the student attempt table does not fully match
all expected security keys
"""
class
ProctoredExamReviewAlreadyExists
(
ProctoredBaseException
):
"""
Raised when a lookup on the student attempt table does not fully match
all expected security keys
"""
edx_proctoring/migrations/0004_auto__add_proctoredexamsoftwaresecurecomment__add_proctoredexamsoftwar.py
0 → 100644
View file @
0de557b0
This diff is collapsed.
Click to expand it.
edx_proctoring/models.py
View file @
0de557b0
...
...
@@ -304,6 +304,18 @@ class ProctoredExamStudentAttemptHistory(TimeStampedModel):
student_name
=
models
.
CharField
(
max_length
=
255
)
@classmethod
def
get_exam_attempt_by_code
(
cls
,
attempt_code
):
"""
Returns the Student Exam Attempt object if found
else Returns None.
"""
try
:
exam_attempt_obj
=
cls
.
objects
.
get
(
attempt_code
=
attempt_code
)
except
ObjectDoesNotExist
:
# pylint: disable=no-member
exam_attempt_obj
=
None
return
exam_attempt_obj
@receiver
(
pre_delete
,
sender
=
ProctoredExamStudentAttempt
)
def
on_attempt_deleted
(
sender
,
instance
,
**
kwargs
):
# pylint: disable=unused-argument
...
...
@@ -473,3 +485,69 @@ def _make_archive_copy(item):
value
=
item
.
value
)
archive_object
.
save
()
class
ProctoredExamSoftwareSecureReview
(
TimeStampedModel
):
"""
This is where we store the proctored exam review feedback
from the exam reviewers
"""
# which student attempt is this feedback for?
attempt_code
=
models
.
CharField
(
max_length
=
255
,
db_index
=
True
)
# overall status of the review
review_status
=
models
.
CharField
(
max_length
=
255
)
# The raw payload that was received back from the
# reviewing service
raw_data
=
models
.
TextField
()
# URL for the exam video that had been reviewed
video_url
=
models
.
TextField
()
class
Meta
:
""" Meta class for this Django model """
db_table
=
'proctoring_proctoredexamsoftwaresecurereview'
verbose_name
=
'proctored exam software secure review'
@classmethod
def
get_review_by_attempt_code
(
cls
,
attempt_code
):
"""
Does a lookup by attempt_code
"""
try
:
review
=
cls
.
objects
.
get
(
attempt_code
=
attempt_code
)
return
review
except
cls
.
DoesNotExist
:
# pylint: disable=no-member
return
None
class
ProctoredExamSoftwareSecureComment
(
TimeStampedModel
):
"""
This is where we store the proctored exam review comments
from the exam reviewers
"""
# which student attempt is this feedback for?
review
=
models
.
ForeignKey
(
ProctoredExamSoftwareSecureReview
)
# start time in the video, in seconds, regarding the comment
start_time
=
models
.
IntegerField
()
# stop time in the video, in seconds, regarding the comment
stop_time
=
models
.
IntegerField
()
# length of time, in seconds, regarding the comment
duration
=
models
.
IntegerField
()
# the text that the reviewer typed in
comment
=
models
.
TextField
()
# reviewers opinion regarding exam validitity based on the comment
status
=
models
.
CharField
(
max_length
=
255
)
class
Meta
:
""" Meta class for this Django model """
db_table
=
'proctoring_proctoredexamstudentattemptcomment'
verbose_name
=
'proctored exam software secure comment'
edx_proctoring/tests/test_views.py
View file @
0de557b0
...
...
@@ -3,6 +3,8 @@
All tests for the proctored_exams.py
"""
import
json
from
httmock
import
HTTMock
from
string
import
Template
# pylint: disable=deprecated-module
from
datetime
import
datetime
from
django.test.client
import
Client
from
django.core.urlresolvers
import
reverse
,
NoReverseMatch
...
...
@@ -10,7 +12,9 @@ import pytz
from
edx_proctoring.models
import
ProctoredExam
,
ProctoredExamStudentAttempt
,
ProctoredExamStudentAllowance
from
edx_proctoring.views
import
require_staff
from
edx_proctoring.api
import
(
get_exam_attempt_by_id
create_exam
,
create_exam_attempt
,
get_exam_attempt_by_id
,
)
from
django.contrib.auth.models
import
User
...
...
@@ -20,6 +24,8 @@ from .utils import (
from
mock
import
Mock
from
edx_proctoring.urls
import
urlpatterns
from
edx_proctoring.backends.tests.test_review_payload
import
TEST_REVIEW_PAYLOAD
from
edx_proctoring.backends.tests.test_software_secure
import
mock_response_content
class
ProctoredExamsApiTests
(
LoggedInTestCase
):
...
...
@@ -941,6 +947,54 @@ class TestStudentProctoredExamAttempt(LoggedInTestCase):
)
self
.
assertEqual
(
response
.
status_code
,
404
)
def
test_review_callback
(
self
):
"""
Simulates a callback from the proctoring service with the
review data
"""
exam_id
=
create_exam
(
course_id
=
'foo/bar/baz'
,
content_id
=
'content'
,
exam_name
=
'Sample Exam'
,
time_limit_mins
=
10
,
is_proctored
=
True
)
# be sure to use the mocked out SoftwareSecure handlers
with
HTTMock
(
mock_response_content
):
attempt_id
=
create_exam_attempt
(
exam_id
,
self
.
user
.
id
,
taking_as_proctored
=
True
)
attempt
=
get_exam_attempt_by_id
(
attempt_id
)
self
.
assertIsNotNone
(
attempt
[
'external_id'
])
test_payload
=
Template
(
TEST_REVIEW_PAYLOAD
)
.
substitute
(
attempt_code
=
attempt
[
'attempt_code'
],
external_id
=
attempt
[
'external_id'
]
)
response
=
self
.
client
.
post
(
reverse
(
'edx_proctoring.anonymous.proctoring_review_callback'
),
data
=
test_payload
,
content_type
=
'application/json'
)
self
.
assertEqual
(
response
.
status_code
,
200
)
def
test_review_callback_get
(
self
):
"""
We don't support any http METHOD other than GET
"""
response
=
self
.
client
.
get
(
reverse
(
'edx_proctoring.anonymous.proctoring_review_callback'
),
)
self
.
assertEqual
(
response
.
status_code
,
405
)
class
TestExamAllowanceView
(
LoggedInTestCase
):
"""
...
...
edx_proctoring/urls.py
View file @
0de557b0
...
...
@@ -71,5 +71,10 @@ urlpatterns = patterns( # pylint: disable=invalid-name
callbacks
.
start_exam_callback
,
name
=
'edx_proctoring.anonymous.proctoring_launch_callback.start_exam'
),
url
(
r'edx_proctoring/proctoring_review_callback/$'
,
callbacks
.
ExamReviewCallback
.
as_view
(),
name
=
'edx_proctoring.anonymous.proctoring_review_callback'
),
url
(
r'^'
,
include
(
'rest_framework.urls'
,
namespace
=
'rest_framework'
))
)
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