Commit 00d52e39 by Jason Bau

Merge pull request #2685 from edx/jbau/lti-20-outcomes-rest

LTI 2.0 results service endpoint 
parents 85228476 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):
"""
......
......@@ -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())
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;
}
}
# 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', "")
......@@ -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')
# -*- 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 &lt;script&gt;alert(3)&lt;/script&gt;"), # 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%3D", '
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)
......@@ -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):
......
......@@ -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)."""
......
......@@ -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)"
......@@ -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]))
......@@ -289,7 +289,6 @@ class DjangoKeyValueStore(KeyValueStore):
Scope.user_info,
)
def __init__(self, field_data_cache):
self._field_data_cache = field_data_cache
......
......@@ -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
......
"""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)
......@@ -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)
......@@ -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')
......@@ -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)
......
......@@ -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
......
......@@ -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):
......
......@@ -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"""
......
<%! 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
% else:
% elif 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>
......@@ -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
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment