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
a5f0f316
Commit
a5f0f316
authored
Jun 26, 2015
by
Chris Dodge
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Initial integration into the LMS
parent
5d790ab4
Hide whitespace changes
Inline
Side-by-side
Showing
12 changed files
with
395 additions
and
20 deletions
+395
-20
edx_proctoring/api.py
+66
-1
edx_proctoring/serializers.py
+5
-4
edx_proctoring/services.py
+43
-0
edx_proctoring/static/proctoring/js/proctored_app.js
+8
-0
edx_proctoring/static/proctoring/js/proctored_exam_model.js
+47
-0
edx_proctoring/static/proctoring/js/proctored_exam_view.js
+58
-0
edx_proctoring/static/proctoring/spec/proctored_exam_spec.js
+73
-0
edx_proctoring/templates/proctoring/proctored-exam-status.underscore
+13
-0
edx_proctoring/templates/proctoring/seq_timed_exam_completed.html
+6
-0
edx_proctoring/templates/proctoring/seq_timed_exam_entrance.html
+25
-0
edx_proctoring/templates/proctoring/seq_timed_exam_expired.html
+5
-0
edx_proctoring/views.py
+46
-15
No files found.
edx_proctoring/api.py
View file @
a5f0f316
...
...
@@ -7,7 +7,10 @@ In-Proc API (aka Library) for the edx_proctoring subsystem. This is not to be co
API which is in the views.py file, per edX coding standards
"""
import
pytz
from
datetime
import
datetime
from
datetime
import
datetime
,
timedelta
from
django.template
import
Context
,
Template
,
loader
from
django.core.urlresolvers
import
reverse
from
edx_proctoring.exceptions
import
(
ProctoredExamAlreadyExists
,
ProctoredExamNotFoundException
,
StudentExamAttemptAlreadyExistsException
,
StudentExamAttemptDoesNotExistsException
)
...
...
@@ -131,6 +134,14 @@ def remove_allowance_for_user(exam_id, user_id, key):
student_allowance
.
delete
()
def
get_exam_attempt
(
exam_id
,
user_id
):
"""
Return an existing exam attempt for the given student
"""
exam_attempt_obj
=
ProctoredExamStudentAttempt
.
get_student_exam_attempt
(
exam_id
,
user_id
)
return
exam_attempt_obj
.
__dict__
if
exam_attempt_obj
else
None
def
start_exam_attempt
(
exam_id
,
user_id
,
external_id
):
"""
Signals the beginning of an exam attempt for a given
...
...
@@ -195,4 +206,58 @@ def get_active_exams_for_user(user_id, course_id=None):
'attempt'
:
active_exam_serialized_data
,
'allowances'
:
allowance_serialized_data
})
return
result
def
get_student_view
(
user_id
,
course_id
,
content_id
,
context
):
"""
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
content to display, then None will be returned and the caller should
render it's own view
"""
has_started_exam
=
False
has_finished_exam
=
False
has_time_expired
=
False
student_view_template
=
None
exam_id
=
None
try
:
exam
=
get_exam_by_content_id
(
course_id
,
content_id
)
print
'**** exam = {}'
.
format
(
exam
)
exam_id
=
exam
[
'id'
]
except
Exception
,
ex
:
print
'*** exception = {}'
.
format
(
unicode
(
ex
))
exam_id
=
create_exam
(
course_id
=
course_id
,
content_id
=
unicode
(
content_id
),
exam_name
=
context
[
'display_name'
],
time_limit_mins
=
context
[
'default_time_limit_mins'
]
)
attempt
=
get_exam_attempt
(
exam_id
,
user_id
)
has_started_exam
=
attempt
is
not
None
if
attempt
:
now_utc
=
datetime
.
now
(
pytz
.
UTC
)
expires_at
=
attempt
[
'started_at'
]
+
timedelta
(
minutes
=
context
[
'default_time_limit_mins'
])
has_time_expired
=
now_utc
>
expires_at
if
not
has_started_exam
:
student_view_template
=
'proctoring/seq_timed_exam_entrance.html'
elif
has_finished_exam
:
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
)
django_context
.
update
({
'exam_id'
:
exam_id
,
'enter_exam_endpoint'
:
reverse
(
'edx_proctoring.proctored_exam.attempt'
),
})
return
template
.
render
(
django_context
)
return
None
edx_proctoring/serializers.py
View file @
a5f0f316
...
...
@@ -21,7 +21,8 @@ class ProctoredExamSerializer(serializers.ModelSerializer):
"""
Serializer for the ProctoredExam Model.
"""
course_id
=
serializers
.
RegexField
(
settings
.
COURSE_ID_REGEX
,
required
=
True
)
id
=
serializers
.
IntegerField
(
required
=
True
)
course_id
=
serializers
.
CharField
(
required
=
True
)
content_id
=
serializers
.
CharField
(
required
=
True
)
external_id
=
serializers
.
CharField
(
required
=
True
)
exam_name
=
serializers
.
CharField
(
required
=
True
)
...
...
@@ -37,7 +38,7 @@ class ProctoredExamSerializer(serializers.ModelSerializer):
model
=
ProctoredExam
fields
=
(
"course_id"
,
"content_id"
,
"external_id"
,
"exam_name"
,
"
id"
,
"
course_id"
,
"content_id"
,
"external_id"
,
"exam_name"
,
"time_limit_mins"
,
"is_proctored"
,
"is_active"
)
...
...
@@ -52,7 +53,7 @@ class ProctoredExamStudentAttemptSerializer(serializers.ModelSerializer):
"""
model
=
ProctoredExamStudentAttempt
fields
=
(
"created"
,
"modified"
,
"user_id"
,
"started_at"
,
"completed_at"
,
"
id"
,
"
created"
,
"modified"
,
"user_id"
,
"started_at"
,
"completed_at"
,
"external_id"
,
"status"
)
...
...
@@ -67,5 +68,5 @@ class ProctoredExamStudentAllowanceSerializer(serializers.ModelSerializer):
"""
model
=
ProctoredExamStudentAllowance
fields
=
(
"created"
,
"modified"
,
"user_id"
,
"key"
,
"value"
"
id"
,
"
created"
,
"modified"
,
"user_id"
,
"key"
,
"value"
)
edx_proctoring/services.py
0 → 100644
View file @
a5f0f316
"""
A wrapper class around all methods exposed in api.py
"""
from
edx_proctoring
import
api
as
edx_proctoring_api
import
types
class
ProctoringService
(
object
):
"""
An xBlock service for xBlocks to talk to the Proctoring subsystem. This class basically introspects
and exposes all functions in the api libraries, so it is a direct pass through.
NOTE: This is a Singleton class. We should only have one instance of it!
"""
_instance
=
None
def
__new__
(
cls
,
*
args
,
**
kwargs
):
"""
This is the class factory to make sure this is a Singleton
"""
if
not
cls
.
_instance
:
cls
.
_instance
=
super
(
ProctoringService
,
cls
)
.
__new__
(
cls
,
*
args
,
**
kwargs
)
return
cls
.
_instance
def
__init__
(
self
):
"""
Class initializer, which just inspects the libraries and exposes the same functions
as a direct pass through
"""
self
.
_bind_to_module_functions
(
edx_proctoring_api
)
def
_bind_to_module_functions
(
self
,
module
):
"""
bind module functions. Since we use underscores to mean private methods, let's exclude those.
"""
for
attr_name
in
dir
(
module
):
attr
=
getattr
(
module
,
attr_name
,
None
)
if
isinstance
(
attr
,
types
.
FunctionType
)
and
not
attr_name
.
startswith
(
'_'
):
if
not
hasattr
(
self
,
attr_name
):
setattr
(
self
,
attr_name
,
attr
)
edx_proctoring/static/proctoring/js/proctored_app.js
0 → 100644
View file @
a5f0f316
$
(
function
()
{
var
proctored_exam_view
=
new
edx
.
coursware
.
proctored_exam
.
ProctoredExamView
({
el
:
$
(
".proctored_exam_status"
),
proctored_template
:
'#proctored-exam-status-tpl'
,
model
:
new
ProctoredExamModel
()
});
proctored_exam_view
.
render
();
});
edx_proctoring/static/proctoring/js/proctored_exam_model.js
0 → 100644
View file @
a5f0f316
(
function
(
Backbone
)
{
var
ProctoredExamModel
=
Backbone
.
Model
.
extend
({
/* we should probably pull this from a data attribute on the HTML */
url
:
'/api/edx_proctoring/v1/proctored_exam/attempt'
,
defaults
:
{
in_timed_exam
:
false
,
is_proctored
:
false
,
exam_display_name
:
''
,
exam_url_path
:
''
,
time_remaining_seconds
:
0
,
low_threshold
:
0
,
critically_low_threshold
:
0
,
lastFetched
:
new
Date
()
},
getRemainingSeconds
:
function
()
{
var
currentTime
=
(
new
Date
()).
getTime
();
var
lastFetched
=
this
.
get
(
'lastFetched'
).
getTime
();
var
totalSeconds
=
this
.
get
(
'time_remaining_seconds'
)
-
(
currentTime
-
lastFetched
)
/
1000
;
return
(
totalSeconds
>
0
)
?
totalSeconds
:
0
;
},
getFormattedRemainingTime
:
function
()
{
var
totalSeconds
=
this
.
getRemainingSeconds
();
var
hours
=
parseInt
(
totalSeconds
/
3600
)
%
24
;
var
minutes
=
parseInt
(
totalSeconds
/
60
)
%
60
;
var
seconds
=
Math
.
floor
(
totalSeconds
%
60
);
return
hours
+
":"
+
(
minutes
<
10
?
"0"
+
minutes
:
minutes
)
+
":"
+
(
seconds
<
10
?
"0"
+
seconds
:
seconds
);
},
getRemainingTimeState
:
function
()
{
var
totalSeconds
=
this
.
getRemainingSeconds
();
if
(
totalSeconds
>
this
.
get
(
'low_threshold'
))
{
return
""
;
}
else
if
(
totalSeconds
<=
this
.
get
(
'low_threshold'
)
&&
totalSeconds
>
this
.
get
(
'critically_low_threshold'
))
{
return
"low-time warning"
;
}
else
{
return
"low-time critical"
;
}
}
});
this
.
ProctoredExamModel
=
ProctoredExamModel
;
}).
call
(
this
,
Backbone
);
edx_proctoring/static/proctoring/js/proctored_exam_view.js
0 → 100644
View file @
a5f0f316
var
edx
=
edx
||
{};
(
function
(
Backbone
,
$
,
_
)
{
'use strict'
;
edx
.
coursware
=
edx
.
coursware
||
{};
edx
.
coursware
.
proctored_exam
=
{};
edx
.
coursware
.
proctored_exam
.
ProctoredExamView
=
Backbone
.
View
.
extend
({
initialize
:
function
(
options
)
{
this
.
$el
=
options
.
el
;
this
.
model
=
options
.
model
;
this
.
templateId
=
options
.
proctored_template
;
this
.
template
=
null
;
this
.
timerId
=
null
;
var
template_html
=
$
(
this
.
templateId
).
text
();
if
(
template_html
!==
null
)
{
/* don't assume this backbone view is running on a page with the underscore templates */
this
.
template
=
_
.
template
(
template_html
);
}
/* re-render if the model changes */
this
.
listenTo
(
this
.
model
,
'change'
,
this
.
modelChanged
);
/* make the async call to the backend REST API */
/* after it loads, the listenTo event will file and */
/* will call into the rendering */
this
.
model
.
fetch
();
},
modelChanged
:
function
()
{
this
.
render
();
},
render
:
function
()
{
if
(
this
.
template
!==
null
)
{
if
(
this
.
model
.
get
(
'in_timed_exam'
)
&&
this
.
model
.
get
(
'time_remaining_seconds'
)
>
0
)
{
var
html
=
this
.
template
(
this
.
model
.
toJSON
());
this
.
$el
.
html
(
html
);
this
.
$el
.
show
();
this
.
updateRemainingTime
(
this
);
this
.
timerId
=
setInterval
(
this
.
updateRemainingTime
,
1000
,
this
);
}
}
return
this
;
},
updateRemainingTime
:
function
(
self
)
{
self
.
$el
.
find
(
'div.exam-timer'
).
removeClass
(
"low-time warning critical"
);
self
.
$el
.
find
(
'div.exam-timer'
).
addClass
(
self
.
model
.
getRemainingTimeState
());
self
.
$el
.
find
(
'span#time_remaining_id b'
).
html
(
self
.
model
.
getFormattedRemainingTime
());
if
(
self
.
model
.
getRemainingSeconds
()
<=
0
)
{
clearInterval
(
self
.
timerId
);
// stop the timer once the time finishes.
// refresh the page when the timer expired
location
.
reload
();
}
}
});
this
.
edx
.
coursware
.
proctored_exam
.
ProctoredExamView
=
edx
.
coursware
.
proctored_exam
.
ProctoredExamView
;
}).
call
(
this
,
Backbone
,
$
,
_
);
edx_proctoring/static/proctoring/spec/proctored_exam_spec.js
0 → 100644
View file @
a5f0f316
define
([
'jquery'
,
'backbone'
,
'common/js/spec_helpers/template_helpers'
,
'js/courseware/base/models/proctored_exam_model'
,
'js/courseware/base/views/proctored_exam_view'
],
function
(
$
,
Backbone
,
TemplateHelpers
,
ProctoredExamModel
,
ProctoredExamView
)
{
'use strict'
;
describe
(
'Proctored Exam'
,
function
()
{
beforeEach
(
function
()
{
this
.
model
=
new
ProctoredExamModel
();
});
it
(
'model has properties'
,
function
()
{
expect
(
this
.
model
.
get
(
'in_timed_exam'
)).
toBeDefined
();
expect
(
this
.
model
.
get
(
'is_proctored'
)).
toBeDefined
();
expect
(
this
.
model
.
get
(
'exam_display_name'
)).
toBeDefined
();
expect
(
this
.
model
.
get
(
'exam_url_path'
)).
toBeDefined
();
expect
(
this
.
model
.
get
(
'time_remaining_seconds'
)).
toBeDefined
();
expect
(
this
.
model
.
get
(
'low_threshold'
)).
toBeDefined
();
expect
(
this
.
model
.
get
(
'critically_low_threshold'
)).
toBeDefined
();
expect
(
this
.
model
.
get
(
'lastFetched'
)).
toBeDefined
();
});
});
describe
(
'ProctoredExamView'
,
function
()
{
beforeEach
(
function
()
{
TemplateHelpers
.
installTemplate
(
'templates/courseware/proctored-exam-status'
,
true
,
'proctored-exam-status-tpl'
);
appendSetFixtures
(
'<div class="proctored_exam_status"></div>'
);
this
.
model
=
new
ProctoredExamModel
({
in_timed_exam
:
true
,
is_proctored
:
true
,
exam_display_name
:
'Midterm'
,
exam_url_path
:
'/test_url'
,
time_remaining_seconds
:
45
,
//2 * 60 + 15,
low_threshold
:
30
,
critically_low_threshold
:
15
,
lastFetched
:
new
Date
()
});
this
.
proctored_exam_view
=
new
edx
.
coursware
.
proctored_exam
.
ProctoredExamView
(
{
model
:
this
.
model
,
el
:
$
(
".proctored_exam_status"
),
proctored_template
:
'#proctored-exam-status-tpl'
}
);
this
.
proctored_exam_view
.
render
();
});
it
(
'renders items correctly'
,
function
()
{
expect
(
this
.
proctored_exam_view
.
$el
.
find
(
'a'
)).
toHaveAttr
(
'href'
,
this
.
model
.
get
(
"exam_url_path"
));
expect
(
this
.
proctored_exam_view
.
$el
.
find
(
'a'
)).
toContainHtml
(
this
.
model
.
get
(
'exam_display_name'
));
});
it
(
'changes behavior when clock time decreases low threshold'
,
function
()
{
spyOn
(
this
.
model
,
'getRemainingSeconds'
).
andCallFake
(
function
()
{
return
25
;
});
expect
(
this
.
model
.
getRemainingSeconds
()).
toEqual
(
25
);
expect
(
this
.
proctored_exam_view
.
$el
.
find
(
'div.exam-timer'
)).
not
.
toHaveClass
(
'low-time warning'
);
this
.
proctored_exam_view
.
render
();
expect
(
this
.
proctored_exam_view
.
$el
.
find
(
'div.exam-timer'
)).
toHaveClass
(
'low-time warning'
);
});
it
(
'changes behavior when clock time decreases critically low threshold'
,
function
()
{
spyOn
(
this
.
model
,
'getRemainingSeconds'
).
andCallFake
(
function
()
{
return
5
;
});
expect
(
this
.
model
.
getRemainingSeconds
()).
toEqual
(
5
);
expect
(
this
.
proctored_exam_view
.
$el
.
find
(
'div.exam-timer'
)).
not
.
toHaveClass
(
'low-time critical'
);
this
.
proctored_exam_view
.
render
();
expect
(
this
.
proctored_exam_view
.
$el
.
find
(
'div.exam-timer'
)).
toHaveClass
(
'low-time critical'
);
});
});
});
edx_proctoring/templates/proctoring/proctored-exam-status.underscore
0 → 100644
View file @
a5f0f316
<div class="exam-timer">
<%- gettext("You are taking") %>
<a href="<%- interpolate(gettext('%(exam_url_path)s'), { exam_url_path: exam_url_path }, true)%>" >
<%- interpolate(gettext(' %(exam_display_name)s '), { exam_display_name: exam_display_name }, true) %>
</a>
<%- gettext(" exam as a proctored exam. Good Luck!") %>
<span id="time_remaining_id" class="pull-right">
<b>
</b>
</span>
</div>
edx_proctoring/templates/proctoring/seq_timed_exam_completed.html
0 → 100644
View file @
a5f0f316
<div
class=
"sequence"
>
<div
class=
"gated-sequence"
>
All done!
</div>
</div>
edx_proctoring/templates/proctoring/seq_timed_exam_entrance.html
0 → 100644
View file @
a5f0f316
<div
class=
"sequence"
data-exam-id=
"{{exam_id}}"
>
<div
class=
"gated-sequence"
>
This is a timed exam. Would you like to
<a
class=
'start-timed-exam'
data-ajax-url=
"{{enter_exam_endpoint}}"
>
enter
</a>
it?
</div>
</div>
<script
type=
"text/javascript"
>
$
(
'.start-timed-exam'
).
click
(
function
(
event
)
{
var
target
=
$
(
event
.
target
);
var
action_url
=
target
.
data
(
'ajax-url'
);
var
exam_id
=
target
.
parent
().
parent
().
data
(
'exam-id'
);
$
.
post
(
action_url
,
{
"exam_id"
:
exam_id
,
},
function
(
data
)
{
// reload the page, because we've unlocked it
location
.
reload
();
}
);
}
);
</script>
edx_proctoring/templates/proctoring/seq_timed_exam_expired.html
0 → 100644
View file @
a5f0f316
<div
class=
"sequence"
>
<div
class=
"gated-sequence"
>
You have run out of time!
</div>
</div>
edx_proctoring/views.py
View file @
a5f0f316
...
...
@@ -3,11 +3,25 @@ Proctored Exams HTTP-based API endpoints
"""
import
logging
import
pytz
from
datetime
import
datetime
,
timedelta
from
django.utils.decorators
import
method_decorator
from
django.db
import
IntegrityError
from
rest_framework
import
status
from
rest_framework.response
import
Response
from
edx_proctoring.api
import
create_exam
,
update_exam
,
get_exam_by_id
,
get_exam_by_content_id
,
start_exam_attempt
,
\
stop_exam_attempt
,
add_allowance_for_user
,
remove_allowance_for_user
,
get_active_exams_for_user
from
edx_proctoring.api
import
(
create_exam
,
update_exam
,
get_exam_by_id
,
get_exam_by_content_id
,
start_exam_attempt
,
stop_exam_attempt
,
add_allowance_for_user
,
remove_allowance_for_user
,
get_active_exams_for_user
)
from
edx_proctoring.exceptions
import
ProctoredExamNotFoundException
,
\
StudentExamAttemptAlreadyExistsException
,
StudentExamAttemptDoesNotExistsException
from
edx_proctoring.serializers
import
ProctoredExamSerializer
...
...
@@ -187,22 +201,40 @@ class StudentProctoredExamAttempt(AuthenticatedAPIView):
HTTP GET Handler. Returns the status of the exam attempt.
"""
response_dict
=
{
'in_timed_exam'
:
True
,
'is_proctored'
:
True
,
'exam_display_name'
:
'Midterm'
,
'exam_url_path'
:
''
,
'time_remaining_seconds'
:
45
,
'low_threshold'
:
30
,
'critically_low_threshold'
:
15
,
}
exams
=
get_active_exams_for_user
(
request
.
user
.
id
)
if
exams
:
exam
=
exams
[
0
]
# need to adjust for allowances
expires_at
=
exam
[
'attempt'
][
'started_at'
]
+
timedelta
(
minutes
=
exam
[
'exam'
][
'time_limit_mins'
])
now_utc
=
datetime
.
now
(
pytz
.
UTC
)
if
expires_at
>
now_utc
:
time_remaining_seconds
=
(
expires_at
-
now_utc
)
.
seconds
else
:
time_remaining_seconds
=
0
response_dict
=
{
'in_timed_exam'
:
True
,
'is_proctored'
:
True
,
'exam_display_name'
:
exam
[
'exam'
][
'exam_name'
],
'exam_url_path'
:
''
,
'time_remaining_seconds'
:
time_remaining_seconds
,
'low_threshold'
:
30
,
'critically_low_threshold'
:
15
,
}
else
:
response_dict
=
{
'in_timed_exam'
:
False
,
'is_proctored'
:
False
,
}
return
Response
(
data
=
response_dict
,
status
=
status
.
HTTP_200_OK
)
@method_decorator
(
require_staff
)
def
post
(
self
,
request
):
"""
HTTP POST handler. To start an exam.
...
...
@@ -210,7 +242,7 @@ class StudentProctoredExamAttempt(AuthenticatedAPIView):
try
:
exam_attempt_id
=
start_exam_attempt
(
exam_id
=
request
.
DATA
.
get
(
'exam_id'
,
None
),
user_id
=
request
.
DATA
.
get
(
'user_id'
,
None
)
,
user_id
=
request
.
user
.
id
,
external_id
=
request
.
DATA
.
get
(
'external_id'
,
None
)
)
return
Response
({
'exam_attempt_id'
:
exam_attempt_id
})
...
...
@@ -221,7 +253,6 @@ class StudentProctoredExamAttempt(AuthenticatedAPIView):
data
=
{
"detail"
:
"Error. Trying to start an exam that has already started."
}
)
@method_decorator
(
require_staff
)
def
put
(
self
,
request
):
"""
HTTP POST handler. To stop an exam.
...
...
@@ -229,7 +260,7 @@ class StudentProctoredExamAttempt(AuthenticatedAPIView):
try
:
exam_attempt_id
=
stop_exam_attempt
(
exam_id
=
request
.
DATA
.
get
(
'exam_id'
,
None
),
user_id
=
request
.
DATA
.
get
(
'user_id'
,
None
)
user_id
=
request
.
user
.
id
)
return
Response
({
"exam_attempt_id"
:
exam_attempt_id
})
...
...
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