Commit 11bbf4c1 by Calen Pennington

Add grading functionality to LTI xmodule

Co-author: Alexander Kryklia <kryklia@edx.org>
Co-author: Ned Batchelder <ned@edx.org>
Co-author: Oleg Marchev <oleg@edx.org>
Co-author: Valera Rozuvan <valera@edx.org>
Co-author: polesye
[BLD-384]
parent 52ab2b13
......@@ -9,6 +9,10 @@ LMS: Add feature for providing background grade report generation via Celery
instructor task, with reports uploaded to S3. Feature is visible on the beta
instructor dashboard. LMS-58
Blades: Added grading support for LTI module. LTI providers can now grade
student's work and send edX scores. OAuth1 based authentication
implemented. BLD-384.
LMS: Beta-tester status is now set on a per-course-run basis, rather than being valid
across all runs with the same course name. Old group membership will still work
across runs, but new beta-testers will only be added to a single course run.
......
......@@ -371,7 +371,6 @@ class XModule(XModuleMixin, HTMLSnippet, XBlock): # pylint: disable=abstract-me
See the HTML module for a simple example.
"""
has_score = descriptor_attr('has_score')
_field_data_cache = descriptor_attr('_field_data_cache')
_field_data = descriptor_attr('_field_data')
......@@ -968,7 +967,7 @@ class ModuleSystem(ConfigurableFragmentWrapper, Runtime): # pylint: disable=abs
anonymous_student_id='', course_id=None,
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, **kwargs):
replace_jump_to_id_urls=None, error_descriptor_class=None, get_real_user=None, **kwargs):
"""
Create a closure around the system environment.
......@@ -1053,6 +1052,8 @@ class ModuleSystem(ConfigurableFragmentWrapper, Runtime): # pylint: disable=abs
self.error_descriptor_class = error_descriptor_class
self.xmodule_instance = None
self.get_real_user = get_real_user
def get(self, attr):
""" provide uniform access to attributes (like etree)."""
return self.__dict__.get(attr)
......
**********************
Create a LTI Component
**********************
Description
===========
The LTI XModule is based on the `IMS Global Learning Tools Interoperability <http://www.imsglobal.org/LTI/v1p1p1/ltiIMGv1p1p1.html>`_ Version 1.1.1 specifications.
Enabling LTI
============
It is not available from the list of general components. To turn it on, add
"lti" to the "advanced_modules" key on the Advanced Settings page.
The module supports 2 modes of operation.
1. Simple display of external LTI content
2. Display of LTI content that will be graded by external provider
In both cases, before an LTI component from an external provider can be
included in a unit, the following pieces of information must be known/decided
upon:
**LTI id** [string]
Internal string representing the external LTI provider. Can contain multi-
case alphanumeric characters, and underscore.
**Client key** [string]
Used for OAuth authentication. Issued by external LTI provider.
**Client secret** [string]
Used for OAuth authentication. Issued by external LTI provider.
LTI id is necessary to differentiate between multiple available external LTI
providers that are added to an edX course.
The three fields above must be entered in "lti_passports" field in the format::
[
"{lti_id}:{client_key}:{client_secret}"
]
Multiple external LTI providers are separated by commas::
[
"{lti_id_1}:{client_key_1}:{client_secret_1}",
"{lti_id_2}:{client_key_2}:{client_secret_2}",
"{lti_id_3}:{client_key_3}:{client_secret_3}"
]
Adding LTI to a unit
====================
After LTI has been enabled, and an external provider has been registered, an
instance of it can be added to a unit.
LTI will be available from the Advanced Component category. After adding an LTI
component to a unit, it can be configured by Editing it's settings (the Edit
dialog). The following settings are available:
**Display Name** [string]
Title of the new LTI component instance
**custom_parameters** [string]
With the "+ Add" button, multiple custom parameters can be
added. Basically, each individual external LTI provider can have a separate
format custom parameters. For example::
key=value
**graded** [boolean]
Whether or not the grade for this particular LTI instance problem will be
counted towards student's total grade.
**launch_url** [string]
If `rgaded` above is set to `true`, then this must be
the URL that will be passed to the external LTI provider for it to respond with
a grade.
**lti_id** [string]
Internal string representing the external LTI provider that
will be used to display content. The same as was entered on the Advanced
Settings page.
**open_in_a_new_page** [boolean]
If set to `true`, a link will be present for the student
to click. When the link is clicked, a new window will open with the external
LTI content. If set to `false`, the external LTI content will be loaded in the
page in an iframe.
**weight** [float]
If the problem will be graded by an external LTI provider,
the raw grade will be in the range [0.0, 1.0]. In order to change this range,
set the `weight`. The grade that will be stored is calculated by the formula::
stored_grade = raw_grade * weight
......@@ -20,6 +20,7 @@ Contents
create_video
create_discussion
create_html_component
create_lti
create_problem
set_content_releasedates
establish_course_settings
......@@ -34,13 +35,13 @@ Contents
checking_student_progress
change_log
Appendices
Appendices
==========
.. toctree::
......
......@@ -25,6 +25,7 @@ Specific Problem Types
course_data_formats/drag_and_drop/drag_and_drop_input.rst
course_data_formats/graphical_slider_tool/graphical_slider_tool.rst
course_data_formats/poll_module/poll_module.rst
course_data_formats/lti_module/lti.rst
course_data_formats/conditional_module/conditional_module.rst
course_data_formats/word_cloud/word_cloud.rst
course_data_formats/custom_response.rst
......
......@@ -2,27 +2,57 @@
Feature: LMS.LTI component
As a student, I want to view LTI component in LMS.
#1
Scenario: LTI component in LMS with no launch_url is not rendered
Given the course has correct LTI credentials
And the course has an LTI component with no_launch_url fields, new_page is false
And the course has an LTI component with no_launch_url fields:
| open_in_a_new_page |
| False |
Then I view the LTI and error is shown
#2
Scenario: LTI component in LMS with incorrect lti_id is rendered incorrectly
Given the course has correct LTI credentials
And the course has an LTI component with incorrect_lti_id fields, new_page is false
And the course has an LTI component with incorrect_lti_id fields:
| open_in_a_new_page |
| False |
Then I view the LTI but incorrect_signature warning is rendered
#3
Scenario: LTI component in LMS is rendered incorrectly
Given the course has incorrect LTI credentials
And the course has an LTI component with correct fields, new_page is false
And the course has an LTI component with correct fields:
| open_in_a_new_page |
| False |
Then I view the LTI but incorrect_signature warning is rendered
#4
Scenario: LTI component in LMS is correctly rendered in new page
Given the course has correct LTI credentials
And the course has an LTI component with correct fields, new_page is true
And the course has an LTI component with correct fields
Then I view the LTI and it is rendered in new page
#5
Scenario: LTI component in LMS is correctly rendered in iframe
Given the course has correct LTI credentials
And the course has an LTI component with correct fields, new_page is false
And the course has an LTI component with correct fields:
| open_in_a_new_page |
| False |
Then I view the LTI and it is rendered in iframe
#6
Scenario: Graded LTI component in LMS is correctly works
Given the course has correct LTI credentials
And the course has an LTI component with correct fields:
| open_in_a_new_page | weight | is_graded |
| False | 10 | True |
And I submit answer to LTI question
And I click on the "Progress" tab
Then I see text "Problem Scores: 5/10"
And I see graph with total progress "5%"
Then I click on the "Instructor" tab
And I click on the "Gradebook" tab
And I see in the gradebook table that "HW" is "50"
And I see in the gradebook table that "Total" is "5"
#pylint: disable=C0111
import os
from django.contrib.auth.models import User
from lettuce import world, step
from lettuce.django import django_url
......@@ -86,36 +87,41 @@ def set_incorrect_lti_passport(_step):
}
i_am_registered_for_the_course(coursenum, metadata)
@step('the course has an LTI component with (.*) fields, new_page is(.*)$')
def add_correct_lti_to_course(_step, fields, new_page):
@step('the course has an LTI component with (.*) fields(?:\:)?$') #, new_page is(.*), is_graded is(.*)
def add_correct_lti_to_course(_step, fields):
category = 'lti'
lti_id = 'correct_lti_id'
launch_url = world.lti_server.oauth_settings['lti_base'] + world.lti_server.oauth_settings['lti_endpoint']
metadata = {
'lti_id': 'correct_lti_id',
'launch_url': world.lti_server.oauth_settings['lti_base'] + world.lti_server.oauth_settings['lti_endpoint'],
}
if fields.strip() == 'incorrect_lti_id': # incorrect fields
lti_id = 'incorrect_lti_id'
metadata.update({
'lti_id': 'incorrect_lti_id'
})
elif fields.strip() == 'correct': # correct fields
pass
elif fields.strip() == 'no_launch_url':
launch_url = u''
metadata.update({
'launch_url': u''
})
else: # incorrect parameter
assert False
if new_page.strip().lower() == 'false':
new_page = False
else: # default is True
new_page = True
if _step.hashes:
metadata.update(_step.hashes[0])
world.scenario_dict['LTI'] = world.ItemFactory.create(
parent_location=world.scenario_dict['SEQUENTIAL'].location,
category=category,
display_name='LTI',
metadata={
'lti_id': lti_id,
'launch_url': launch_url,
'open_in_a_new_page': new_page
}
metadata=metadata,
)
setattr(world.scenario_dict['LTI'], 'TEST_BASE_PATH', '{host}:{port}'.format(
host=world.browser.host,
port=world.browser.port,
))
course = world.scenario_dict["COURSE"]
chapter_name = world.scenario_dict['SECTION'].display_name.replace(
" ", "_")
......@@ -138,6 +144,20 @@ def create_course(course, metadata):
# This also ensures that the necessary templates are loaded
world.clear_courses()
weight = 0.1
grading_policy = {
"GRADER": [
{
"type": "Homework",
"min_count": 1,
"drop_count": 0,
"short_label": "HW",
"weight": weight
},
]
}
metadata.update(grading_policy)
# Create the course
# We always use the same org and display name,
# but vary the course identifier (e.g. 600x or 191x)
......@@ -145,18 +165,30 @@ def create_course(course, metadata):
org='edx',
number=course,
display_name='Test Course',
metadata=metadata
metadata=metadata,
grading_policy={
"GRADER": [
{
"type": "Homework",
"min_count": 1,
"drop_count": 0,
"short_label": "HW",
"weight": weight
},
]
},
)
# Add a section to the course to contain problems
world.scenario_dict['SECTION'] = world.ItemFactory.create(
parent_location=world.scenario_dict['COURSE'].location,
display_name='Test Section'
display_name='Test Section',
)
world.scenario_dict['SEQUENTIAL'] = world.ItemFactory.create(
parent_location=world.scenario_dict['SECTION'].location,
category='sequential',
display_name='Test Section')
display_name='Test Section',
metadata={'graded': True, 'format': 'Homework'})
def i_am_registered_for_the_course(course, metadata):
......@@ -170,6 +202,7 @@ def i_am_registered_for_the_course(course, metadata):
# If the user is not already enrolled, enroll the user.
CourseEnrollment.enroll(usr, course_id(course))
world.add_to_course_staff('robot', world.scenario_dict['COURSE'].number)
world.log_in(username='robot', password='test')
......@@ -196,3 +229,41 @@ def check_lti_popup():
world.browser.switch_to_window(parent_window) # Switch to the main window again
@step('I see text "([^"]*)"$')
def check_progress(_step, text):
assert world.browser.is_text_present(text)
@step('I see graph with total progress "([^"]*)"$')
def see_graph(_step, progress):
SELECTOR = 'grade-detail-graph'
node = world.browser.find_by_xpath('//div[@id="{parent}"]//div[text()="{progress}"]'.format(
parent=SELECTOR,
progress=progress,
))
assert node
@step('I see in the gradebook table that "([^"]*)" is "([^"]*)"$')
def see_value_in_the_gradebook(_step, label, text):
TABLE_SELECTOR = '.grade-table'
index = 0
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)
@step('I submit answer to LTI question$')
def click_grade(_step):
location = world.scenario_dict['LTI'].location.html_id()
iframe_name = 'ltiLaunchFrame-' + 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')
......@@ -30,6 +30,7 @@ def setup_mock_lti_server():
server_thread.daemon = True
server_thread.start()
server.server_host = server_host
server.oauth_settings = {
'client_key': 'test_client_key',
'client_secret': 'test_client_secret',
......
from BaseHTTPServer import HTTPServer, BaseHTTPRequestHandler
from uuid import uuid4
import textwrap
import urlparse
from oauthlib.oauth1.rfc5849 import signature
import oauthlib.oauth1
import hashlib
import base64
import mock
import sys
import requests
import textwrap
from logging import getLogger
logger = getLogger(__name__)
......@@ -13,6 +21,7 @@ class MockLTIRequestHandler(BaseHTTPRequestHandler):
'''
protocol = "HTTP/1.0"
callback_url = None
def log_message(self, format, *args):
"""Log an arbitrary message."""
......@@ -23,24 +32,42 @@ class MockLTIRequestHandler(BaseHTTPRequestHandler):
self.log_date_time_string(),
format % args))
def do_HEAD(self):
self._send_head()
def do_GET(self):
'''
Handle a GET request from the client and sends response back.
'''
self.send_response(200, 'OK')
self.send_header('Content-type', 'html')
self.end_headers()
response_str = """<html><head><title>TEST TITLE</title></head>
<body>I have stored grades.</body></html>"""
self.wfile.write(response_str)
self._send_graded_result()
def do_POST(self):
'''
Handle a POST request from the client and sends response back.
'''
self._send_head()
post_dict = self._post_dict() # Retrieve the POST data
'''
logger.debug("LTI provider received POST request {} to path {}".format(
str(post_dict),
str(self.post_dict),
self.path)
) # Log the request
# Respond only to requests with correct lti endpoint:
if self._is_correct_lti_request():
'''
# Respond to grade request
if 'grade' in self.path and self._send_graded_result().status_code == 200:
status_message = 'LTI consumer (edX) responded with XML content:<br>' + self.server.grade_data['TC answer']
self.server.grade_data['callback_url'] = None
# Respond to request with correct lti endpoint:
elif self._is_correct_lti_request():
self.post_dict = self._post_dict()
correct_keys = [
'user_id',
'role',
......@@ -55,31 +82,41 @@ class MockLTIRequestHandler(BaseHTTPRequestHandler):
'oauth_callback',
'lis_outcome_service_url',
'lis_result_sourcedid',
'launch_presentation_return_url'
'launch_presentation_return_url',
# 'lis_person_sourcedid', optional, not used now.
'resource_link_id',
]
if sorted(correct_keys) != sorted(post_dict.keys()):
if sorted(correct_keys) != sorted(self.post_dict.keys()):
status_message = "Incorrect LTI header"
else:
params = {k: v for k, v in post_dict.items() if k != 'oauth_signature'}
if self.server.check_oauth_signature(params, post_dict['oauth_signature']):
params = {k: v for k, v in self.post_dict.items() if k != 'oauth_signature'}
if self.server.check_oauth_signature(params, self.post_dict['oauth_signature']):
status_message = "This is LTI tool. Success."
else:
status_message = "Wrong LTI signature"
# set data for grades
# what need to be stored as server data
self.server.grade_data = {
'callback_url': self.post_dict["lis_outcome_service_url"],
'sourcedId': self.post_dict['lis_result_sourcedid']
}
else:
status_message = "Invalid request URL"
self._send_head()
self._send_response(status_message)
def _send_head(self):
'''
Send the response code and MIME headers
'''
self.send_response(200)
'''
if self._is_correct_lti_request():
self.send_response(200)
else:
self.send_response(500)
'''
self.send_header('Content-type', 'text/html')
self.end_headers()
......@@ -100,18 +137,91 @@ class MockLTIRequestHandler(BaseHTTPRequestHandler):
# the correct fields, it won't find them,
# and will therefore send an error response
return {}
try:
cookie = self.headers.getheader('cookie')
self.server.cookie = {k.strip(): v[0] for k, v in urlparse.parse_qs(cookie).items()}
except:
self.server.cookie = {}
referer = urlparse.urlparse(self.headers.getheader('referer'))
self.server.referer_host = "{}://{}".format(referer.scheme, referer.netloc)
self.server.referer_netloc = referer.netloc
return post_dict
def _send_graded_result(self):
values = {
'textString': 0.5,
'sourcedId': self.server.grade_data['sourcedId'],
'imsx_messageIdentifier': uuid4().hex,
}
payload = textwrap.dedent("""
<?xml version = "1.0" encoding = "UTF-8"?>
<imsx_POXEnvelopeRequest xmlns="http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0">
<imsx_POXHeader>
<imsx_POXRequestHeaderInfo>
<imsx_version>V1.0</imsx_version>
<imsx_messageIdentifier>{imsx_messageIdentifier}</imsx_messageIdentifier> /
</imsx_POXRequestHeaderInfo>
</imsx_POXHeader>
<imsx_POXBody>
<replaceResultRequest>
<resultRecord>
<sourcedGUID>
<sourcedId>{sourcedId}</sourcedId>
</sourcedGUID>
<result>
<resultScore>
<language>en-us</language>
<textString>{textString}</textString>
</resultScore>
</result>
</resultRecord>
</replaceResultRequest>
</imsx_POXBody>
</imsx_POXEnvelopeRequest>
""")
data = payload.format(**values)
# temporarily changed to get for easy view in browser
# get relative part, because host name is different in a) manual tests b) acceptance tests c) demos
relative_url = urlparse.urlparse(self.server.grade_data['callback_url']).path
url = self.server.referer_host + relative_url
headers = {'Content-Type': 'application/xml', 'X-Requested-With': 'XMLHttpRequest'}
headers['Authorization'] = self.oauth_sign(url, data)
response = requests.post(
url,
data=data,
headers=headers
)
self.server.grade_data['TC answer'] = response.content
return response
def _send_response(self, message):
'''
Send message back to the client
'''
response_str = """<html><head><title>TEST TITLE</title></head>
<body>
<div><h2>IFrame loaded</h2> \
<h3>Server response is:</h3>\
<h3 class="result">{}</h3></div>
</body></html>""".format(message)
if self.server.grade_data['callback_url']:
response_str = """<html><head><title>TEST TITLE</title></head>
<body>
<div><h2>Graded IFrame loaded</h2> \
<h3>Server response is:</h3>\
<h3 class="result">{}</h3></div>
<form action="{url}/grade" method="post">
<input type="submit" name="submit-button" value="Submit">
</form>
</body></html>""".format(message, url="http://%s:%s" % self.server.server_address)
else:
response_str = """<html><head><title>TEST TITLE</title></head>
<body>
<div><h2>IFrame loaded</h2> \
<h3>Server response is:</h3>\
<h3 class="result">{}</h3></div>
</body></html>""".format(message)
# Log the response
logger.debug("LTI: sent response {}".format(response_str))
......@@ -122,6 +232,34 @@ class MockLTIRequestHandler(BaseHTTPRequestHandler):
'''If url to LTI tool is correct.'''
return self.server.oauth_settings['lti_endpoint'] in self.path
def oauth_sign(self, url, body):
"""
Signs request and returns signed body and headers.
"""
client = oauthlib.oauth1.Client(
client_key=unicode(self.server.oauth_settings['client_key']),
client_secret=unicode(self.server.oauth_settings['client_secret'])
)
headers = {
# This is needed for body encoding:
'Content-Type': 'application/x-www-form-urlencoded',
}
#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.hexdigest())
__, headers, __ = client.sign(
unicode(url.strip()),
http_method=u'POST',
body={u'oauth_body_hash': oauth_body_hash},
headers=headers
)
headers = headers['Authorization'] + ', oauth_body_hash="{}"'.format(oauth_body_hash)
return headers
class MockLTIServer(HTTPServer):
'''
......@@ -172,6 +310,5 @@ class MockLTIServer(HTTPServer):
request.uri = unicode(url)
request.http_method = u'POST'
request.signature = unicode(client_signature)
return signature.verify_hmac_sha1(request, client_secret)
"""
Mock LTI server for manual testing.
"""
import threading
from mock_lti_server import MockLTIServer
server_port = 8034
server_host = 'localhost'
address = (server_host, server_port)
server = MockLTIServer(address)
server.oauth_settings = {
'client_key': 'test_client_key',
'client_secret': 'test_client_secret',
'lti_base': 'http://{}:{}/'.format(server_host, server_port),
'lti_endpoint': 'correct_lti_endpoint'
}
server.server_host = server_host
try:
server.serve_forever()
except KeyboardInterrupt:
print('^C received, shutting down server')
server.socket.close()
"""
Test for Mock_LTI_Server
"""
import mock
from mock import Mock
import unittest
import threading
import textwrap
import urllib
import requests
from mock_lti_server import MockLTIServer
from nose.plugins.skip import SkipTest
class MockLTIServerTest(unittest.TestCase):
......@@ -19,14 +22,9 @@ class MockLTIServerTest(unittest.TestCase):
def setUp(self):
# This is a test of the test setup,
# so it does not need to run as part of the unit test suite
# You can re-enable it by commenting out the line below
# raise SkipTest
# Create the server
server_port = 8034
server_host = '127.0.0.1'
server_host = 'localhost'
address = (server_host, server_port)
self.server = MockLTIServer(address)
self.server.oauth_settings = {
......@@ -45,12 +43,42 @@ class MockLTIServerTest(unittest.TestCase):
# Stop the server, freeing up the port
self.server.shutdown()
def test_request(self):
def test_wrong_signature(self):
"""
Tests that LTI server processes request with right program
path, and responses with incorrect signature.
path and responses with incorrect signature.
"""
request = {
payload = {
'user_id': 'default_user_id',
'role': 'student',
'oauth_nonce': '',
'oauth_timestamp': '',
'oauth_consumer_key': 'client_key',
'lti_version': 'LTI-1p0',
'oauth_signature_method': 'HMAC-SHA1',
'oauth_version': '1.0',
'oauth_signature': '',
'lti_message_type': 'basic-lti-launch-request',
'oauth_callback': 'about:blank',
'launch_presentation_return_url': '',
'lis_outcome_service_url': '',
'lis_result_sourcedid': '',
'resource_link_id':'',
}
uri = self.server.oauth_settings['lti_base'] + self.server.oauth_settings['lti_endpoint']
headers = {'referer': 'http://localhost:8000/'}
response = requests.post(uri, data=payload, headers=headers)
self.assertTrue('Wrong LTI signature' in response.content)
def test_success_response_launch_lti(self):
"""
Success lti launch.
"""
payload = {
'user_id': 'default_user_id',
'role': 'student',
'oauth_nonce': '',
......@@ -64,12 +92,16 @@ class MockLTIServerTest(unittest.TestCase):
'oauth_callback': 'about:blank',
'launch_presentation_return_url': '',
'lis_outcome_service_url': '',
'lis_result_sourcedid': ''
'lis_result_sourcedid': '',
'resource_link_id':'',
"lis_outcome_service_url": '',
}
self.server.check_oauth_signature = Mock(return_value=True)
uri = self.server.oauth_settings['lti_base'] + self.server.oauth_settings['lti_endpoint']
headers = {'referer': 'http://localhost:8000/'}
response = requests.post(uri, data=payload, headers=headers)
self.assertTrue('This is LTI tool. Success.' in response.content)
response_handle = urllib.urlopen(
self.server.oauth_settings['lti_base'] + self.server.oauth_settings['lti_endpoint'],
urllib.urlencode(request)
)
response = response_handle.read()
self.assertTrue('Wrong LTI signature' in response)
......@@ -24,7 +24,7 @@ from lms.lib.xblock.field_data import LmsFieldData
from lms.lib.xblock.runtime import LmsModuleSystem, handler_prefix, unquote_slashes
from mitxmako.shortcuts import render_to_string
from psychometrics.psychoanalyze import make_psychometrics_data_update_handler
from student.models import unique_id_for_user
from student.models import anonymous_id_for_user, user_by_anonymous_id
from util.json_request import JsonResponse
from util.sandboxing import can_execute_unsafe_code
from xblock.fields import Scope
......@@ -37,6 +37,7 @@ from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule_modifiers import replace_course_urls, replace_jump_to_id_urls, replace_static_urls, add_histogram, wrap_xblock
from xmodule.lti_module import LTIModule
log = logging.getLogger(__name__)
......@@ -285,15 +286,20 @@ def get_module_for_descriptor_internal(user, descriptor, field_data_cache, cours
position, wrap_xmodule_display, grade_bucket_type,
static_asset_path)
def publish(event):
def publish(event, custom_user=None):
"""A function that allows XModules to publish events. This only supports grade changes right now."""
if event.get('event_name') != 'grade':
return
if custom_user:
user_id = custom_user.id
else:
user_id = user.id
# Construct the key for the module
key = KeyValueStore.Key(
scope=Scope.user_state,
user_id=user.id,
user_id=user_id,
block_scope_id=descriptor.location,
field_name='grade'
)
......@@ -361,6 +367,17 @@ def get_module_for_descriptor_internal(user, descriptor, field_data_cache, cours
if has_access(user, descriptor, 'staff', course_id):
block_wrappers.append(partial(add_histogram, user))
# These modules store data using the anonymous_student_id as a key.
# To prevent loss of data, we will continue to provide old modules with
# the per-student anonymized id (as we have in the past),
# while giving selected modules a per-course anonymized id.
# As we have the time to manually test more modules, we can add to the list
# of modules that get the per-course anonymized id.
if issubclass(getattr(descriptor, 'module_class', None), LTIModule):
anonymous_student_id = anonymous_id_for_user(user, course_id)
else:
anonymous_student_id = anonymous_id_for_user(user, '')
system = LmsModuleSystem(
track_function=track_function,
render_template=render_to_string,
......@@ -392,7 +409,7 @@ def get_module_for_descriptor_internal(user, descriptor, field_data_cache, cours
),
node_path=settings.NODE_PATH,
publish=publish,
anonymous_student_id=unique_id_for_user(user),
anonymous_student_id=anonymous_student_id,
course_id=course_id,
open_ended_grading_interface=open_ended_grading_interface,
s3_interface=s3_interface,
......@@ -401,6 +418,7 @@ def get_module_for_descriptor_internal(user, descriptor, field_data_cache, cours
# TODO: When we merge the descriptor and module systems, we can stop reaching into the mixologist (cpennington)
mixins=descriptor.runtime.mixologist._mixins, # pylint: disable=protected-access
wrappers=block_wrappers,
get_real_user=user_by_anonymous_id,
)
# pass position specified in URL to module through ModuleSystem
......
......@@ -4,6 +4,9 @@ import oauthlib
from . import BaseTestXmodule
from collections import OrderedDict
import mock
import urllib
from xmodule.lti_module import LTIModule
from mock import Mock
class TestLTI(BaseTestXmodule):
......@@ -26,21 +29,33 @@ class TestLTI(BaseTestXmodule):
mocked_signature_after_sign = u'my_signature%3D'
mocked_decoded_signature = u'my_signature='
lti_id = self.item_module.lti_id
module_id = unicode(urllib.quote(self.item_module.id))
user_id = unicode(self.item_descriptor.xmodule_runtime.anonymous_student_id)
sourcedId = u':'.join(urllib.quote(i) for i in (lti_id, module_id, user_id))
lis_outcome_service_url = 'http://{host}{path}'.format(
host=self.item_descriptor.xmodule_runtime.hostname,
path=self.item_descriptor.xmodule_runtime.handler_url(self.item_module, 'grade_handler', thirdparty=True).rstrip('/?')
)
self.correct_headers = {
u'user_id': user_id,
u'oauth_callback': u'about:blank',
u'lis_outcome_service_url': '',
u'lis_result_sourcedid': '',
u'launch_presentation_return_url': '',
u'lti_message_type': u'basic-lti-launch-request',
u'lti_version': 'LTI-1p0',
u'role': u'student',
u'resource_link_id': module_id,
u'lis_outcome_service_url': lis_outcome_service_url,
u'lis_result_sourcedid': sourcedId,
u'oauth_nonce': mocked_nonce,
u'oauth_timestamp': mocked_timestamp,
u'oauth_consumer_key': u'',
u'oauth_signature_method': u'HMAC-SHA1',
u'oauth_version': u'1.0',
u'user_id': self.item_descriptor.xmodule_runtime.anonymous_student_id,
u'role': u'student',
u'oauth_signature': mocked_decoded_signature
}
......@@ -70,14 +85,16 @@ class TestLTI(BaseTestXmodule):
Makes sure that all parameters extracted.
"""
generated_context = self.item_module.render('student_view').content
expected_context = {
'input_fields': self.correct_headers,
'display_name': self.item_module.display_name,
'element_class': self.item_module.location.category,
'input_fields': self.correct_headers,
'element_class': self.item_module.category,
'element_id': self.item_module.location.html_id(),
'launch_url': 'http://www.example.com', # default value
'open_in_a_new_page': True,
}
self.assertEqual(
generated_context,
self.runtime.render_template('lti.html', expected_context),
......
......@@ -15,10 +15,11 @@ from django.test.utils import override_settings
from xblock.field_data import FieldData
from xblock.runtime import Runtime
from xblock.fields import ScopeIds
from xmodule.modulestore.django import modulestore
from xmodule.lti_module import LTIDescriptor
from xmodule.modulestore import Location
from xmodule.modulestore.tests.factories import ItemFactory, CourseFactory
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import ItemFactory, CourseFactory
from xmodule.x_module import XModuleDescriptor
import courseware.module_render as render
from courseware.tests.tests import LoginEnrollmentTestCase
......@@ -521,7 +522,7 @@ class TestHtmlModifiers(ModuleStoreTestCase):
result_fragment.content
)
PER_COURSE_ANONYMIZED_DESCRIPTORS = ()
PER_COURSE_ANONYMIZED_DESCRIPTORS = (LTIDescriptor, )
PER_STUDENT_ANONYMIZED_DESCRIPTORS = [
class_ for (name, class_) in XModuleDescriptor.load_classes()
......
......@@ -32,7 +32,7 @@ task :showdocs, [:options] do |t, args|
elsif args.options == 'data'
path = "docs/data"
else
path = "docs"
path = "docs/developers"
end
Launchy.open("#{path}/build/html/index.html")
......
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