Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
E
edx-platform
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
edx
edx-platform
Commits
f8c062b6
Commit
f8c062b6
authored
Feb 18, 2014
by
Jason Bau
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
lti 2.0 result service rest endpoint
parent
c747ebdd
Hide whitespace changes
Inline
Side-by-side
Showing
21 changed files
with
1587 additions
and
127 deletions
+1587
-127
common/djangoapps/terrain/stubs/lti.py
+90
-16
common/djangoapps/terrain/stubs/tests/test_lti_stub.py
+21
-1
common/lib/xmodule/xmodule/css/lti/lti.scss
+25
-0
common/lib/xmodule/xmodule/lti_2_util.py
+363
-0
common/lib/xmodule/xmodule/lti_module.py
+140
-40
common/lib/xmodule/xmodule/tests/test_lti20_unit.py
+372
-0
common/lib/xmodule/xmodule/tests/test_lti_unit.py
+15
-18
common/lib/xmodule/xmodule/x_module.py
+6
-2
lms/djangoapps/courseware/features/lti.feature
+60
-2
lms/djangoapps/courseware/features/lti.py
+56
-20
lms/djangoapps/courseware/model_data.py
+0
-1
lms/djangoapps/courseware/module_render.py
+95
-13
lms/djangoapps/courseware/tests/test_lti_integration.py
+122
-3
lms/djangoapps/courseware/tests/test_module_render.py
+107
-1
lms/djangoapps/courseware/views.py
+59
-4
lms/envs/aws.py
+1
-0
lms/envs/dev.py
+1
-0
lms/lib/xblock/runtime.py
+10
-1
lms/lib/xblock/test/test_runtime.py
+13
-0
lms/templates/lti.html
+28
-5
lms/urls.py
+3
-0
No files found.
common/djangoapps/terrain/stubs/lti.py
View file @
f8c062b6
...
...
@@ -13,7 +13,7 @@ from uuid import uuid4
import
textwrap
import
urllib
import
re
from
oauthlib.oauth1.rfc5849
import
signature
from
oauthlib.oauth1.rfc5849
import
signature
,
parameters
import
oauthlib.oauth1
import
hashlib
import
base64
...
...
@@ -46,7 +46,16 @@ class StubLtiHandler(StubHttpRequestHandler):
status_message
=
'LTI consumer (edX) responded with XML content:<br>'
+
self
.
server
.
grade_data
[
'TC answer'
]
content
=
self
.
_create_content
(
status_message
)
self
.
send_response
(
200
,
content
)
elif
'lti2_outcome'
in
self
.
path
and
self
.
_send_lti2_outcome
()
.
status_code
==
200
:
status_message
=
'LTI consumer (edX) responded with HTTP {}<br>'
.
format
(
self
.
server
.
grade_data
[
'status_code'
])
content
=
self
.
_create_content
(
status_message
)
self
.
send_response
(
200
,
content
)
elif
'lti2_delete'
in
self
.
path
and
self
.
_send_lti2_delete
()
.
status_code
==
200
:
status_message
=
'LTI consumer (edX) responded with HTTP {}<br>'
.
format
(
self
.
server
.
grade_data
[
'status_code'
])
content
=
self
.
_create_content
(
status_message
)
self
.
send_response
(
200
,
content
)
# Respond to request with correct lti endpoint
elif
self
.
_is_correct_lti_request
():
params
=
{
k
:
v
for
k
,
v
in
self
.
post_dict
.
items
()
if
k
!=
'oauth_signature'
}
...
...
@@ -57,7 +66,7 @@ class StubLtiHandler(StubHttpRequestHandler):
# Set data for grades what need to be stored as server data
if
'lis_outcome_service_url'
in
self
.
post_dict
:
self
.
server
.
grade_data
=
{
'callback_url'
:
self
.
post_dict
.
get
(
'lis_outcome_service_url'
),
'callback_url'
:
self
.
post_dict
.
get
(
'lis_outcome_service_url'
)
.
replace
(
'https'
,
'http'
)
,
'sourcedId'
:
self
.
post_dict
.
get
(
'lis_result_sourcedid'
)
}
...
...
@@ -122,16 +131,75 @@ class StubLtiHandler(StubHttpRequestHandler):
self
.
server
.
grade_data
[
'TC answer'
]
=
response
.
content
return
response
def
_send_lti2_outcome
(
self
):
"""
Send a grade back to consumer
"""
payload
=
textwrap
.
dedent
(
"""
{{
"@context" : "http://purl.imsglobal.org/ctx/lis/v2/Result",
"@type" : "Result",
"resultScore" : {score},
"comment" : "This is awesome."
}}
"""
)
data
=
payload
.
format
(
score
=
0.8
)
return
self
.
_send_lti2
(
data
)
def
_send_lti2_delete
(
self
):
"""
Send a delete back to consumer
"""
payload
=
textwrap
.
dedent
(
"""
{
"@context" : "http://purl.imsglobal.org/ctx/lis/v2/Result",
"@type" : "Result"
}
"""
)
return
self
.
_send_lti2
(
payload
)
def
_send_lti2
(
self
,
payload
):
"""
Send lti2 json result service request.
"""
### We compute the LTI V2.0 service endpoint from the callback_url (which is set by the launch call)
url
=
self
.
server
.
grade_data
[
'callback_url'
]
url_parts
=
url
.
split
(
'/'
)
url_parts
[
-
1
]
=
"lti_2_0_result_rest_handler"
anon_id
=
self
.
server
.
grade_data
[
'sourcedId'
]
.
split
(
":"
)[
-
1
]
url_parts
.
extend
([
"user"
,
anon_id
])
new_url
=
'/'
.
join
(
url_parts
)
content_type
=
'application/vnd.ims.lis.v2.result+json'
headers
=
{
'Content-Type'
:
content_type
,
'Authorization'
:
self
.
_oauth_sign
(
new_url
,
payload
,
method
=
'PUT'
,
content_type
=
content_type
)
}
# Send request ignoring verifirecation of SSL certificate
response
=
requests
.
put
(
new_url
,
data
=
payload
,
headers
=
headers
,
verify
=
False
)
self
.
server
.
grade_data
[
'status_code'
]
=
response
.
status_code
self
.
server
.
grade_data
[
'TC answer'
]
=
response
.
content
return
response
def
_create_content
(
self
,
response_text
,
submit_url
=
None
):
"""
Return content (str) either for launch, send grade or get result from TC.
"""
if
submit_url
:
submit_form
=
textwrap
.
dedent
(
"""
<form action="{}/grade" method="post">
<form action="{
submit_url
}/grade" method="post">
<input type="submit" name="submit-button" value="Submit">
</form>
"""
)
.
format
(
submit_url
)
<form action="{submit_url}/lti2_outcome" method="post">
<input type="submit" name="submit-lti2-button" value="Submit">
</form>
<form action="{submit_url}/lti2_delete" method="post">
<input type="submit" name="submit-lti2-delete-button" value="Submit">
</form>
"""
)
.
format
(
submit_url
=
submit_url
)
else
:
submit_form
=
''
...
...
@@ -169,9 +237,9 @@ class StubLtiHandler(StubHttpRequestHandler):
lti_endpoint
=
self
.
server
.
config
.
get
(
'lti_endpoint'
,
self
.
DEFAULT_LTI_ENDPOINT
)
return
lti_endpoint
in
self
.
path
def
_oauth_sign
(
self
,
url
,
body
):
def
_oauth_sign
(
self
,
url
,
body
,
content_type
=
u'application/x-www-form-urlencoded'
,
method
=
u'POST'
):
"""
Signs request and returns signed
body and headers
.
Signs request and returns signed
Authorization header
.
"""
client_key
=
self
.
server
.
config
.
get
(
'client_key'
,
self
.
DEFAULT_CLIENT_KEY
)
client_secret
=
self
.
server
.
config
.
get
(
'client_secret'
,
self
.
DEFAULT_CLIENT_SECRET
)
...
...
@@ -181,21 +249,27 @@ class StubLtiHandler(StubHttpRequestHandler):
)
headers
=
{
# This is needed for body encoding:
'Content-Type'
:
'application/x-www-form-urlencoded'
,
'Content-Type'
:
content_type
,
}
# Calculate and encode body hash. See http://oauth.googlecode.com/svn/spec/ext/body_hash/1.0/oauth-bodyhash.html
sha1
=
hashlib
.
sha1
()
sha1
.
update
(
body
)
oauth_body_hash
=
base64
.
b64encode
(
sha1
.
digest
())
__
,
headers
,
__
=
client
.
sign
(
unicode
(
url
.
strip
()),
http_method
=
u'POST'
,
body
=
{
u'oauth_body_hash'
:
oauth_body_hash
},
headers
=
headers
oauth_body_hash
=
unicode
(
base64
.
b64encode
(
sha1
.
digest
()))
# pylint: disable=too-many-function-args
params
=
client
.
get_oauth_params
()
params
.
append
((
u'oauth_body_hash'
,
oauth_body_hash
))
mock_request
=
mock
.
Mock
(
uri
=
unicode
(
urllib
.
unquote
(
url
)),
headers
=
headers
,
body
=
u""
,
decoded_body
=
u""
,
oauth_params
=
params
,
http_method
=
unicode
(
method
),
)
headers
=
headers
[
'Authorization'
]
+
', oauth_body_hash="{}"'
.
format
(
oauth_body_hash
)
return
headers
sig
=
client
.
get_oauth_signature
(
mock_request
)
mock_request
.
oauth_params
.
append
((
u'oauth_signature'
,
sig
))
new_headers
=
parameters
.
prepare_headers
(
mock_request
.
oauth_params
,
headers
,
realm
=
None
)
return
new_headers
[
'Authorization'
]
def
_check_oauth_signature
(
self
,
params
,
client_signature
):
"""
...
...
common/djangoapps/terrain/stubs/tests/test_lti_stub.py
View file @
f8c062b6
...
...
@@ -62,7 +62,7 @@ class StubLtiServiceTest(unittest.TestCase):
self
.
assertIn
(
'This is LTI tool. Success.'
,
response
.
content
)
@patch
(
'terrain.stubs.lti.signature.verify_hmac_sha1'
,
return_value
=
True
)
def
test_send_graded_result
(
self
,
verify_hmac
):
def
test_send_graded_result
(
self
,
verify_hmac
):
# pylint: disable=unused-argument
response
=
requests
.
post
(
self
.
launch_uri
,
data
=
self
.
payload
)
self
.
assertIn
(
'This is LTI tool. Success.'
,
response
.
content
)
grade_uri
=
self
.
uri
+
'grade'
...
...
@@ -70,3 +70,23 @@ class StubLtiServiceTest(unittest.TestCase):
mocked_post
.
return_value
=
Mock
(
content
=
'Test response'
,
status_code
=
200
)
response
=
urllib2
.
urlopen
(
grade_uri
,
data
=
''
)
self
.
assertIn
(
'Test response'
,
response
.
read
())
@patch
(
'terrain.stubs.lti.signature.verify_hmac_sha1'
,
return_value
=
True
)
def
test_lti20_outcomes_put
(
self
,
verify_hmac
):
# pylint: disable=unused-argument
response
=
requests
.
post
(
self
.
launch_uri
,
data
=
self
.
payload
)
self
.
assertIn
(
'This is LTI tool. Success.'
,
response
.
content
)
grade_uri
=
self
.
uri
+
'lti2_outcome'
with
patch
(
'terrain.stubs.lti.requests.put'
)
as
mocked_put
:
mocked_put
.
return_value
=
Mock
(
status_code
=
200
)
response
=
urllib2
.
urlopen
(
grade_uri
,
data
=
''
)
self
.
assertIn
(
'LTI consumer (edX) responded with HTTP 200'
,
response
.
read
())
@patch
(
'terrain.stubs.lti.signature.verify_hmac_sha1'
,
return_value
=
True
)
def
test_lti20_outcomes_put_like_delete
(
self
,
verify_hmac
):
# pylint: disable=unused-argument
response
=
requests
.
post
(
self
.
launch_uri
,
data
=
self
.
payload
)
self
.
assertIn
(
'This is LTI tool. Success.'
,
response
.
content
)
grade_uri
=
self
.
uri
+
'lti2_delete'
with
patch
(
'terrain.stubs.lti.requests.put'
)
as
mocked_put
:
mocked_put
.
return_value
=
Mock
(
status_code
=
200
)
response
=
urllib2
.
urlopen
(
grade_uri
,
data
=
''
)
self
.
assertIn
(
'LTI consumer (edX) responded with HTTP 200'
,
response
.
read
())
common/lib/xmodule/xmodule/css/lti/lti.scss
View file @
f8c062b6
h2
.problem-header
{
display
:
inline-block
;
}
div
.problem-progress
{
display
:
inline-block
;
padding-left
:
5px
;
color
:
#666
;
font-weight
:
100
;
font-size
:
em
(
16
);
}
div
.lti
{
// align center
margin
:
0
auto
;
...
...
@@ -31,4 +44,16 @@ div.lti {
display
:
block
;
border
:
0px
;
}
h4
.problem-feedback-label
{
font-weight
:
100
;
font-size
:
em
(
16
);
font-family
:
"Source Sans"
,
"Open Sans"
,
Verdana
,
Geneva
,
sans-serif
,
sans-serif
;
}
div
.problem-feedback
{
margin-top
:
5px
;
margin-bottom
:
5px
;
}
}
common/lib/xmodule/xmodule/lti_2_util.py
0 → 100644
View file @
f8c062b6
# pylint: disable=attribute-defined-outside-init
"""
A mixin class for LTI 2.0 functionality. This is really just done to refactor the code to
keep the LTIModule class from getting too big
"""
import
json
import
re
import
mock
import
urllib
import
hashlib
import
base64
import
logging
from
webob
import
Response
from
xblock.core
import
XBlock
from
oauthlib.oauth1
import
Client
log
=
logging
.
getLogger
(
__name__
)
LTI_2_0_REST_SUFFIX_PARSER
=
re
.
compile
(
r"^user/(?P<anon_id>\w+)"
,
re
.
UNICODE
)
LTI_2_0_JSON_CONTENT_TYPE
=
'application/vnd.ims.lis.v2.result+json'
class
LTIError
(
Exception
):
"""Error class for LTIModule and LTI20ModuleMixin"""
pass
class
LTI20ModuleMixin
(
object
):
"""
This class MUST be mixed into LTIModule. It does not do anything on its own. It's just factored
out for modularity.
"""
# LTI 2.0 Result Service Support
@XBlock.handler
def
lti_2_0_result_rest_handler
(
self
,
request
,
suffix
):
"""
Handler function for LTI 2.0 JSON/REST result service.
See http://www.imsglobal.org/lti/ltiv2p0/uml/purl.imsglobal.org/vocab/lis/v2/outcomes/Result/service.html
An example JSON object:
{
"@context" : "http://purl.imsglobal.org/ctx/lis/v2/Result",
"@type" : "Result",
"resultScore" : 0.83,
"comment" : "This is exceptional work."
}
For PUTs, the content type must be "application/vnd.ims.lis.v2.result+json".
We use the "suffix" parameter to parse out the user from the end of the URL. An example endpoint url is
http://localhost:8000/courses/org/num/run/xblock/i4x:;_;_org;_num;_lti;_GUID/handler_noauth/lti_2_0_result_rest_handler/user/<anon_id>
so suffix is of the form "user/<anon_id>"
Failures result in 401, 404, or 500s without any body. Successes result in 200. Again see
http://www.imsglobal.org/lti/ltiv2p0/uml/purl.imsglobal.org/vocab/lis/v2/outcomes/Result/service.html
(Note: this prevents good debug messages for the client, so we might want to change this, or the spec)
Arguments:
request (xblock.django.request.DjangoWebobRequest): Request object for current HTTP request
suffix (unicode): request path after "lti_2_0_result_rest_handler/". expected to be "user/<anon_id>"
Returns:
webob.response: response to this request. See above for details.
"""
if
self
.
system
.
debug
:
self
.
_log_correct_authorization_header
(
request
)
try
:
anon_id
=
self
.
parse_lti_2_0_handler_suffix
(
suffix
)
except
LTIError
:
return
Response
(
status
=
404
)
# 404 because a part of the URL (denoting the anon user id) is invalid
try
:
self
.
verify_lti_2_0_result_rest_headers
(
request
,
verify_content_type
=
True
)
except
LTIError
:
return
Response
(
status
=
401
)
# Unauthorized in this case. 401 is right
real_user
=
self
.
system
.
get_real_user
(
anon_id
)
if
not
real_user
:
# that means we can't save to database, as we do not have real user id.
msg
=
"[LTI]: Real user not found against anon_id: {}"
.
format
(
anon_id
)
log
.
info
(
msg
)
return
Response
(
status
=
404
)
# have to do 404 due to spec, but 400 is better, with error msg in body
if
request
.
method
==
"PUT"
:
return
self
.
_lti_2_0_result_put_handler
(
request
,
real_user
)
elif
request
.
method
==
"GET"
:
return
self
.
_lti_2_0_result_get_handler
(
request
,
real_user
)
elif
request
.
method
==
"DELETE"
:
return
self
.
_lti_2_0_result_del_handler
(
request
,
real_user
)
else
:
return
Response
(
status
=
404
)
# have to do 404 due to spec, but 405 is better, with error msg in body
def
_log_correct_authorization_header
(
self
,
request
):
"""
Helper function that logs proper HTTP Authorization header for a given request
Used only in debug situations, this logs the correct Authorization header based on
the request header and body according to OAuth 1 Body signing
Arguments:
request (xblock.django.request.DjangoWebobRequest): Request object to log Authorization header for
Returns:
nothing
"""
sha1
=
hashlib
.
sha1
()
sha1
.
update
(
request
.
body
)
oauth_body_hash
=
unicode
(
base64
.
b64encode
(
sha1
.
digest
()))
# pylint: disable=too-many-function-args
log
.
debug
(
"[LTI] oauth_body_hash = {}"
.
format
(
oauth_body_hash
))
client_key
,
client_secret
=
self
.
get_client_key_secret
()
client
=
Client
(
client_key
,
client_secret
)
params
=
client
.
get_oauth_params
()
params
.
append
((
u'oauth_body_hash'
,
oauth_body_hash
))
mock_request
=
mock
.
Mock
(
uri
=
unicode
(
urllib
.
unquote
(
request
.
url
)),
headers
=
request
.
headers
,
body
=
u""
,
decoded_body
=
u""
,
oauth_params
=
params
,
http_method
=
unicode
(
request
.
method
),
)
sig
=
client
.
get_oauth_signature
(
mock_request
)
mock_request
.
oauth_params
.
append
((
u'oauth_signature'
,
sig
))
_
,
headers
,
_
=
client
.
_render
(
mock_request
)
# pylint: disable=protected-access
log
.
debug
(
"
\n\n
#### COPY AND PASTE AUTHORIZATION HEADER ####
\n
{}
\n
####################################
\n\n
"
.
format
(
headers
[
'Authorization'
]))
def
parse_lti_2_0_handler_suffix
(
self
,
suffix
):
"""
Parser function for HTTP request path suffixes
parses the suffix argument (the trailing parts of the URL) of the LTI2.0 REST handler.
must be of the form "user/<anon_id>". Returns anon_id if match found, otherwise raises LTIError
Arguments:
suffix (unicode): suffix to parse
Returns:
unicode: anon_id if match found
Raises:
LTIError if suffix cannot be parsed or is not in its expected form
"""
if
suffix
:
match_obj
=
LTI_2_0_REST_SUFFIX_PARSER
.
match
(
suffix
)
if
match_obj
:
return
match_obj
.
group
(
'anon_id'
)
# fall-through handles all error cases
msg
=
"No valid user id found in endpoint URL"
log
.
info
(
"[LTI]: {}"
.
format
(
msg
))
raise
LTIError
(
msg
)
def
_lti_2_0_result_get_handler
(
self
,
request
,
real_user
):
# pylint: disable=unused-argument
"""
Helper request handler for GET requests to LTI 2.0 result endpoint
GET handler for lti_2_0_result. Assumes all authorization has been checked.
Arguments:
request (xblock.django.request.DjangoWebobRequest): Request object (unused)
real_user (django.contrib.auth.models.User): Actual user linked to anon_id in request path suffix
Returns:
webob.response: response to this request, in JSON format with status 200 if success
"""
base_json_obj
=
{
"@context"
:
"http://purl.imsglobal.org/ctx/lis/v2/Result"
,
"@type"
:
"Result"
}
self
.
system
.
rebind_noauth_module_to_user
(
self
,
real_user
)
if
self
.
module_score
is
None
:
# In this case, no score has been ever set
return
Response
(
json
.
dumps
(
base_json_obj
),
content_type
=
LTI_2_0_JSON_CONTENT_TYPE
)
# Fall through to returning grade and comment
base_json_obj
[
'resultScore'
]
=
round
(
self
.
module_score
,
2
)
base_json_obj
[
'comment'
]
=
self
.
score_comment
return
Response
(
json
.
dumps
(
base_json_obj
),
content_type
=
LTI_2_0_JSON_CONTENT_TYPE
)
def
_lti_2_0_result_del_handler
(
self
,
request
,
real_user
):
# pylint: disable=unused-argument
"""
Helper request handler for DELETE requests to LTI 2.0 result endpoint
DELETE handler for lti_2_0_result. Assumes all authorization has been checked.
Arguments:
request (xblock.django.request.DjangoWebobRequest): Request object (unused)
real_user (django.contrib.auth.models.User): Actual user linked to anon_id in request path suffix
Returns:
webob.response: response to this request. status 200 if success
"""
self
.
clear_user_module_score
(
real_user
)
return
Response
(
status
=
200
)
def
_lti_2_0_result_put_handler
(
self
,
request
,
real_user
):
"""
Helper request handler for PUT requests to LTI 2.0 result endpoint
PUT handler for lti_2_0_result. Assumes all authorization has been checked.
Arguments:
request (xblock.django.request.DjangoWebobRequest): Request object
real_user (django.contrib.auth.models.User): Actual user linked to anon_id in request path suffix
Returns:
webob.response: response to this request. status 200 if success. 404 if body of PUT request is malformed
"""
try
:
(
score
,
comment
)
=
self
.
parse_lti_2_0_result_json
(
request
.
body
)
except
LTIError
:
return
Response
(
status
=
404
)
# have to do 404 due to spec, but 400 is better, with error msg in body
# According to http://www.imsglobal.org/lti/ltiv2p0/ltiIMGv2p0.html#_Toc361225514
# PUTting a JSON object with no "resultScore" field is equivalent to a DELETE.
if
score
is
None
:
self
.
clear_user_module_score
(
real_user
)
return
Response
(
status
=
200
)
# Fall-through record the score and the comment in the module
self
.
set_user_module_score
(
real_user
,
score
,
self
.
max_score
(),
comment
)
return
Response
(
status
=
200
)
def
clear_user_module_score
(
self
,
user
):
"""
Clears the module user state, including grades and comments, and also scoring in db's courseware_studentmodule
Arguments:
user (django.contrib.auth.models.User): Actual user whose module state is to be cleared
Returns:
nothing
"""
self
.
set_user_module_score
(
user
,
None
,
None
)
def
set_user_module_score
(
self
,
user
,
score
,
max_score
,
comment
=
u""
):
"""
Sets the module user state, including grades and comments, and also scoring in db's courseware_studentmodule
Arguments:
user (django.contrib.auth.models.User): Actual user whose module state is to be set
score (float): user's numeric score to set. Must be in the range [0.0, 1.0]
max_score (float): max score that could have been achieved on this module
comment (unicode): comments provided by the grader as feedback to the student
Returns:
nothing
"""
if
score
is
not
None
and
max_score
is
not
None
:
scaled_score
=
score
*
max_score
else
:
scaled_score
=
None
self
.
system
.
rebind_noauth_module_to_user
(
self
,
user
)
# have to publish for the progress page...
self
.
system
.
publish
(
self
,
'grade'
,
{
'value'
:
scaled_score
,
'max_value'
:
max_score
,
'user_id'
:
user
.
id
,
},
)
self
.
module_score
=
scaled_score
self
.
score_comment
=
comment
def
verify_lti_2_0_result_rest_headers
(
self
,
request
,
verify_content_type
=
True
):
"""
Helper method to validate LTI 2.0 REST result service HTTP headers. returns if correct, else raises LTIError
Arguments:
request (xblock.django.request.DjangoWebobRequest): Request object
verify_content_type (bool): If true, verifies the content type of the request is that spec'ed by LTI 2.0
Returns:
nothing, but will only return if verification succeeds
Raises:
LTIError if verification fails
"""
content_type
=
request
.
headers
.
get
(
'Content-Type'
)
if
verify_content_type
and
content_type
!=
LTI_2_0_JSON_CONTENT_TYPE
:
log
.
info
(
"[LTI]: v2.0 result service -- bad Content-Type: {}"
.
format
(
content_type
))
raise
LTIError
(
"For LTI 2.0 result service, Content-Type must be {}. Got {}"
.
format
(
LTI_2_0_JSON_CONTENT_TYPE
,
content_type
))
try
:
self
.
verify_oauth_body_sign
(
request
,
content_type
=
LTI_2_0_JSON_CONTENT_TYPE
)
except
(
ValueError
,
LTIError
)
as
err
:
log
.
info
(
"[LTI]: v2.0 result service -- OAuth body verification failed: {}"
.
format
(
err
.
message
))
raise
LTIError
(
err
.
message
)
def
parse_lti_2_0_result_json
(
self
,
json_str
):
"""
Helper method for verifying LTI 2.0 JSON object contained in the body of the request.
The json_str must be loadable. It can either be an dict (object) or an array whose first element is an dict,
in which case that first dict is considered.
The dict must have the "@type" key with value equal to "Result",
"resultScore" key with value equal to a number [0, 1],
The "@context" key must be present, but we don't do anything with it. And the "comment" key may be
present, in which case it must be a string.
Arguments:
json_str (unicode): The body of the LTI 2.0 results service request, which is a JSON string]
Returns:
(float, str): (score, [optional]comment) if verification checks out
Raises:
LTIError (with message) if verification fails
"""
try
:
json_obj
=
json
.
loads
(
json_str
)
except
(
ValueError
,
TypeError
):
msg
=
"Supplied JSON string in request body could not be decoded: {}"
.
format
(
json_str
)
log
.
info
(
"[LTI] {}"
.
format
(
msg
))
raise
LTIError
(
msg
)
# the standard supports a list of objects, who knows why. It must contain at least 1 element, and the
# first element must be a dict
if
type
(
json_obj
)
!=
dict
:
if
type
(
json_obj
)
==
list
and
len
(
json_obj
)
>=
1
and
type
(
json_obj
[
0
])
==
dict
:
json_obj
=
json_obj
[
0
]
else
:
msg
=
(
"Supplied JSON string is a list that does not contain an object as the first element. {}"
.
format
(
json_str
))
log
.
info
(
"[LTI] {}"
.
format
(
msg
))
raise
LTIError
(
msg
)
# '@type' must be "Result"
result_type
=
json_obj
.
get
(
"@type"
)
if
result_type
!=
"Result"
:
msg
=
"JSON object does not contain correct @type attribute (should be 'Result', is {})"
.
format
(
result_type
)
log
.
info
(
"[LTI] {}"
.
format
(
msg
))
raise
LTIError
(
msg
)
# '@context' must be present as a key
REQUIRED_KEYS
=
[
"@context"
]
# pylint: disable=invalid-name
for
key
in
REQUIRED_KEYS
:
if
key
not
in
json_obj
:
msg
=
"JSON object does not contain required key {}"
.
format
(
key
)
log
.
info
(
"[LTI] {}"
.
format
(
msg
))
raise
LTIError
(
msg
)
# 'resultScore' is not present. If this was a PUT this means it's actually a DELETE according
# to the LTI spec. We will indicate this by returning None as score, "" as comment.
# The actual delete will be handled by the caller
if
"resultScore"
not
in
json_obj
:
return
None
,
json_obj
.
get
(
'comment'
,
""
)
# if present, 'resultScore' must be a number between 0 and 1 inclusive
try
:
score
=
float
(
json_obj
.
get
(
'resultScore'
,
"unconvertable"
))
# Check if float is present and the right type
if
not
0
<=
score
<=
1
:
msg
=
'score value outside the permitted range of 0-1.'
log
.
info
(
"[LTI] {}"
.
format
(
msg
))
raise
LTIError
(
msg
)
except
(
TypeError
,
ValueError
)
as
err
:
msg
=
"Could not convert resultScore to float: {}"
.
format
(
err
.
message
)
log
.
info
(
"[LTI] {}"
.
format
(
msg
))
raise
LTIError
(
msg
)
return
score
,
json_obj
.
get
(
'comment'
,
""
)
common/lib/xmodule/xmodule/lti_module.py
View file @
f8c062b6
...
...
@@ -22,6 +22,11 @@ A resource to test the LTI protocol (PHP realization):
http://www.imsglobal.org/developers/LTI/test/v1p1/lms.php
We have also begun to add support for LTI 1.2/2.0. We will keep this
docstring in synch with what support is available. The first LTI 2.0
feature to be supported is the REST API results service, see specification
at
http://www.imsglobal.org/lti/ltiv2p0/uml/purl.imsglobal.org/vocab/lis/v2/outcomes/Result/service.html
What is supported:
------------------
...
...
@@ -30,9 +35,20 @@ What is supported:
2.) Multiple LTI components on a single page.
3.) The use of multiple LTI providers per course.
4.) Use of advanced LTI component that provides back a grade.
a.) The LTI provider sends back a grade to a specified URL.
b.) Currently only action "update" is supported. "Read", and "delete"
actions initially weren't required.
A) LTI 1.1.1 XML endpoint
a.) The LTI provider sends back a grade to a specified URL.
b.) Currently only action "update" is supported. "Read", and "delete"
actions initially weren't required.
B) LTI 2.0 Result Service JSON REST endpoint
(http://www.imsglobal.org/lti/ltiv2p0/uml/purl.imsglobal.org/vocab/lis/v2/outcomes/Result/service.html)
a.) Discovery of all such LTI http endpoints for a course. External tools GET from this discovery
endpoint and receive URLs for interacting with individual grading units.
(see lms/djangoapps/courseware/views.py:get_course_lti_endpoints)
b.) GET, PUT and DELETE in LTI Result JSON binding
(http://www.imsglobal.org/lti/ltiv2p0/mediatype/application/vnd/ims/lis/v2/result+json/index.html)
for a provider to synchronize grades into edx-platform. Reading, Setting, and Deleteing
Numeric grades between 0 and 1 and text + basic HTML feedback comments are supported, via
GET / PUT / DELETE HTTP methods respectively
"""
import
logging
...
...
@@ -42,6 +58,7 @@ import hashlib
import
base64
import
urllib
import
textwrap
import
bleach
from
lxml
import
etree
from
webob
import
Response
import
mock
...
...
@@ -51,15 +68,18 @@ from xmodule.editing_module import MetadataOnlyEditingDescriptor
from
xmodule.raw_module
import
EmptyDataRawDescriptor
from
xmodule.x_module
import
XModule
,
module_attr
from
xmodule.course_module
import
CourseDescriptor
from
xmodule.lti_2_util
import
LTI20ModuleMixin
,
LTIError
from
pkg_resources
import
resource_string
from
xblock.core
import
String
,
Scope
,
List
,
XBlock
from
xblock.fields
import
Boolean
,
Float
log
=
logging
.
getLogger
(
__name__
)
class
LTIError
(
Exception
):
pass
DOCS_ANCHOR_TAG
=
(
"<a target='_blank'"
"href='http://edx.readthedocs.org/projects/ca/en/latest/exercises_tools/lti_component.html'>"
"the edX LTI documentation</a>"
)
class
LTIFields
(
object
):
...
...
@@ -82,22 +102,95 @@ class LTIFields(object):
https://github.com/idan/oauthlib/blob/master/oauthlib/oauth1/rfc5849/signature.py#L136
"""
display_name
=
String
(
display_name
=
"Display Name"
,
help
=
"Display name for this module"
,
scope
=
Scope
.
settings
,
default
=
"LTI"
)
lti_id
=
String
(
help
=
"Id of the tool"
,
default
=
''
,
scope
=
Scope
.
settings
)
launch_url
=
String
(
help
=
"URL of the tool"
,
default
=
'http://www.example.com'
,
scope
=
Scope
.
settings
)
custom_parameters
=
List
(
help
=
"Custom parameters (vbid, book_location, etc..)"
,
scope
=
Scope
.
settings
)
open_in_a_new_page
=
Boolean
(
help
=
"Should LTI be opened in new page?"
,
default
=
True
,
scope
=
Scope
.
settings
)
graded
=
Boolean
(
help
=
"Grades will be considered in overall score."
,
default
=
False
,
scope
=
Scope
.
settings
)
display_name
=
String
(
display_name
=
"Display Name"
,
help
=
(
"Enter the name that students see for this component. "
"Analytics reports may also use the display name to identify this component."
),
scope
=
Scope
.
settings
,
default
=
"LTI"
,
)
lti_id
=
String
(
display_name
=
"LTI ID"
,
help
=
(
"Enter the LTI ID for the external LTI provider. "
"This value must be the same LTI ID that you entered in the "
"LTI Passports setting on the Advanced Settings page."
"<br />See "
+
DOCS_ANCHOR_TAG
+
" for more details on this setting."
),
default
=
''
,
scope
=
Scope
.
settings
)
launch_url
=
String
(
display_name
=
"LTI URL"
,
help
=
(
"Enter the URL of the external tool that this component launches. "
"This setting is only used when Hide External Tool is set to False."
"<br />See "
+
DOCS_ANCHOR_TAG
+
" for more details on this setting."
),
default
=
'http://www.example.com'
,
scope
=
Scope
.
settings
)
custom_parameters
=
List
(
display_name
=
"Custom Parameters"
,
help
=
(
"Add the key/value pair for any custom parameters, such as the page your e-book should open to or "
"the background color for this component."
"<br />See "
+
DOCS_ANCHOR_TAG
+
" for more details on this setting."
),
scope
=
Scope
.
settings
)
open_in_a_new_page
=
Boolean
(
display_name
=
"Open in New Page"
,
help
=
(
"Select True if you want students to click a link that opens the LTI tool in a new window. "
"Select False if you want the LTI content to open in an IFrame in the current page. "
"This setting is only used when Hide External Tool is set to False. "
),
default
=
True
,
scope
=
Scope
.
settings
)
has_score
=
Boolean
(
display_name
=
"Scored"
,
help
=
(
"Select True if this component will receive a numerical score from the external LTI system."
),
default
=
False
,
scope
=
Scope
.
settings
)
weight
=
Float
(
help
=
"Weight for student grades."
,
display_name
=
"Weight"
,
help
=
(
"Enter the number of points possible for this component. "
"The default value is 1.0. "
"This setting is only used when Scored is set to True."
),
default
=
1.0
,
scope
=
Scope
.
settings
,
values
=
{
"min"
:
0
},
)
has_score
=
Boolean
(
help
=
"Does this LTI module have score?"
,
default
=
False
,
scope
=
Scope
.
settings
)
module_score
=
Float
(
help
=
"The score kept in the xblock KVS -- duplicate of the published score in django DB"
,
default
=
None
,
scope
=
Scope
.
user_state
)
score_comment
=
String
(
help
=
"Comment as returned from grader, LTI2.0 spec"
,
default
=
""
,
scope
=
Scope
.
user_state
)
hide_launch
=
Boolean
(
display_name
=
"Hide External Tool"
,
help
=
(
"Select True if you want to use this component as a placeholder for syncing with an external grading "
"system rather than launch an external tool. "
"This setting hides the Launch button and any IFrames for this component."
),
default
=
False
,
scope
=
Scope
.
settings
)
class
LTIModule
(
LTIFields
,
XModule
):
class
LTIModule
(
LTIFields
,
LTI20ModuleMixin
,
XModule
):
"""
Module provides LTI integration to course.
...
...
@@ -247,6 +340,18 @@ class LTIModule(LTIFields, XModule):
"""
Returns a context.
"""
# use bleach defaults. see https://github.com/jsocol/bleach/blob/master/bleach/__init__.py
# ALLOWED_TAGS are
# ['a', 'abbr', 'acronym', 'b', 'blockquote', 'code', 'em', 'i', 'li', 'ol', 'strong', 'ul']
#
# ALLOWED_ATTRIBUTES are
# 'a': ['href', 'title'],
# 'abbr': ['title'],
# 'acronym': ['title'],
#
# This lets all plaintext through.
sanitized_comment
=
bleach
.
clean
(
self
.
score_comment
)
return
{
'input_fields'
:
self
.
get_input_fields
(),
...
...
@@ -257,6 +362,11 @@ class LTIModule(LTIFields, XModule):
'open_in_a_new_page'
:
self
.
open_in_a_new_page
,
'display_name'
:
self
.
display_name
,
'form_url'
:
self
.
runtime
.
handler_url
(
self
,
'preview_handler'
)
.
rstrip
(
'/?'
),
'hide_launch'
:
self
.
hide_launch
,
'has_score'
:
self
.
has_score
,
'weight'
:
self
.
weight
,
'module_score'
:
self
.
module_score
,
'comment'
:
sanitized_comment
,
}
def
get_html
(
self
):
...
...
@@ -278,7 +388,7 @@ class LTIModule(LTIFields, XModule):
assert
user_id
is
not
None
return
unicode
(
urllib
.
quote
(
user_id
))
def
get_outcome_service_url
(
self
):
def
get_outcome_service_url
(
self
,
service_name
=
"grade_handler"
):
"""
Return URL for storing grades.
...
...
@@ -286,14 +396,10 @@ class LTIModule(LTIFields, XModule):
While testing locally and on Jenkins, mock_lti_server use http.referer
to obtain scheme, so it is ok to have http(s) anyway.
The scheme logic is handled in lms/lib/xblock/runtime.py
"""
scheme
=
'http'
if
'sandbox'
in
self
.
system
.
hostname
or
self
.
system
.
debug
else
'https'
uri
=
'{scheme}://{host}{path}'
.
format
(
scheme
=
scheme
,
host
=
self
.
system
.
hostname
,
path
=
self
.
runtime
.
handler_url
(
self
,
'grade_handler'
,
thirdparty
=
True
)
.
rstrip
(
'/?'
)
)
return
uri
return
self
.
runtime
.
handler_url
(
self
,
service_name
,
thirdparty
=
True
)
.
rstrip
(
'/?'
)
def
get_resource_link_id
(
self
):
"""
...
...
@@ -453,9 +559,8 @@ oauth_consumer_key="", oauth_signature="frVp4JuvT1mVXlxktiAUjQ7%2F1cw%3D"'}
def
max_score
(
self
):
return
self
.
weight
if
self
.
has_score
else
None
@XBlock.handler
def
grade_handler
(
self
,
request
,
dispatch
):
def
grade_handler
(
self
,
request
,
suffix
):
# pylint: disable=unused-argument
"""
This is called by courseware.module_render, to handle an AJAX call.
...
...
@@ -554,15 +659,7 @@ oauth_consumer_key="", oauth_signature="frVp4JuvT1mVXlxktiAUjQ7%2F1cw%3D"'}
return
Response
(
response_xml_template
.
format
(
**
failure_values
),
content_type
=
"application/xml"
)
if
action
==
'replaceResultRequest'
:
self
.
system
.
publish
(
self
,
'grade'
,
{
'value'
:
score
*
self
.
max_score
(),
'max_value'
:
self
.
max_score
(),
'user_id'
:
real_user
.
id
,
}
)
self
.
set_user_module_score
(
real_user
,
score
,
self
.
max_score
())
values
=
{
'imsx_codeMajor'
:
'success'
,
...
...
@@ -607,7 +704,7 @@ oauth_consumer_key="", oauth_signature="frVp4JuvT1mVXlxktiAUjQ7%2F1cw%3D"'}
return
imsx_messageIdentifier
,
sourcedId
,
score
,
action
def
verify_oauth_body_sign
(
self
,
request
):
def
verify_oauth_body_sign
(
self
,
request
,
content_type
=
'application/x-www-form-urlencoded'
):
"""
Verify grade request from LTI provider using OAuth body signing.
...
...
@@ -625,26 +722,26 @@ oauth_consumer_key="", oauth_signature="frVp4JuvT1mVXlxktiAUjQ7%2F1cw%3D"'}
client_key
,
client_secret
=
self
.
get_client_key_secret
()
headers
=
{
'Authorization'
:
unicode
(
request
.
headers
.
get
(
'Authorization'
)),
'Content-Type'
:
'application/x-www-form-urlencoded'
,
'Authorization'
:
unicode
(
request
.
headers
.
get
(
'Authorization'
)),
'Content-Type'
:
content_type
,
}
sha1
=
hashlib
.
sha1
()
sha1
.
update
(
request
.
body
)
oauth_body_hash
=
base64
.
b64encode
(
sha1
.
digest
())
oauth_params
=
signature
.
collect_parameters
(
headers
=
headers
,
exclude_oauth_signature
=
False
)
oauth_headers
=
dict
(
oauth_params
)
oauth_headers
=
dict
(
oauth_params
)
oauth_signature
=
oauth_headers
.
pop
(
'oauth_signature'
)
mock_request
=
mock
.
Mock
(
uri
=
unicode
(
urllib
.
unquote
(
request
.
url
)),
http_method
=
unicode
(
request
.
method
),
params
=
oauth_headers
.
items
(),
signature
=
oauth_signature
)
if
oauth_body_hash
!=
oauth_headers
.
get
(
'oauth_body_hash'
):
raise
LTIError
(
"OAuth body hash verification is failed."
)
if
not
signature
.
verify_hmac_sha1
(
mock_request
,
client_secret
):
raise
LTIError
(
"OAuth signature verification is failed."
)
...
...
@@ -674,3 +771,6 @@ class LTIDescriptor(LTIFields, MetadataOnlyEditingDescriptor, EmptyDataRawDescri
module_class
=
LTIModule
grade_handler
=
module_attr
(
'grade_handler'
)
preview_handler
=
module_attr
(
'preview_handler'
)
lti_2_0_result_rest_handler
=
module_attr
(
'lti_2_0_result_rest_handler'
)
clear_user_module_score
=
module_attr
(
'clear_user_module_score'
)
get_outcome_service_url
=
module_attr
(
'get_outcome_service_url'
)
common/lib/xmodule/xmodule/tests/test_lti20_unit.py
0 → 100644
View file @
f8c062b6
# -*- coding: utf-8 -*-
"""Tests for LTI Xmodule LTIv2.0 functional logic."""
import
textwrap
from
mock
import
Mock
from
xmodule.lti_module
import
LTIDescriptor
from
xmodule.lti_2_util
import
LTIError
from
.
import
LogicTest
class
LTI20RESTResultServiceTest
(
LogicTest
):
"""Logic tests for LTI module. LTI2.0 REST ResultService"""
descriptor_class
=
LTIDescriptor
def
setUp
(
self
):
super
(
LTI20RESTResultServiceTest
,
self
)
.
setUp
()
self
.
environ
=
{
'wsgi.url_scheme'
:
'http'
,
'REQUEST_METHOD'
:
'POST'
}
self
.
system
.
get_real_user
=
Mock
()
self
.
system
.
publish
=
Mock
()
self
.
system
.
rebind_noauth_module_to_user
=
Mock
()
self
.
user_id
=
self
.
xmodule
.
runtime
.
anonymous_student_id
self
.
lti_id
=
self
.
xmodule
.
lti_id
def
test_sanitize_get_context
(
self
):
"""Tests that the get_context function does basic sanitization"""
# get_context, unfortunately, requires a lot of mocking machinery
mocked_course
=
Mock
(
lti_passports
=
[
'lti_id:test_client:test_secret'
])
modulestore
=
Mock
()
modulestore
.
get_item
.
return_value
=
mocked_course
runtime
=
Mock
(
modulestore
=
modulestore
)
self
.
xmodule
.
descriptor
.
runtime
=
runtime
self
.
xmodule
.
lti_id
=
"lti_id"
self
.
xmodule
.
scope_ids
.
usage_id
=
"mocked"
test_cases
=
(
# (before sanitize, after sanitize)
(
u"plaintext"
,
u"plaintext"
),
(
u"a <script>alert(3)</script>"
,
u"a <script>alert(3)</script>"
),
# encodes scripts
(
u"<b>bold 包</b>"
,
u"<b>bold 包</b>"
),
# unicode, and <b> tags pass through
)
for
case
in
test_cases
:
self
.
xmodule
.
score_comment
=
case
[
0
]
self
.
assertEqual
(
case
[
1
],
self
.
xmodule
.
get_context
()[
'comment'
]
)
def
test_lti20_rest_bad_contenttype
(
self
):
"""
Input with bad content type
"""
with
self
.
assertRaisesRegexp
(
LTIError
,
"Content-Type must be"
):
request
=
Mock
(
headers
=
{
u'Content-Type'
:
u'Non-existent'
})
self
.
xmodule
.
verify_lti_2_0_result_rest_headers
(
request
)
def
test_lti20_rest_failed_oauth_body_verify
(
self
):
"""
Input with bad oauth body hash verification
"""
err_msg
=
"OAuth body verification failed"
self
.
xmodule
.
verify_oauth_body_sign
=
Mock
(
side_effect
=
LTIError
(
err_msg
))
with
self
.
assertRaisesRegexp
(
LTIError
,
err_msg
):
request
=
Mock
(
headers
=
{
u'Content-Type'
:
u'application/vnd.ims.lis.v2.result+json'
})
self
.
xmodule
.
verify_lti_2_0_result_rest_headers
(
request
)
def
test_lti20_rest_good_headers
(
self
):
"""
Input with good oauth body hash verification
"""
self
.
xmodule
.
verify_oauth_body_sign
=
Mock
(
return_value
=
True
)
request
=
Mock
(
headers
=
{
u'Content-Type'
:
u'application/vnd.ims.lis.v2.result+json'
})
self
.
xmodule
.
verify_lti_2_0_result_rest_headers
(
request
)
# We just want the above call to complete without exceptions, and to have called verify_oauth_body_sign
self
.
assertTrue
(
self
.
xmodule
.
verify_oauth_body_sign
.
called
)
BAD_DISPATCH_INPUTS
=
[
None
,
u""
,
u"abcd"
u"notuser/abcd"
u"user/"
u"user//"
u"user/gbere/"
u"user/gbere/xsdf"
u"user/ಠ益ಠ"
# not alphanumeric
]
def
test_lti20_rest_bad_dispatch
(
self
):
"""
Test the error cases for the "dispatch" argument to the LTI 2.0 handler. Anything that doesn't
fit the form user/<anon_id>
"""
for
einput
in
self
.
BAD_DISPATCH_INPUTS
:
with
self
.
assertRaisesRegexp
(
LTIError
,
"No valid user id found in endpoint URL"
):
self
.
xmodule
.
parse_lti_2_0_handler_suffix
(
einput
)
GOOD_DISPATCH_INPUTS
=
[
(
u"user/abcd3"
,
u"abcd3"
),
(
u"user/Äbcdè2"
,
u"Äbcdè2"
),
# unicode, just to make sure
]
def
test_lti20_rest_good_dispatch
(
self
):
"""
Test the good cases for the "dispatch" argument to the LTI 2.0 handler. Anything that does
fit the form user/<anon_id>
"""
for
ginput
,
expected
in
self
.
GOOD_DISPATCH_INPUTS
:
self
.
assertEquals
(
self
.
xmodule
.
parse_lti_2_0_handler_suffix
(
ginput
),
expected
)
BAD_JSON_INPUTS
=
[
# (bad inputs, error message expected)
([
u"kk"
,
# ValueError
u"{{}"
,
# ValueError
u"{}}"
,
# ValueError
3
,
# TypeError
{},
# TypeError
],
u"Supplied JSON string in request body could not be decoded"
),
([
u"3"
,
# valid json, not array or object
u"[]"
,
# valid json, array too small
u"[3, {}]"
,
# valid json, 1st element not an object
],
u"Supplied JSON string is a list that does not contain an object as the first element"
),
([
u'{"@type": "NOTResult"}'
,
# @type key must have value 'Result'
],
u"JSON object does not contain correct @type attribute"
),
([
# @context missing
u'{"@type": "Result", "resultScore": 0.1}'
,
],
u"JSON object does not contain required key"
),
([
u'''
{"@type": "Result",
"@context": "http://purl.imsglobal.org/ctx/lis/v2/Result",
"resultScore": 100}'''
# score out of range
],
u"score value outside the permitted range of 0-1."
),
([
u'''
{"@type": "Result",
"@context": "http://purl.imsglobal.org/ctx/lis/v2/Result",
"resultScore": "1b"}'''
,
# score ValueError
u'''
{"@type": "Result",
"@context": "http://purl.imsglobal.org/ctx/lis/v2/Result",
"resultScore": {}}'''
,
# score TypeError
],
u"Could not convert resultScore to float"
),
]
def
test_lti20_bad_json
(
self
):
"""
Test that bad json_str to parse_lti_2_0_result_json inputs raise appropriate LTI Error
"""
for
error_inputs
,
error_message
in
self
.
BAD_JSON_INPUTS
:
for
einput
in
error_inputs
:
with
self
.
assertRaisesRegexp
(
LTIError
,
error_message
):
self
.
xmodule
.
parse_lti_2_0_result_json
(
einput
)
GOOD_JSON_INPUTS
=
[
(
u'''
{"@type": "Result",
"@context": "http://purl.imsglobal.org/ctx/lis/v2/Result",
"resultScore": 0.1}'''
,
u""
),
# no comment means we expect ""
(
u'''
[{"@type": "Result",
"@context": "http://purl.imsglobal.org/ctx/lis/v2/Result",
"@id": "anon_id:abcdef0123456789",
"resultScore": 0.1}]'''
,
u""
),
# OK to have array of objects -- just take the first. @id is okay too
(
u'''
{"@type": "Result",
"@context": "http://purl.imsglobal.org/ctx/lis/v2/Result",
"resultScore": 0.1,
"comment": "ಠ益ಠ"}'''
,
u"ಠ益ಠ"
),
# unicode comment
]
def
test_lti20_good_json
(
self
):
"""
Test the parsing of good comments
"""
for
json_str
,
expected_comment
in
self
.
GOOD_JSON_INPUTS
:
score
,
comment
=
self
.
xmodule
.
parse_lti_2_0_result_json
(
json_str
)
self
.
assertEqual
(
score
,
0.1
)
self
.
assertEqual
(
comment
,
expected_comment
)
GOOD_JSON_PUT
=
textwrap
.
dedent
(
u"""
{"@type": "Result",
"@context": "http://purl.imsglobal.org/ctx/lis/v2/Result",
"@id": "anon_id:abcdef0123456789",
"resultScore": 0.1,
"comment": "ಠ益ಠ"}
"""
)
.
encode
(
'utf-8'
)
GOOD_JSON_PUT_LIKE_DELETE
=
textwrap
.
dedent
(
u"""
{"@type": "Result",
"@context": "http://purl.imsglobal.org/ctx/lis/v2/Result",
"@id": "anon_id:abcdef0123456789",
"comment": "ಠ益ಠ"}
"""
)
.
encode
(
'utf-8'
)
def
get_signed_lti20_mock_request
(
self
,
body
,
method
=
u'PUT'
):
"""
Example of signed from LTI 2.0 Provider. Signatures and hashes are example only and won't verify
"""
mock_request
=
Mock
()
mock_request
.
headers
=
{
'Content-Type'
:
'application/vnd.ims.lis.v2.result+json'
,
'Authorization'
:
(
u'OAuth oauth_nonce="135685044251684026041377608307", '
u'oauth_timestamp="1234567890", oauth_version="1.0", '
u'oauth_signature_method="HMAC-SHA1", '
u'oauth_consumer_key="test_client_key", '
u'oauth_signature="my_signature
%3
D", '
u'oauth_body_hash="gz+PeJZuF2//n9hNUnDj2v5kN70="'
)
}
mock_request
.
url
=
u'http://testurl'
mock_request
.
http_method
=
method
mock_request
.
method
=
method
mock_request
.
body
=
body
return
mock_request
USER_STANDIN
=
Mock
()
USER_STANDIN
.
id
=
999
def
setup_system_xmodule_mocks_for_lti20_request_test
(
self
):
"""
Helper fn to set up mocking for lti 2.0 request test
"""
self
.
system
.
get_real_user
=
Mock
(
return_value
=
self
.
USER_STANDIN
)
self
.
xmodule
.
max_score
=
Mock
(
return_value
=
1.0
)
self
.
xmodule
.
get_client_key_secret
=
Mock
(
return_value
=
(
'test_client_key'
,
u'test_client_secret'
))
self
.
xmodule
.
verify_oauth_body_sign
=
Mock
()
def
test_lti20_put_like_delete_success
(
self
):
"""
The happy path for LTI 2.0 PUT that acts like a delete
"""
self
.
setup_system_xmodule_mocks_for_lti20_request_test
()
SCORE
=
0.55
# pylint: disable=invalid-name
COMMENT
=
u"ಠ益ಠ"
# pylint: disable=invalid-name
self
.
xmodule
.
module_score
=
SCORE
self
.
xmodule
.
score_comment
=
COMMENT
mock_request
=
self
.
get_signed_lti20_mock_request
(
self
.
GOOD_JSON_PUT_LIKE_DELETE
)
# Now call the handler
response
=
self
.
xmodule
.
lti_2_0_result_rest_handler
(
mock_request
,
"user/abcd"
)
# Now assert there's no score
self
.
assertEqual
(
response
.
status_code
,
200
)
self
.
assertIsNone
(
self
.
xmodule
.
module_score
)
self
.
assertEqual
(
self
.
xmodule
.
score_comment
,
u""
)
(
_
,
evt_type
,
called_grade_obj
),
_
=
self
.
system
.
publish
.
call_args
self
.
assertEqual
(
called_grade_obj
,
{
'user_id'
:
self
.
USER_STANDIN
.
id
,
'value'
:
None
,
'max_value'
:
None
})
self
.
assertEqual
(
evt_type
,
'grade'
)
def
test_lti20_delete_success
(
self
):
"""
The happy path for LTI 2.0 DELETE
"""
self
.
setup_system_xmodule_mocks_for_lti20_request_test
()
SCORE
=
0.55
# pylint: disable=invalid-name
COMMENT
=
u"ಠ益ಠ"
# pylint: disable=invalid-name
self
.
xmodule
.
module_score
=
SCORE
self
.
xmodule
.
score_comment
=
COMMENT
mock_request
=
self
.
get_signed_lti20_mock_request
(
""
,
method
=
u'DELETE'
)
# Now call the handler
response
=
self
.
xmodule
.
lti_2_0_result_rest_handler
(
mock_request
,
"user/abcd"
)
# Now assert there's no score
self
.
assertEqual
(
response
.
status_code
,
200
)
self
.
assertIsNone
(
self
.
xmodule
.
module_score
)
self
.
assertEqual
(
self
.
xmodule
.
score_comment
,
u""
)
(
_
,
evt_type
,
called_grade_obj
),
_
=
self
.
system
.
publish
.
call_args
self
.
assertEqual
(
called_grade_obj
,
{
'user_id'
:
self
.
USER_STANDIN
.
id
,
'value'
:
None
,
'max_value'
:
None
})
self
.
assertEqual
(
evt_type
,
'grade'
)
def
test_lti20_put_set_score_success
(
self
):
"""
The happy path for LTI 2.0 PUT that sets a score
"""
self
.
setup_system_xmodule_mocks_for_lti20_request_test
()
mock_request
=
self
.
get_signed_lti20_mock_request
(
self
.
GOOD_JSON_PUT
)
# Now call the handler
response
=
self
.
xmodule
.
lti_2_0_result_rest_handler
(
mock_request
,
"user/abcd"
)
# Now assert
self
.
assertEqual
(
response
.
status_code
,
200
)
self
.
assertEqual
(
self
.
xmodule
.
module_score
,
0.1
)
self
.
assertEqual
(
self
.
xmodule
.
score_comment
,
u"ಠ益ಠ"
)
(
_
,
evt_type
,
called_grade_obj
),
_
=
self
.
system
.
publish
.
call_args
self
.
assertEqual
(
evt_type
,
'grade'
)
self
.
assertEqual
(
called_grade_obj
,
{
'user_id'
:
self
.
USER_STANDIN
.
id
,
'value'
:
0.1
,
'max_value'
:
1.0
})
def
test_lti20_get_no_score_success
(
self
):
"""
The happy path for LTI 2.0 GET when there's no score
"""
self
.
setup_system_xmodule_mocks_for_lti20_request_test
()
mock_request
=
self
.
get_signed_lti20_mock_request
(
""
,
method
=
u'GET'
)
# Now call the handler
response
=
self
.
xmodule
.
lti_2_0_result_rest_handler
(
mock_request
,
"user/abcd"
)
# Now assert
self
.
assertEqual
(
response
.
status_code
,
200
)
self
.
assertEqual
(
response
.
json
,
{
"@context"
:
"http://purl.imsglobal.org/ctx/lis/v2/Result"
,
"@type"
:
"Result"
})
def
test_lti20_get_with_score_success
(
self
):
"""
The happy path for LTI 2.0 GET when there is a score
"""
self
.
setup_system_xmodule_mocks_for_lti20_request_test
()
SCORE
=
0.55
# pylint: disable=invalid-name
COMMENT
=
u"ಠ益ಠ"
# pylint: disable=invalid-name
self
.
xmodule
.
module_score
=
SCORE
self
.
xmodule
.
score_comment
=
COMMENT
mock_request
=
self
.
get_signed_lti20_mock_request
(
""
,
method
=
u'GET'
)
# Now call the handler
response
=
self
.
xmodule
.
lti_2_0_result_rest_handler
(
mock_request
,
"user/abcd"
)
# Now assert
self
.
assertEqual
(
response
.
status_code
,
200
)
self
.
assertEqual
(
response
.
json
,
{
"@context"
:
"http://purl.imsglobal.org/ctx/lis/v2/Result"
,
"@type"
:
"Result"
,
"resultScore"
:
SCORE
,
"comment"
:
COMMENT
})
UNSUPPORTED_HTTP_METHODS
=
[
"OPTIONS"
,
"HEAD"
,
"POST"
,
"TRACE"
,
"CONNECT"
]
def
test_lti20_unsupported_method_error
(
self
):
"""
Test we get a 404 when we don't GET or PUT
"""
self
.
setup_system_xmodule_mocks_for_lti20_request_test
()
mock_request
=
self
.
get_signed_lti20_mock_request
(
self
.
GOOD_JSON_PUT
)
for
bad_method
in
self
.
UNSUPPORTED_HTTP_METHODS
:
mock_request
.
method
=
bad_method
response
=
self
.
xmodule
.
lti_2_0_result_rest_handler
(
mock_request
,
"user/abcd"
)
self
.
assertEqual
(
response
.
status_code
,
404
)
def
test_lti20_request_handler_bad_headers
(
self
):
"""
Test that we get a 401 when header verification fails
"""
self
.
setup_system_xmodule_mocks_for_lti20_request_test
()
self
.
xmodule
.
verify_lti_2_0_result_rest_headers
=
Mock
(
side_effect
=
LTIError
())
mock_request
=
self
.
get_signed_lti20_mock_request
(
self
.
GOOD_JSON_PUT
)
response
=
self
.
xmodule
.
lti_2_0_result_rest_handler
(
mock_request
,
"user/abcd"
)
self
.
assertEqual
(
response
.
status_code
,
401
)
def
test_lti20_request_handler_bad_dispatch_user
(
self
):
"""
Test that we get a 404 when there's no (or badly formatted) user specified in the url
"""
self
.
setup_system_xmodule_mocks_for_lti20_request_test
()
mock_request
=
self
.
get_signed_lti20_mock_request
(
self
.
GOOD_JSON_PUT
)
response
=
self
.
xmodule
.
lti_2_0_result_rest_handler
(
mock_request
,
None
)
self
.
assertEqual
(
response
.
status_code
,
404
)
def
test_lti20_request_handler_bad_json
(
self
):
"""
Test that we get a 404 when json verification fails
"""
self
.
setup_system_xmodule_mocks_for_lti20_request_test
()
self
.
xmodule
.
parse_lti_2_0_result_json
=
Mock
(
side_effect
=
LTIError
())
mock_request
=
self
.
get_signed_lti20_mock_request
(
self
.
GOOD_JSON_PUT
)
response
=
self
.
xmodule
.
lti_2_0_result_rest_handler
(
mock_request
,
"user/abcd"
)
self
.
assertEqual
(
response
.
status_code
,
404
)
def
test_lti20_request_handler_bad_user
(
self
):
"""
Test that we get a 404 when the supplied user does not exist
"""
self
.
setup_system_xmodule_mocks_for_lti20_request_test
()
self
.
system
.
get_real_user
=
Mock
(
return_value
=
None
)
mock_request
=
self
.
get_signed_lti20_mock_request
(
self
.
GOOD_JSON_PUT
)
response
=
self
.
xmodule
.
lti_2_0_result_rest_handler
(
mock_request
,
"user/abcd"
)
self
.
assertEqual
(
response
.
status_code
,
404
)
common/lib/xmodule/xmodule/tests/test_lti_unit.py
View file @
f8c062b6
...
...
@@ -2,21 +2,15 @@
"""Test for LTI Xmodule functional logic."""
from
mock
import
Mock
,
patch
,
PropertyMock
import
mock
import
textwrap
import
json
from
lxml
import
etree
import
json
from
webob.request
import
Request
from
copy
import
copy
from
collections
import
OrderedDict
import
urllib
import
oauthlib
import
hashlib
import
base64
from
xmodule.lti_module
import
LTIDescriptor
,
LTIError
from
xmodule.lti_module
import
LTIDescriptor
from
xmodule.lti_2_util
import
LTIError
from
.
import
LogicTest
...
...
@@ -56,6 +50,7 @@ class LTIModuleTest(LogicTest):
"""
)
self
.
system
.
get_real_user
=
Mock
()
self
.
system
.
publish
=
Mock
()
self
.
system
.
rebind_noauth_module_to_user
=
Mock
()
self
.
user_id
=
self
.
xmodule
.
runtime
.
anonymous_student_id
self
.
lti_id
=
self
.
xmodule
.
lti_id
...
...
@@ -239,6 +234,7 @@ class LTIModuleTest(LogicTest):
self
.
assertEqual
(
response
.
status_code
,
200
)
self
.
assertDictEqual
(
expected_response
,
real_response
)
self
.
assertEqual
(
self
.
xmodule
.
module_score
,
float
(
self
.
DEFAULTS
[
'grade'
]))
def
test_user_id
(
self
):
expected_user_id
=
unicode
(
urllib
.
quote
(
self
.
xmodule
.
runtime
.
anonymous_student_id
))
...
...
@@ -246,13 +242,16 @@ class LTIModuleTest(LogicTest):
self
.
assertEqual
(
real_user_id
,
expected_user_id
)
def
test_outcome_service_url
(
self
):
expected_outcome_service_url
=
'{scheme}://{host}{path}'
.
format
(
scheme
=
'http'
if
self
.
xmodule
.
runtime
.
debug
else
'https'
,
host
=
self
.
xmodule
.
runtime
.
hostname
,
path
=
self
.
xmodule
.
runtime
.
handler_url
(
self
.
xmodule
,
'grade_handler'
,
thirdparty
=
True
)
.
rstrip
(
'/?'
)
)
real_outcome_service_url
=
self
.
xmodule
.
get_outcome_service_url
()
self
.
assertEqual
(
real_outcome_service_url
,
expected_outcome_service_url
)
mock_url_prefix
=
'https://hostname/'
test_service_name
=
"test_service"
def
mock_handler_url
(
block
,
handler_name
,
**
kwargs
):
# pylint: disable=unused-argument
"""Mock function for returning fully-qualified handler urls"""
return
mock_url_prefix
+
handler_name
self
.
xmodule
.
runtime
.
handler_url
=
Mock
(
side_effect
=
mock_handler_url
)
real_outcome_service_url
=
self
.
xmodule
.
get_outcome_service_url
(
service_name
=
test_service_name
)
self
.
assertEqual
(
real_outcome_service_url
,
mock_url_prefix
+
test_service_name
)
def
test_resource_link_id
(
self
):
with
patch
(
'xmodule.lti_module.LTIModule.location'
,
new_callable
=
PropertyMock
)
as
mock_location
:
...
...
@@ -398,13 +397,11 @@ class LTIModuleTest(LogicTest):
def
test_max_score
(
self
):
self
.
xmodule
.
weight
=
100.0
self
.
xmodule
.
graded
=
True
self
.
assertFalse
(
self
.
xmodule
.
has_score
)
self
.
assertEqual
(
self
.
xmodule
.
max_score
(),
None
)
self
.
xmodule
.
has_score
=
True
self
.
assertEqual
(
self
.
xmodule
.
max_score
(),
100.0
)
self
.
xmodule
.
graded
=
False
self
.
assertEqual
(
self
.
xmodule
.
max_score
(),
100.0
)
def
test_context_id
(
self
):
...
...
common/lib/xmodule/xmodule/x_module.py
View file @
f8c062b6
...
...
@@ -1134,7 +1134,7 @@ class XMLParsingSystem(DescriptorSystem):
self
.
process_xml
=
process_xml
class
ModuleSystem
(
MetricsMixin
,
ConfigurableFragmentWrapper
,
Runtime
):
# pylint: disable=abstract-method
class
ModuleSystem
(
MetricsMixin
,
ConfigurableFragmentWrapper
,
Runtime
):
# pylint: disable=abstract-method
"""
This is an abstraction such that x_modules can function independent
of the courseware (e.g. import into other types of courseware, LMS,
...
...
@@ -1154,7 +1154,7 @@ class ModuleSystem(MetricsMixin,ConfigurableFragmentWrapper, Runtime): # pylint
open_ended_grading_interface
=
None
,
s3_interface
=
None
,
cache
=
None
,
can_execute_unsafe_code
=
None
,
replace_course_urls
=
None
,
replace_jump_to_id_urls
=
None
,
error_descriptor_class
=
None
,
get_real_user
=
None
,
field_data
=
None
,
get_user_role
=
None
,
field_data
=
None
,
get_user_role
=
None
,
rebind_noauth_module_to_user
=
None
,
**
kwargs
):
"""
Create a closure around the system environment.
...
...
@@ -1213,6 +1213,9 @@ class ModuleSystem(MetricsMixin,ConfigurableFragmentWrapper, Runtime): # pylint
for LMS and Studio.
field_data - the `FieldData` to use for backing XBlock storage.
rebind_noauth_module_to_user - rebinds module bound to AnonymousUser to a real user...used in LTI
modules, which have an anonymous handler, to set legitimate users' data
"""
# Usage_store is unused, and field_data is often supplanted with an
...
...
@@ -1251,6 +1254,7 @@ class ModuleSystem(MetricsMixin,ConfigurableFragmentWrapper, Runtime): # pylint
self
.
get_user_role
=
get_user_role
self
.
descriptor_runtime
=
descriptor_runtime
self
.
rebind_noauth_module_to_user
=
rebind_noauth_module_to_user
def
get
(
self
,
attr
):
""" provide uniform access to attributes (like etree)."""
...
...
lms/djangoapps/courseware/features/lti.feature
View file @
f8c062b6
...
...
@@ -46,7 +46,7 @@ Feature: LMS.LTI component
And the course has an LTI component with correct fields
:
|
open_in_a_new_page
|
weight
|
is_graded
|
has_score
|
|
False
|
10
|
True
|
True
|
And
I submit answer to LTI question
And
I submit answer to LTI
1
question
And
I click on the
"Progress"
tab
Then I see text "Problem Scores
:
5/10"
And
I see graph with total progress
"5%"
...
...
@@ -72,7 +72,65 @@ Feature: LMS.LTI component
And the course has an LTI component with correct fields
:
|
open_in_a_new_page
|
weight
|
is_graded
|
has_score
|
|
False
|
10
|
True
|
True
|
And
I submit answer to LTI question
And
I submit answer to LTI
1
question
And
I click on the
"Progress"
tab
Then I see text "Problem Scores
:
5/10"
And
I see graph with total progress
"5%"
#9
Scenario
:
Graded LTI component in LMS is correctly works with LTI2.0 PUT callback
Given
the course has correct LTI credentials with registered Instructor
And the course has an LTI component with correct fields
:
|
open_in_a_new_page
|
weight
|
is_graded
|
has_score
|
|
False
|
10
|
True
|
True
|
And
I submit answer to LTI 2 question
And
I click on the
"Progress"
tab
Then I see text "Problem Scores
:
8/10"
And
I see graph with total progress
"8%"
Then
I click on the
"Instructor"
tab
And
I click on the
"Gradebook"
tab
And
I see in the gradebook table that
"HW"
is
"80"
And
I see in the gradebook table that
"Total"
is
"8"
And
I visit the LTI component
Then
I see LTI component progress with text
"(8.0 / 10.0 points)"
Then
I see LTI component feedback with text
"This is awesome."
#10
Scenario
:
Graded LTI component in LMS is correctly works with LTI2.0 PUT delete callback
Given
the course has correct LTI credentials with registered Instructor
And the course has an LTI component with correct fields
:
|
open_in_a_new_page
|
weight
|
is_graded
|
has_score
|
|
False
|
10
|
True
|
True
|
And
I submit answer to LTI 2 question
And
I visit the LTI component
Then
I see LTI component progress with text
"(8.0 / 10.0 points)"
Then
I see LTI component feedback with text
"This is awesome."
And
the LTI provider deletes my grade and feedback
And
I visit the LTI component (have to reload)
Then
I see LTI component progress with text
"(10.0 points possible)"
Then
in the LTI component I do not see feedback
And
I click on the
"Progress"
tab
Then I see text "Problem Scores
:
0/10"
And
I see graph with total progress
"0%"
Then
I click on the
"Instructor"
tab
And
I click on the
"Gradebook"
tab
And
I see in the gradebook table that
"HW"
is
"0"
And
I see in the gradebook table that
"Total"
is
"0"
#11
Scenario
:
LTI component that set to hide_launch and open_in_a_new_page shows no button
Given
the course has correct LTI credentials with registered Instructor
And the course has an LTI component with correct fields
:
|
open_in_a_new_page
|
hide_launch
|
|
False
|
True
|
Then
in the LTI component I do not see a launch button
Then
I see LTI component module title with text
"LTI (EXTERNAL RESOURCE)"
#12
Scenario
:
LTI component that set to hide_launch and not open_in_a_new_page shows no iframe
Given
the course has correct LTI credentials with registered Instructor
And the course has an LTI component with correct fields
:
|
open_in_a_new_page
|
hide_launch
|
|
True
|
True
|
Then
in the LTI component I do not see an provider iframe
Then
I see LTI component module title with text
"LTI (EXTERNAL RESOURCE)"
lms/djangoapps/courseware/features/lti.py
View file @
f8c062b6
...
...
@@ -2,24 +2,17 @@
import
datetime
import
os
import
pytz
from
django.conf
import
settings
from
mock
import
patch
from
pytz
import
UTC
from
nose.tools
import
assert_equal
from
splinter.exceptions
import
ElementDoesNotExist
from
django.contrib.auth.models
import
User
from
django.core.urlresolvers
import
reverse
from
django.conf
import
settings
from
nose.tools
import
assert_true
,
assert_equal
,
assert_in
from
lettuce
import
world
,
step
from
lettuce.django
import
django_url
from
common
import
course_id
,
visit_scenario_item
from
courseware.tests.factories
import
InstructorFactory
,
BetaTesterFactory
from
courseware.access
import
has_access
from
student.tests.factories
import
UserFactory
from
nose.tools
import
assert_equals
from
common
import
course_id
,
visit_scenario_item
...
...
@@ -248,6 +241,22 @@ def check_lti_popup():
world
.
browser
.
switch_to_window
(
parent_window
)
# Switch to the main window again
@step
(
'visit the LTI component'
)
def
visit_lti_component
(
_step
):
visit_scenario_item
(
'LTI'
)
@step
(
'I see LTI component (.*) with text "([^"]*)"$'
)
def
see_elem_text
(
_step
,
elem
,
text
):
selector_map
=
{
'progress'
:
'.problem-progress'
,
'feedback'
:
'.problem-feedback'
,
'module title'
:
'.problem-header'
}
assert_in
(
elem
,
selector_map
)
assert_true
(
world
.
css_has_text
(
selector_map
[
elem
],
text
))
@step
(
'I see text "([^"]*)"$'
)
def
check_progress
(
_step
,
text
):
assert
world
.
browser
.
is_text_present
(
text
)
...
...
@@ -255,37 +264,53 @@ def check_progress(_step, text):
@step
(
'I see graph with total progress "([^"]*)"$'
)
def
see_graph
(
_step
,
progress
):
SELECTOR
=
'grade-detail-graph'
XPATH
=
'//div[@id="{parent}"]//div[text()="{progress}"]'
.
format
(
parent
=
SELECTOR
,
selector
=
'grade-detail-graph'
xpath
=
'//div[@id="{parent}"]//div[text()="{progress}"]'
.
format
(
parent
=
selector
,
progress
=
progress
,
)
node
=
world
.
browser
.
find_by_xpath
(
XPATH
)
node
=
world
.
browser
.
find_by_xpath
(
xpath
)
assert
node
@step
(
'I see in the gradebook table that "([^"]*)" is "([^"]*)"$'
)
def
see_value_in_the_gradebook
(
_step
,
label
,
text
):
TABLE_SELECTOR
=
'.grade-table'
table_selector
=
'.grade-table'
index
=
0
table_headers
=
world
.
css_find
(
'{0} thead th'
.
format
(
TABLE_SELECTOR
))
table_headers
=
world
.
css_find
(
'{0} thead th'
.
format
(
table_selector
))
for
i
,
element
in
enumerate
(
table_headers
):
if
element
.
text
.
strip
()
==
label
:
index
=
i
break
;
assert
world
.
css_has_text
(
'{0} tbody td'
.
format
(
TABLE_SELECTOR
),
text
,
index
=
index
)
assert
_true
(
world
.
css_has_text
(
'{0} tbody td'
.
format
(
table_selector
),
text
,
index
=
index
)
)
@step
(
'I submit answer to LTI question$'
)
def
click_grade
(
_step
):
@step
(
'I submit answer to LTI (.*) question$'
)
def
click_grade
(
_step
,
version
):
version_map
=
{
'1'
:
{
'selector'
:
'submit-button'
,
'expected_text'
:
'LTI consumer (edX) responded with XML content'
},
'2'
:
{
'selector'
:
'submit-lti2-button'
,
'expected_text'
:
'LTI consumer (edX) responded with HTTP 200'
},
}
assert_in
(
version
,
version_map
)
location
=
world
.
scenario_dict
[
'LTI'
]
.
location
.
html_id
()
iframe_name
=
'ltiFrame-'
+
location
with
world
.
browser
.
get_iframe
(
iframe_name
)
as
iframe
:
iframe
.
find_by_name
(
'submit-button'
)
.
first
.
click
()
assert
iframe
.
is_text_present
(
'LTI consumer (edX) responded with XML content'
)
iframe
.
find_by_name
(
version_map
[
version
][
'selector'
])
.
first
.
click
()
assert
iframe
.
is_text_present
(
version_map
[
version
][
'expected_text'
])
@step
(
'LTI provider deletes my grade and feedback$'
)
def
click_delete_button
(
_step
):
with
world
.
browser
.
get_iframe
(
get_lti_frame_name
())
as
iframe
:
iframe
.
find_by_name
(
'submit-lti2-delete-button'
)
.
first
.
click
()
def
get_lti_frame_name
():
location
=
world
.
scenario_dict
[
'LTI'
]
.
location
.
html_id
()
return
'ltiFrame-'
+
location
@step
(
'I see in iframe that LTI role is (.*)$'
)
...
...
@@ -310,3 +335,14 @@ def switch_view(_step, view):
world
.
css_click
(
'#staffstatus'
)
world
.
wait_for_ajax_complete
()
@step
(
"in the LTI component I do not see (.*)$"
)
def
check_lti_component_no_elem
(
_step
,
text
):
selector_map
=
{
'a launch button'
:
'.link_lti_new_window'
,
'an provider iframe'
:
'.ltiLaunchFrame'
,
'feedback'
:
'.problem-feedback'
,
'progress'
:
'.problem-progress'
,
}
assert_in
(
text
,
selector_map
)
assert_true
(
world
.
is_css_not_present
(
selector_map
[
text
]))
lms/djangoapps/courseware/model_data.py
View file @
f8c062b6
...
...
@@ -289,7 +289,6 @@ class DjangoKeyValueStore(KeyValueStore):
Scope
.
user_info
,
)
def
__init__
(
self
,
field_data_cache
):
self
.
_field_data_cache
=
field_data_cache
...
...
lms/djangoapps/courseware/module_render.py
View file @
f8c062b6
...
...
@@ -60,6 +60,13 @@ xqueue_interface = XQueueInterface(
)
class
LmsModuleRenderError
(
Exception
):
"""
An exception class for exceptions thrown by module_render that don't fit well elsewhere
"""
pass
def
make_track_function
(
request
):
'''
Make a tracking function that logs what happened.
...
...
@@ -210,24 +217,30 @@ def get_module_for_descriptor(user, request, descriptor, field_data_cache, cours
static_asset_path
)
def
get_module_for_descriptor_internal
(
user
,
descriptor
,
field_data_cache
,
course_id
,
track_function
,
xqueue_callback_url_prefix
,
position
=
None
,
wrap_xmodule_display
=
True
,
grade_bucket_type
=
None
,
static_asset_path
=
''
):
def
get_module_system_for_user
(
user
,
field_data_cache
,
# Arguments preceding this comment have user binding, those following don't
descriptor
,
course_id
,
track_function
,
xqueue_callback_url_prefix
,
position
=
None
,
wrap_xmodule_display
=
True
,
grade_bucket_type
=
None
,
static_asset_path
=
''
):
"""
Actually implement get_module, without requiring a request
.
Helper function that returns a module system and student_data bound to a user and a descriptor
.
See get_module() docstring for further details.
"""
The purpose of this function is to factor out everywhere a user is implicitly bound when creating a module,
to allow an existing module to be re-bound to a user. Most of the user bindings happen when creating the
closures that feed the instantiation of ModuleSystem.
# Do not check access when it's a noauth request.
if
getattr
(
user
,
'known'
,
True
):
# Short circuit--if the user shouldn't have access, bail without doing any work
if
not
has_access
(
user
,
descriptor
,
'load'
,
course_id
):
return
None
The arguments fall into two categories: those that have explicit or implicit user binding, which are user
and field_data_cache, and those don't and are just present so that ModuleSystem can be instantiated, which
are all the other arguments. Ultimately, this isn't too different than how get_module_for_descriptor_internal
was before refactoring.
student_data
=
KvsFieldData
(
DjangoKeyValueStore
(
field_data_cache
))
Arguments:
see arguments for get_module()
Returns:
(LmsModuleSystem, KvsFieldData): (module system, student_data) bound to, primarily, the user and descriptor
"""
student_data
=
KvsFieldData
(
DjangoKeyValueStore
(
field_data_cache
))
def
make_xqueue_callback
(
dispatch
=
'score_update'
):
# Fully qualified callback URL for external queueing system
...
...
@@ -333,6 +346,49 @@ def get_module_for_descriptor_internal(user, descriptor, field_data_cache, cours
else
:
track_function
(
event_type
,
event
)
def
rebind_noauth_module_to_user
(
module
,
real_user
):
"""
A function that allows a module to get re-bound to a real user if it was previously bound to an AnonymousUser.
Will only work within a module bound to an AnonymousUser, e.g. one that's instantiated by the noauth_handler.
Arguments:
module (any xblock type): the module to rebind
real_user (django.contrib.auth.models.User): the user to bind to
Returns:
nothing (but the side effect is that module is re-bound to real_user)
"""
if
user
.
is_authenticated
():
err_msg
=
(
"rebind_noauth_module_to_user can only be called from a module bound to "
"an anonymous user"
)
log
.
error
(
err_msg
)
raise
LmsModuleRenderError
(
err_msg
)
field_data_cache_real_user
=
FieldDataCache
.
cache_for_descriptor_descendents
(
course_id
,
real_user
,
module
.
descriptor
)
(
inner_system
,
inner_student_data
)
=
get_module_system_for_user
(
real_user
,
field_data_cache_real_user
,
# These have implicit user bindings, rest of args considered not to
module
.
descriptor
,
course_id
,
track_function
,
xqueue_callback_url_prefix
,
position
,
wrap_xmodule_display
,
grade_bucket_type
,
static_asset_path
)
# rebinds module to a different student. We'll change system, student_data, and scope_ids
module
.
descriptor
.
bind_for_student
(
inner_system
,
LmsFieldData
(
module
.
descriptor
.
_field_data
,
inner_student_data
)
# pylint: disable=protected-access
)
module
.
descriptor
.
scope_ids
=
(
module
.
descriptor
.
scope_ids
.
_replace
(
user_id
=
real_user
.
id
)
# pylint: disable=protected-access
)
module
.
scope_ids
=
module
.
descriptor
.
scope_ids
# this is needed b/c NamedTuples are immutable
# now bind the module to the new ModuleSystem instance and vice-versa
module
.
runtime
=
inner_system
inner_system
.
xmodule_instance
=
module
# Build a list of wrapping functions that will be applied in order
# to the Fragment content coming out of the xblocks that are about to be rendered.
block_wrappers
=
[]
...
...
@@ -433,6 +489,7 @@ def get_module_for_descriptor_internal(user, descriptor, field_data_cache, cours
},
get_user_role
=
lambda
:
get_user_role
(
user
,
course_id
),
descriptor_runtime
=
descriptor
.
runtime
,
rebind_noauth_module_to_user
=
rebind_noauth_module_to_user
,
)
# pass position specified in URL to module through ModuleSystem
...
...
@@ -451,6 +508,31 @@ def get_module_for_descriptor_internal(user, descriptor, field_data_cache, cours
else
:
system
.
error_descriptor_class
=
NonStaffErrorDescriptor
return
system
,
student_data
def
get_module_for_descriptor_internal
(
user
,
descriptor
,
field_data_cache
,
course_id
,
# pylint: disable=invalid-name
track_function
,
xqueue_callback_url_prefix
,
position
=
None
,
wrap_xmodule_display
=
True
,
grade_bucket_type
=
None
,
static_asset_path
=
''
):
"""
Actually implement get_module, without requiring a request.
See get_module() docstring for further details.
"""
# Do not check access when it's a noauth request.
if
getattr
(
user
,
'known'
,
True
):
# Short circuit--if the user shouldn't have access, bail without doing any work
if
not
has_access
(
user
,
descriptor
,
'load'
,
course_id
):
return
None
(
system
,
student_data
)
=
get_module_system_for_user
(
user
,
field_data_cache
,
# These have implicit user bindings, the rest of args are considered not to
descriptor
,
course_id
,
track_function
,
xqueue_callback_url_prefix
,
position
,
wrap_xmodule_display
,
grade_bucket_type
,
static_asset_path
)
descriptor
.
bind_for_student
(
system
,
LmsFieldData
(
descriptor
.
_field_data
,
student_data
))
# pylint: disable=protected-access
descriptor
.
scope_ids
=
descriptor
.
scope_ids
.
_replace
(
user_id
=
user
.
id
)
# pylint: disable=protected-access
return
descriptor
...
...
lms/djangoapps/courseware/tests/test_lti_integration.py
View file @
f8c062b6
"""LTI integration tests"""
import
oauthlib
from
.
import
BaseTestXmodule
from
collections
import
OrderedDict
import
mock
import
urllib
import
json
from
django.test.utils
import
override_settings
from
django.core.urlresolvers
import
reverse
from
django.conf
import
settings
from
xmodule.modulestore.tests.django_utils
import
ModuleStoreTestCase
from
xmodule.modulestore.tests.factories
import
CourseFactory
,
ItemFactory
from
xmodule.modulestore
import
Location
from
courseware.tests
import
BaseTestXmodule
from
courseware.tests.modulestore_config
import
TEST_DATA_MIXED_MODULESTORE
from
courseware.views
import
get_course_lti_endpoints
from
lms.lib.xblock.runtime
import
quote_slashes
class
TestLTI
(
BaseTestXmodule
):
"""
...
...
@@ -71,7 +83,13 @@ class TestLTI(BaseTestXmodule):
'element_id'
:
self
.
item_descriptor
.
location
.
html_id
(),
'launch_url'
:
'http://www.example.com'
,
# default value
'open_in_a_new_page'
:
True
,
'form_url'
:
self
.
item_descriptor
.
xmodule_runtime
.
handler_url
(
self
.
item_descriptor
,
'preview_handler'
)
.
rstrip
(
'/?'
),
'form_url'
:
self
.
item_descriptor
.
xmodule_runtime
.
handler_url
(
self
.
item_descriptor
,
'preview_handler'
)
.
rstrip
(
'/?'
),
'hide_launch'
:
False
,
'has_score'
:
False
,
'module_score'
:
None
,
'comment'
:
u''
,
'weight'
:
1.0
,
}
def
mocked_sign
(
self
,
*
args
,
**
kwargs
):
...
...
@@ -95,10 +113,111 @@ class TestLTI(BaseTestXmodule):
def
test_lti_constructor
(
self
):
generated_content
=
self
.
item_descriptor
.
render
(
'student_view'
)
.
content
expected_content
=
self
.
runtime
.
render_template
(
'lti.html'
,
self
.
expected_context
)
expected_content
=
self
.
runtime
.
render_template
(
'lti.html'
,
self
.
expected_context
)
self
.
assertEqual
(
generated_content
,
expected_content
)
def
test_lti_preview_handler
(
self
):
generated_content
=
self
.
item_descriptor
.
preview_handler
(
None
,
None
)
.
body
expected_content
=
self
.
runtime
.
render_template
(
'lti_form.html'
,
self
.
expected_context
)
self
.
assertEqual
(
generated_content
,
expected_content
)
@override_settings
(
MODULESTORE
=
TEST_DATA_MIXED_MODULESTORE
)
class
TestLTIModuleListing
(
ModuleStoreTestCase
):
"""
a test for the rest endpoint that lists LTI modules in a course
"""
# arbitrary constant
COURSE_SLUG
=
"100"
COURSE_NAME
=
"test_course"
def
setUp
(
self
):
"""Create course, 2 chapters, 2 sections"""
self
.
course
=
CourseFactory
.
create
(
display_name
=
self
.
COURSE_NAME
,
number
=
self
.
COURSE_SLUG
)
self
.
chapter1
=
ItemFactory
.
create
(
parent_location
=
self
.
course
.
location
,
display_name
=
"chapter1"
,
category
=
'chapter'
)
self
.
section1
=
ItemFactory
.
create
(
parent_location
=
self
.
chapter1
.
location
,
display_name
=
"section1"
,
category
=
'sequential'
)
self
.
chapter2
=
ItemFactory
.
create
(
parent_location
=
self
.
course
.
location
,
display_name
=
"chapter2"
,
category
=
'chapter'
)
self
.
section2
=
ItemFactory
.
create
(
parent_location
=
self
.
chapter2
.
location
,
display_name
=
"section2"
,
category
=
'sequential'
)
self
.
published_location_dict
=
{
'tag'
:
'i4x'
,
'org'
:
self
.
course
.
location
.
org
,
'category'
:
'lti'
,
'course'
:
self
.
course
.
location
.
course
,
'name'
:
'lti_published'
}
self
.
draft_location_dict
=
{
'tag'
:
'i4x'
,
'org'
:
self
.
course
.
location
.
org
,
'category'
:
'lti'
,
'course'
:
self
.
course
.
location
.
course
,
'name'
:
'lti_draft'
,
'revision'
:
'draft'
}
# creates one draft and one published lti module, in different sections
self
.
lti_published
=
ItemFactory
.
create
(
parent_location
=
self
.
section1
.
location
,
display_name
=
"lti published"
,
category
=
"lti"
,
location
=
Location
(
self
.
published_location_dict
)
)
self
.
lti_draft
=
ItemFactory
.
create
(
parent_location
=
self
.
section2
.
location
,
display_name
=
"lti draft"
,
category
=
"lti"
,
location
=
Location
(
self
.
draft_location_dict
)
)
def
expected_handler_url
(
self
,
handler
):
"""convenience method to get the reversed handler urls"""
return
"https://{}{}"
.
format
(
settings
.
SITE_NAME
,
reverse
(
'courseware.module_render.handle_xblock_callback_noauth'
,
args
=
[
self
.
course
.
id
,
quote_slashes
(
unicode
(
self
.
lti_published
.
scope_ids
.
usage_id
)
.
encode
(
'utf-8'
)),
handler
]
))
def
test_lti_rest_bad_course
(
self
):
"""Tests what happens when the lti listing rest endpoint gets a bad course_id"""
bad_ids
=
[
u"sf"
,
u"dne/dne/dne"
,
u"fo/ey/
\u5305
"
]
request
=
mock
.
Mock
()
request
.
method
=
'GET'
for
bad_course_id
in
bad_ids
:
response
=
get_course_lti_endpoints
(
request
,
bad_course_id
)
self
.
assertEqual
(
404
,
response
.
status_code
)
def
test_lti_rest_listing
(
self
):
"""tests that the draft lti module is not a part of the endpoint response, but the published one is"""
request
=
mock
.
Mock
()
request
.
method
=
'GET'
response
=
get_course_lti_endpoints
(
request
,
self
.
course
.
id
)
self
.
assertEqual
(
200
,
response
.
status_code
)
self
.
assertEqual
(
'application/json'
,
response
[
'Content-Type'
])
expected
=
{
"lti_1_1_result_service_xml_endpoint"
:
self
.
expected_handler_url
(
'grade_handler'
),
"lti_2_0_result_service_json_endpoint"
:
self
.
expected_handler_url
(
'lti_2_0_result_rest_handler'
)
+
"/user/{anon_user_id}"
,
"display_name"
:
self
.
lti_published
.
display_name
}
self
.
assertEqual
([
expected
],
json
.
loads
(
response
.
content
))
def
test_lti_rest_non_get
(
self
):
"""tests that the endpoint returns 404 when hit with NON-get"""
DISALLOWED_METHODS
=
(
"POST"
,
"PUT"
,
"DELETE"
,
"HEAD"
,
"OPTIONS"
)
# pylint: disable=invalid-name
for
method
in
DISALLOWED_METHODS
:
request
=
mock
.
Mock
()
request
.
method
=
method
response
=
get_course_lti_endpoints
(
request
,
self
.
course
.
id
)
self
.
assertEqual
(
405
,
response
.
status_code
)
lms/djangoapps/courseware/tests/test_module_render.py
View file @
f8c062b6
...
...
@@ -12,6 +12,7 @@ from django.conf import settings
from
django.test
import
TestCase
from
django.test.client
import
RequestFactory
from
django.test.utils
import
override_settings
from
django.contrib.auth.models
import
AnonymousUser
from
capa.tests.response_xml_factory
import
OptionResponseXMLFactory
from
xblock.field_data
import
FieldData
...
...
@@ -27,13 +28,16 @@ from xmodule.x_module import XModuleDescriptor
from
courseware
import
module_render
as
render
from
courseware.courses
import
get_course_with_access
,
course_image_url
,
get_course_info_section
from
courseware.model_data
import
FieldDataCache
from
courseware.models
import
StudentModule
from
courseware.tests.factories
import
StudentModuleFactory
,
UserFactory
,
GlobalStaffFactory
from
courseware.tests.tests
import
LoginEnrollmentTestCase
from
courseware.tests.modulestore_config
import
TEST_DATA_MIXED_MODULESTORE
from
courseware.tests.modulestore_config
import
TEST_DATA_MONGO_MODULESTORE
from
courseware.tests.modulestore_config
import
TEST_DATA_XML_MODULESTORE
from
courseware.tests.test_submitting_problems
import
TestSubmittingProblems
from
student.models
import
anonymous_id_for_user
from
lms.lib.xblock.runtime
import
quote_slashes
...
...
@@ -96,7 +100,6 @@ class ModuleRenderTestCase(ModuleStoreTestCase, LoginEnrollmentTestCase):
# note if the URL mapping changes then this assertion will break
self
.
assertIn
(
'/courses/'
+
self
.
course_id
+
'/jump_to_id/vertical_test'
,
html
)
def
test_xqueue_callback_success
(
self
):
"""
Test for happy-path xqueue_callback
...
...
@@ -874,3 +877,106 @@ class TestModuleTrackingContext(ModuleStoreTestCase):
def
test_missing_display_name
(
self
,
mock_tracker
):
actual_display_name
=
self
.
handle_callback_and_get_display_name_from_event
(
mock_tracker
)
self
.
assertTrue
(
actual_display_name
.
startswith
(
'problem'
))
class
TestXmoduleRuntimeEvent
(
TestSubmittingProblems
):
"""
Inherit from TestSubmittingProblems to get functionality that set up a course and problems structure
"""
def
setUp
(
self
):
super
(
TestXmoduleRuntimeEvent
,
self
)
.
setUp
()
self
.
homework
=
self
.
add_graded_section_to_course
(
'homework'
)
self
.
problem
=
self
.
add_dropdown_to_section
(
self
.
homework
.
location
,
'p1'
,
1
)
self
.
grade_dict
=
{
'value'
:
0.18
,
'max_value'
:
32
,
'user_id'
:
self
.
student_user
.
id
}
self
.
delete_dict
=
{
'value'
:
None
,
'max_value'
:
None
,
'user_id'
:
self
.
student_user
.
id
}
def
get_module_for_user
(
self
,
user
):
"""Helper function to get useful module at self.location in self.course_id for user"""
mock_request
=
MagicMock
()
mock_request
.
user
=
user
field_data_cache
=
FieldDataCache
.
cache_for_descriptor_descendents
(
self
.
course
.
id
,
user
,
self
.
course
,
depth
=
2
)
return
render
.
get_module
(
# pylint: disable=protected-access
user
,
mock_request
,
self
.
problem
.
id
,
field_data_cache
,
self
.
course
.
id
)
.
_xmodule
def
set_module_grade_using_publish
(
self
,
grade_dict
):
"""Publish the user's grade, takes grade_dict as input"""
module
=
self
.
get_module_for_user
(
self
.
student_user
)
module
.
system
.
publish
(
module
,
'grade'
,
grade_dict
)
return
module
def
test_xmodule_runtime_publish
(
self
):
"""Tests the publish mechanism"""
self
.
set_module_grade_using_publish
(
self
.
grade_dict
)
student_module
=
StudentModule
.
objects
.
get
(
student
=
self
.
student_user
,
module_state_key
=
self
.
problem
.
id
)
self
.
assertEqual
(
student_module
.
grade
,
self
.
grade_dict
[
'value'
])
self
.
assertEqual
(
student_module
.
max_grade
,
self
.
grade_dict
[
'max_value'
])
def
test_xmodule_runtime_publish_delete
(
self
):
"""Test deleting the grade using the publish mechanism"""
module
=
self
.
set_module_grade_using_publish
(
self
.
grade_dict
)
module
.
system
.
publish
(
module
,
'grade'
,
self
.
delete_dict
)
student_module
=
StudentModule
.
objects
.
get
(
student
=
self
.
student_user
,
module_state_key
=
self
.
problem
.
id
)
self
.
assertIsNone
(
student_module
.
grade
)
self
.
assertIsNone
(
student_module
.
max_grade
)
class
TestRebindModule
(
TestSubmittingProblems
):
"""
Tests to verify the functionality of rebinding a module.
Inherit from TestSubmittingProblems to get functionality that set up a course structure
"""
def
setUp
(
self
):
super
(
TestRebindModule
,
self
)
.
setUp
()
self
.
homework
=
self
.
add_graded_section_to_course
(
'homework'
)
self
.
lti
=
ItemFactory
.
create
(
category
=
'lti'
,
parent
=
self
.
homework
)
self
.
user
=
UserFactory
.
create
()
self
.
anon_user
=
AnonymousUser
()
def
get_module_for_user
(
self
,
user
):
"""Helper function to get useful module at self.location in self.course_id for user"""
mock_request
=
MagicMock
()
mock_request
.
user
=
user
field_data_cache
=
FieldDataCache
.
cache_for_descriptor_descendents
(
self
.
course
.
id
,
user
,
self
.
course
,
depth
=
2
)
return
render
.
get_module
(
# pylint: disable=protected-access
user
,
mock_request
,
self
.
lti
.
id
,
field_data_cache
,
self
.
course
.
id
)
.
_xmodule
def
test_rebind_noauth_module_to_user_not_anonymous
(
self
):
"""
Tests that an exception is thrown when rebind_noauth_module_to_user is run from a
module bound to a real user
"""
module
=
self
.
get_module_for_user
(
self
.
user
)
user2
=
UserFactory
()
user2
.
id
=
2
with
self
.
assertRaisesRegexp
(
render
.
LmsModuleRenderError
,
"rebind_noauth_module_to_user can only be called from a module bound to an anonymous user"
):
self
.
assertTrue
(
module
.
system
.
rebind_noauth_module_to_user
(
module
,
user2
))
def
test_rebind_noauth_module_to_user_anonymous
(
self
):
"""
Tests that get_user_module_for_noauth succeeds when rebind_noauth_module_to_user is run from a
module bound to AnonymousUser
"""
module
=
self
.
get_module_for_user
(
self
.
anon_user
)
user2
=
UserFactory
()
user2
.
id
=
2
module
.
system
.
rebind_noauth_module_to_user
(
module
,
user2
)
self
.
assertTrue
(
module
)
self
.
assertEqual
(
module
.
system
.
anonymous_student_id
,
anonymous_id_for_user
(
user2
,
self
.
course
.
id
))
self
.
assertEqual
(
module
.
scope_ids
.
user_id
,
user2
.
id
)
self
.
assertEqual
(
module
.
descriptor
.
scope_ids
.
user_id
,
user2
.
id
)
lms/djangoapps/courseware/views.py
View file @
f8c062b6
...
...
@@ -4,6 +4,7 @@ Courseware views functions
import
logging
import
urllib
import
json
from
collections
import
defaultdict
from
django.utils.translation
import
ugettext
as
_
...
...
@@ -12,8 +13,9 @@ from django.conf import settings
from
django.core.context_processors
import
csrf
from
django.core.exceptions
import
PermissionDenied
from
django.core.urlresolvers
import
reverse
from
django.contrib.auth.models
import
User
from
django.contrib.auth.models
import
User
,
AnonymousUser
from
django.contrib.auth.decorators
import
login_required
from
django.views.decorators.http
import
require_GET
from
django.http
import
Http404
,
HttpResponse
from
django.shortcuts
import
redirect
from
edxmako.shortcuts
import
render_to_response
,
render_to_string
...
...
@@ -24,8 +26,7 @@ from markupsafe import escape
from
courseware
import
grades
from
courseware.access
import
has_access
from
courseware.courses
import
get_courses
,
get_course_with_access
,
get_studio_url
,
sort_by_announcement
from
courseware.courses
import
get_courses
,
get_course
,
get_studio_url
,
get_course_with_access
,
sort_by_announcement
from
courseware.masquerade
import
setup_masquerade
from
courseware.model_data
import
FieldDataCache
from
.module_render
import
toc_for_course
,
get_module_for_descriptor
,
get_module
...
...
@@ -98,7 +99,6 @@ def render_accordion(request, course, chapter, section, field_data_cache):
Returns the html string
"""
# grab the table of contents
user
=
User
.
objects
.
prefetch_related
(
"groups"
)
.
get
(
id
=
request
.
user
.
id
)
request
.
user
=
user
# keep just one instance of User
...
...
@@ -828,3 +828,58 @@ def get_static_tab_contents(request, course, tab):
)
return
html
@require_GET
def
get_course_lti_endpoints
(
request
,
course_id
):
"""
View that, given a course_id, returns the a JSON object that enumerates all of the LTI endpoints for that course.
The LTI 2.0 result service spec at
http://www.imsglobal.org/lti/ltiv2p0/uml/purl.imsglobal.org/vocab/lis/v2/outcomes/Result/service.html
says "This specification document does not prescribe a method for discovering the endpoint URLs." This view
function implements one way of discovering these endpoints, returning a JSON array when accessed.
Arguments:
request (django request object): the HTTP request object that triggered this view function
course_id (unicode): id associated with the course
Returns:
(django response object): HTTP response. 404 if course is not found, otherwise 200 with JSON body.
"""
try
:
course
=
get_course
(
course_id
,
depth
=
2
)
except
ValueError
:
# get_course raises ValueError if course_id is invalid or doesn't refer to a course
return
HttpResponse
(
status
=
404
)
anonymous_user
=
AnonymousUser
()
anonymous_user
.
known
=
False
# make these "noauth" requests like module_render.handle_xblock_callback_noauth
lti_descriptors
=
modulestore
()
.
get_items
(
Location
(
"i4x"
,
course
.
org
,
course
.
number
,
"lti"
,
None
),
course
.
id
)
lti_noauth_modules
=
[
get_module_for_descriptor
(
anonymous_user
,
request
,
descriptor
,
FieldDataCache
.
cache_for_descriptor_descendents
(
course_id
,
anonymous_user
,
descriptor
),
course_id
)
for
descriptor
in
lti_descriptors
]
endpoints
=
[
{
'display_name'
:
module
.
display_name
,
'lti_2_0_result_service_json_endpoint'
:
module
.
get_outcome_service_url
(
service_name
=
'lti_2_0_result_rest_handler'
)
+
"/user/{anon_user_id}"
,
'lti_1_1_result_service_xml_endpoint'
:
module
.
get_outcome_service_url
(
service_name
=
'grade_handler'
),
}
for
module
in
lti_noauth_modules
]
return
HttpResponse
(
json
.
dumps
(
endpoints
),
content_type
=
'application/json'
)
lms/envs/aws.py
View file @
f8c062b6
...
...
@@ -134,6 +134,7 @@ EMAIL_HOST = ENV_TOKENS.get('EMAIL_HOST', 'localhost') # django default is loca
EMAIL_PORT
=
ENV_TOKENS
.
get
(
'EMAIL_PORT'
,
25
)
# django default is 25
EMAIL_USE_TLS
=
ENV_TOKENS
.
get
(
'EMAIL_USE_TLS'
,
False
)
# django default is False
SITE_NAME
=
ENV_TOKENS
[
'SITE_NAME'
]
HTTPS
=
ENV_TOKENS
.
get
(
'HTTPS'
,
HTTPS
)
SESSION_ENGINE
=
ENV_TOKENS
.
get
(
'SESSION_ENGINE'
,
SESSION_ENGINE
)
SESSION_COOKIE_DOMAIN
=
ENV_TOKENS
.
get
(
'SESSION_COOKIE_DOMAIN'
)
REGISTRATION_EXTRA_FIELDS
=
ENV_TOKENS
.
get
(
'REGISTRATION_EXTRA_FIELDS'
,
REGISTRATION_EXTRA_FIELDS
)
...
...
lms/envs/dev.py
View file @
f8c062b6
...
...
@@ -18,6 +18,7 @@ from logsettings import get_logger_config
DEBUG
=
True
TEMPLATE_DEBUG
=
True
HTTPS
=
'off'
FEATURES
[
'DISABLE_START_DATES'
]
=
False
FEATURES
[
'ENABLE_SQL_TRACKING_LOGS'
]
=
True
FEATURES
[
'SUBDOMAIN_COURSE_LISTINGS'
]
=
False
# Enable to test subdomains--otherwise, want all courses to show up
...
...
lms/lib/xblock/runtime.py
View file @
f8c062b6
...
...
@@ -5,7 +5,7 @@ Module implementing `xblock.runtime.Runtime` functionality for the LMS
import
re
from
django.core.urlresolvers
import
reverse
from
django.conf
import
settings
from
user_api
import
user_service
from
xmodule.modulestore.django
import
modulestore
from
xmodule.x_module
import
ModuleSystem
...
...
@@ -100,6 +100,15 @@ class LmsHandlerUrls(object):
if
query
:
url
+=
'?'
+
query
# If third-party, return fully-qualified url
if
thirdparty
:
scheme
=
"https"
if
settings
.
HTTPS
==
"on"
else
"http"
url
=
'{scheme}://{host}{path}'
.
format
(
scheme
=
scheme
,
host
=
settings
.
SITE_NAME
,
path
=
url
)
return
url
def
local_resource_url
(
self
,
block
,
uri
):
...
...
lms/lib/xblock/test/test_runtime.py
View file @
f8c062b6
...
...
@@ -3,6 +3,7 @@ Tests of the LMS XBlock Runtime and associated utilities
"""
from
django.contrib.auth.models
import
User
from
django.conf
import
settings
from
ddt
import
ddt
,
data
from
mock
import
Mock
from
unittest
import
TestCase
...
...
@@ -87,6 +88,18 @@ class TestHandlerUrl(TestCase):
self
.
assertIn
(
'handler1'
,
self
.
_parsed_path
(
'handler1'
))
self
.
assertIn
(
'handler_a'
,
self
.
_parsed_path
(
'handler_a'
))
def
test_thirdparty_fq
(
self
):
"""Testing the Fully-Qualified URL returned by thirdparty=True"""
parsed_fq_url
=
urlparse
(
self
.
runtime
.
handler_url
(
self
.
block
,
'handler'
,
thirdparty
=
True
))
self
.
assertEqual
(
parsed_fq_url
.
scheme
,
'https'
)
self
.
assertEqual
(
parsed_fq_url
.
hostname
,
settings
.
SITE_NAME
)
def
test_not_thirdparty_rel
(
self
):
"""Testing the Fully-Qualified URL returned by thirdparty=False"""
parsed_fq_url
=
urlparse
(
self
.
runtime
.
handler_url
(
self
.
block
,
'handler'
,
thirdparty
=
False
))
self
.
assertEqual
(
parsed_fq_url
.
scheme
,
''
)
self
.
assertIsNone
(
parsed_fq_url
.
hostname
)
class
TestUserServiceAPI
(
TestCase
):
"""Test the user service interface"""
...
...
lms/templates/lti.html
View file @
f8c062b6
<
%!
import
json
%
>
<
%!
from
django
.
utils
.
translation
import
ugettext
as
_
%
>
<h2
class=
"problem-header"
>
## Translators: "External resource" means that this learning module is hosted on a platform external to the edX LMS
${display_name} (${_('External resource')})
</h2>
% if has_score and weight:
<div
class=
"problem-progress"
>
% if module_score is not None:
## Translators: "points" is the student's achieved score on this LTI unit, and "total_points" is the maximum number of points achievable.
(${_("{points} / {total_points} points").format(points=module_score, total_points=weight)})
% else:
## Translators: "total_points" is the maximum number of points achievable on this LTI unit
(${_("{total_points} points possible").format(total_points=weight)})
% endif
</div>
% endif
<div
id=
"${element_id}"
class=
"${element_class}"
>
% if launch_url and launch_url != 'http://www.example.com':
% if launch_url and launch_url != 'http://www.example.com'
and not hide_launch
:
% if open_in_a_new_page:
<div
class=
"wrapper-lti-link"
>
<h3
class=
"title"
>
${display_name} (${_('External resource')})
</h3>
<p
class=
"lti-link external"
><a
target=
"_blank"
class=
"link_lti_new_window"
href=
"${form_url}"
>
${_('View resource in a new window')}
<i
class=
"icon-external-link"
></i>
...
...
@@ -25,9 +39,18 @@
src=
"${form_url}"
></iframe>
% endif
% el
se
:
% el
if not hide_launch
:
<h3
class=
"error_message"
>
${_('Please provide launch_url. Click "Edit", and fill in the required fields.')}
</h3>
%endif
% if has_score and comment:
<h4
class=
"problem-feedback-label"
>
${_("Feedback on your work from the grader:")}
</h4>
<div
class=
"problem-feedback"
>
## sanitized with bleach in view
${comment}
</div>
% endif
</div>
lms/urls.py
View file @
f8c062b6
...
...
@@ -326,6 +326,9 @@ if settings.COURSEWARE_ENABLED:
url
(
r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/notes$'
,
'notes.views.notes'
,
name
=
'notes'
),
url
(
r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/notes/'
,
include
(
'notes.urls'
)),
# LTI endpoints listing
url
(
r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/lti_rest_endpoints/'
,
'courseware.views.get_course_lti_endpoints'
,
name
=
'lti_rest_endpoints'
),
)
# allow course staff to change to student view of courseware
...
...
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