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;
}
}
......@@ -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