From 4edd30226f8a2d1126e2574163508667041cbfd0 Mon Sep 17 00:00:00 2001 From: Muhammad Tayayb Tahir Qureshi Date: Mon, 21 Oct 2024 16:49:23 +0500 Subject: [PATCH] feat: Extracting LTI xblock from edx-platform --- xblocks_contrib/lti/__init__.py | 2 +- xblocks_contrib/lti/lti.py | 970 +++++++++++++++++- xblocks_contrib/lti/lti_2_util.py | 370 +++++++ xblocks_contrib/lti/static/README.txt | 18 + xblocks_contrib/lti/static/css/lti.css | 56 +- xblocks_contrib/lti/static/js/src/lti.js | 64 +- xblocks_contrib/lti/templates/lti.html | 71 +- xblocks_contrib/lti/templates/lti_form.html | 38 + xblocks_contrib/lti/tests/test_lti20_unit.py | 387 +++++++ .../tests/{test_lti.py => test_lti_unit.py} | 0 10 files changed, 1888 insertions(+), 88 deletions(-) create mode 100644 xblocks_contrib/lti/lti_2_util.py create mode 100644 xblocks_contrib/lti/static/README.txt create mode 100644 xblocks_contrib/lti/templates/lti_form.html create mode 100644 xblocks_contrib/lti/tests/test_lti20_unit.py rename xblocks_contrib/lti/tests/{test_lti.py => test_lti_unit.py} (100%) diff --git a/xblocks_contrib/lti/__init__.py b/xblocks_contrib/lti/__init__.py index 094c184..030a9bb 100644 --- a/xblocks_contrib/lti/__init__.py +++ b/xblocks_contrib/lti/__init__.py @@ -1,5 +1,5 @@ """ -Init for the LTIBlock. +Learning Tools Interoperability (LTI) module. """ from .lti import LTIBlock diff --git a/xblocks_contrib/lti/lti.py b/xblocks_contrib/lti/lti.py index 5dbaf15..5c06f6d 100644 --- a/xblocks_contrib/lti/lti.py +++ b/xblocks_contrib/lti/lti.py @@ -1,81 +1,637 @@ -"""TO-DO: Write a description of what this XBlock is.""" +""" +THIS MODULE IS DEPRECATED IN FAVOR OF https://github.com/openedx/xblock-lti-consumer + +Learning Tools Interoperability (LTI) module. + + +Resources +--------- + +Theoretical background and detailed specifications of LTI can be found on: + + http://www.imsglobal.org/LTI/v1p1p1/ltiIMGv1p1p1.html + +This module is based on the version 1.1.1 of the LTI specifications by the +IMS Global authority. For authentication, it uses OAuth1. + +When responding back to the LTI tool provider, we must issue a correct +response. Types of responses and their message payload is available at: + + Table A1.2 Interpretation of the 'CodeMajor/severity' matrix. + http://www.imsglobal.org/gws/gwsv1p0/imsgws_wsdlBindv1p0.html + +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: +------------------ + +1.) Display of simple LTI in iframe or a new window. +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) 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/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 +""" from importlib.resources import files -from django.utils import translation +import base64 +import datetime +import hashlib +import logging +import textwrap +from xml.sax.saxutils import escape +from unittest import mock +from urllib import parse + +import nh3 +import oauthlib.oauth1 +from django.conf import settings +from lxml import etree +from oauthlib.oauth1.rfc5849 import signature +from pytz import UTC +from webob import Response from web_fragments.fragment import Fragment -from xblock.core import XBlock -from xblock.fields import Integer, Scope +from xblock.core import List, Scope, String, XBlock +from xblock.fields import Boolean, Float from xblock.utils.resources import ResourceLoader +from openedx.core.djangolib.markup import HTML, Text +from .lti_2_util import LTI20BlockMixin, LTIError + +from common.djangoapps.xblock_django.constants import ( + ATTR_KEY_ANONYMOUS_USER_ID, + ATTR_KEY_USER_ROLE, +) + resource_loader = ResourceLoader(__name__) +log = logging.getLogger(__name__) -# This Xblock is just to test the strucutre of xblocks-contrib -@XBlock.needs("i18n") -class LTIBlock(XBlock): +DOCS_ANCHOR_TAG_OPEN = ( + "" +) +BREAK_TAG = '
' + +# Make '_' a no-op so we can scrape strings. Using lambda instead of +# `django.utils.translation.ugettext_noop` because Django cannot be imported in this file +def noop(text): + return text + +_ = noop + +class LTIFields: """ - TO-DO: document what your XBlock does. + Fields to define and obtain LTI tool from provider are set here, + except credentials, which should be set in course settings:: + + `lti_id` is id to connect tool with credentials in course settings. It should not contain :: (double semicolon) + `launch_url` is launch URL of tool. + `custom_parameters` are additional parameters to navigate to proper book and book page. + + For example, for Vitalsource provider, `launch_url` should be + *https://bc-staging.vitalsource.com/books/book*, + and to get to proper book and book page, you should set custom parameters as:: + + vbid=put_book_id_here + book_location=page/put_page_number_here + + Default non-empty URL for `launch_url` is needed due to oauthlib demand (URL scheme should be presented):: + + https://github.com/idan/oauthlib/blob/master/oauthlib/oauth1/rfc5849/signature.py#L136 """ + display_name = String( + display_name=_("Display Name"), + help=_( + "The display name 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=Text(_( + "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." + "{break_tag}See {docs_anchor_open}the edX LTI documentation{anchor_close} for more details on this setting." + )).format( + break_tag=HTML(BREAK_TAG), + docs_anchor_open=HTML(DOCS_ANCHOR_TAG_OPEN), + anchor_close=HTML("
") + ), + default='', + scope=Scope.settings + ) + launch_url = String( + display_name=_("LTI URL"), + help=Text(_( + "Enter the URL of the external tool that this component launches. " + "This setting is only used when Hide External Tool is set to False." + "{break_tag}See {docs_anchor_open}the edX LTI documentation{anchor_close} for more details on this setting." + )).format( + break_tag=HTML(BREAK_TAG), + docs_anchor_open=HTML(DOCS_ANCHOR_TAG_OPEN), + anchor_close=HTML("") + ), + default='http://www.example.com', + scope=Scope.settings) + custom_parameters = List( + display_name=_("Custom Parameters"), + help=Text(_( + "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." + "{break_tag}See {docs_anchor_open}the edX LTI documentation{anchor_close} for more details on this setting." + )).format( + break_tag=HTML(BREAK_TAG), + docs_anchor_open=HTML(DOCS_ANCHOR_TAG_OPEN), + anchor_close=HTML("") + ), + 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( + 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}, + ) + 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 + ) + + # Users will be presented with a message indicating that their e-mail/username would be sent to a third + # party application. When "Open in New Page" is not selected, the tool automatically appears without any user action. # lint-amnesty, pylint: disable=line-too-long + ask_to_send_username = Boolean( + display_name=_("Request user's username"), + # Translators: This is used to request the user's username for a third party service. + help=_("Select True to request the user's username."), + default=False, + scope=Scope.settings + ) + ask_to_send_email = Boolean( + display_name=_("Request user's email"), + # Translators: This is used to request the user's email for a third party service. + help=_("Select True to request the user's email address."), + default=False, + scope=Scope.settings + ) + + description = String( + display_name=_("LTI Application Information"), + help=_( + "Enter a description of the third party application. If requesting username and/or email, use this text box to inform users " # lint-amnesty, pylint: disable=line-too-long + "why their username and/or email will be forwarded to a third party application." + ), + default="", + scope=Scope.settings + ) - # Fields are defined on the class. You can access them in your code as - # self.. + button_text = String( + display_name=_("Button Text"), + help=_( + "Enter the text on the button used to launch the third party application." + ), + default="", + scope=Scope.settings + ) - # TO-DO: delete count, and define your own fields. - count = Integer( - default=0, - scope=Scope.user_state, - help="A simple counter, to show something happening", + accept_grades_past_due = Boolean( + display_name=_("Accept grades past deadline"), + help=_("Select True to allow third party systems to post grades past the deadline."), + default=True, + scope=Scope.settings ) + +@XBlock.needs("i18n") +@XBlock.needs("user") +@XBlock.needs("rebind_user") +class LTIBlock( + XBlock, + LTIFields, + LTI20BlockMixin, +): # pylint: disable=abstract-method + """ + THIS MODULE IS DEPRECATED IN FAVOR OF https://github.com/openedx/xblock-lti-consumer + + Module provides LTI integration to course. + + Except usual Xmodule structure it proceeds with OAuth signing. + How it works:: + + 1. Get credentials from course settings. + + 2. There is minimal set of parameters need to be signed (presented for Vitalsource):: + + user_id + oauth_callback + lis_outcome_service_url + lis_result_sourcedid + launch_presentation_return_url + lti_message_type + lti_version + roles + *+ all custom parameters* + + These parameters should be encoded and signed by *OAuth1* together with + `launch_url` and *POST* request type. + + 3. Signing proceeds with client key/secret pair obtained from course settings. + That pair should be obtained from LTI provider and set into course settings by course author. + After that signature and other OAuth data are generated. + + OAuth data which is generated after signing is usual:: + + oauth_callback + oauth_nonce + oauth_consumer_key + oauth_signature_method + oauth_timestamp + oauth_version + + + 4. All that data is passed to form and sent to LTI provider server by browser via + autosubmit via JavaScript. + + Form example:: + +
+ + + + + + + + + + + + + + + + + + + + +
+ + 5. LTI provider has same secret key and it signs data string via *OAuth1* and compares signatures. + + If signatures are correct, LTI provider redirects iframe source to LTI tool web page, + and LTI tool is rendered to iframe inside course. + + Otherwise error message from LTI provider is generated. + """ + # Indicates that this XBlock has been extracted from edx-platform. is_extracted = True - def resource_string(self, path): - """Handy helper for getting resources from our kit.""" - return files(__package__).joinpath(path).read_text(encoding="utf-8") + def max_score(self): + return self.weight if self.has_score else None + + def get_input_fields(self): # lint-amnesty, pylint: disable=missing-function-docstring + # LTI provides a list of default parameters that might be passed as + # part of the POST data. These parameters should not be prefixed. + # Likewise, The creator of an LTI link can add custom key/value parameters + # to a launch which are to be included with the launch of the LTI link. + # In this case, we will automatically add `custom_` prefix before this parameters. + # See http://www.imsglobal.org/LTI/v1p1p1/ltiIMGv1p1p1.html#_Toc316828520 + PARAMETERS = [ + "lti_message_type", + "lti_version", + "resource_link_title", + "resource_link_description", + "user_image", + "lis_person_name_given", + "lis_person_name_family", + "lis_person_name_full", + "lis_person_contact_email_primary", + "lis_person_sourcedid", + "role_scope_mentor", + "context_type", + "context_title", + "context_label", + "launch_presentation_locale", + "launch_presentation_document_target", + "launch_presentation_css_url", + "launch_presentation_width", + "launch_presentation_height", + "launch_presentation_return_url", + "tool_consumer_info_product_family_code", + "tool_consumer_info_version", + "tool_consumer_instance_guid", + "tool_consumer_instance_name", + "tool_consumer_instance_description", + "tool_consumer_instance_url", + "tool_consumer_instance_contact_email", + ] + + client_key, client_secret = self.get_client_key_secret() + + # parsing custom parameters to dict + custom_parameters = {} + + for custom_parameter in self.custom_parameters: + try: + param_name, param_value = [p.strip() for p in custom_parameter.split('=', 1)] + except ValueError: + _ = self.runtime.service(self, "i18n").ugettext + msg = _('Could not parse custom parameter: {custom_parameter}. Should be "x=y" string.').format( + custom_parameter=f"{custom_parameter!r}" + ) + raise LTIError(msg) # lint-amnesty, pylint: disable=raise-missing-from + + # LTI specs: 'custom_' should be prepended before each custom parameter, as pointed in link above. + if param_name not in PARAMETERS: + param_name = 'custom_' + param_name + + custom_parameters[str(param_name)] = str(param_value) + + return self.oauth_params( + custom_parameters, + client_key, + client_secret, + ) + + def get_context(self): + """ + Returns a context. + """ + # nh3 defaults for + # ALLOWED_TAGS are + # { + # 'a', 'abbr', 'acronym', 'area', 'article', 'aside', 'b', 'bdi', 'bdo', + # 'blockquote', 'br', 'caption', 'center', 'cite', 'code', 'col', 'colgroup', + # 'data', 'dd', 'del', 'details', 'dfn', 'div', 'dl', 'dt', 'em', 'figcaption', + # 'figure', 'footer', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hgroup', + # 'hr', 'i', 'img', 'ins', 'kbd', 'li', 'map', 'mark', 'nav', 'ol', 'p', 'pre', + # 'q', 'rp', 'rt', 'rtc', 'ruby', 's', 'samp', 'small', 'span', 'strike', + # 'strong', 'sub', 'summary', 'sup', 'table', 'tbody', 'td', 'th', 'thead', + # 'time', 'tr', 'tt', 'u', 'ul', 'var', 'wbr' + # } + # + # ALLOWED_ATTRIBUTES are + # { + # 'a': {'href', 'hreflang'}, + # 'bdo': {'dir'}, + # 'blockquote': {'cite'}, + # 'col': {'charoff', 'char', 'align', 'span'}, + # 'colgroup': {'align', 'char', 'charoff', 'span'}, + # 'del': {'datetime', 'cite'}, + # 'hr': {'width', 'align', 'size'}, + # 'img': {'height', 'src', 'width', 'alt', 'align'}, + # 'ins': {'datetime', 'cite'}, + # 'ol': {'start'}, + # 'q': {'cite'}, + # 'table': {'align', 'char', 'charoff', 'summary'}, + # 'tbody': {'align', 'char', 'charoff'}, + # 'td': {'rowspan', 'headers', 'charoff', 'colspan', 'char', 'align'}, + # 'tfoot': {'align', 'char', 'charoff'}, + # 'th': {'rowspan', 'headers', 'charoff', 'colspan', 'scope', 'char', 'align'}, + # 'thead': {'charoff', 'char', 'align'}, + # 'tr': {'align', 'char', 'charoff'} + # } + # + # This lets all plaintext through. + sanitized_comment = nh3.clean(self.score_comment) + + return { + 'input_fields': self.get_input_fields(), + + # These parameters do not participate in OAuth signing. + 'launch_url': self.launch_url.strip(), + 'element_id': self.location.html_id(), + 'element_class': self.category, + '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, + 'description': self.description, + 'ask_to_send_username': self.ask_to_send_username, + 'ask_to_send_email': self.ask_to_send_email, + 'button_text': self.button_text, + 'accept_grades_past_due': self.accept_grades_past_due, + } - # TO-DO: change this view to display your data your own way. def student_view(self, context=None): """ Create primary view of the LTIBlock, shown to students when viewing courses. """ - if context: - pass # TO-DO: do something based on the context. - frag = Fragment() frag.add_content( resource_loader.render_django_template( - "templates/lti.html", - { - "count": self.count, - }, - i18n_service=self.runtime.service(self, "i18n"), + "templates/lti.html", self.get_context() ) ) - frag.add_css(self.resource_string("static/css/lti.css")) - frag.add_javascript(self.resource_string("static/js/src/lti.js")) + frag.add_css(resource_loader.load_unicode("static/css/lti.css")) + frag.add_javascript(resource_loader.load_unicode("static/js/src/lti.js")) frag.initialize_js("LTIBlock") return frag - # TO-DO: change this handler to perform your own actions. You may need more - # than one handler, or you may not need any handlers at all. - @XBlock.json_handler - def increment_count(self, data, suffix=""): + @XBlock.handler + def preview_handler(self, _, __): + """ + This is called to get context with new oauth params to iframe. + """ + template = resource_loader.load_unicode("templates/lti_form.html").format(**self.get_context()) + return Response(template, content_type='text/html') + + @XBlock.handler + def grade_handler(self, request, suffix): # lint-amnesty, pylint: disable=unused-argument """ - Increments data. An example handler. + This is called by courseware.block_render, to handle an AJAX call. + + Used only for grading. Returns XML response. + + Example of request body from LTI provider:: + + + + + + V1.0 + 528243ba5241b + + + + + + + feb-123-456-2929::28883 + + + + en-us + 0.4 + + + + + + + + Example of correct/incorrect answer XML body:: see response_xml_template. """ - if suffix: - pass # TO-DO: Use the suffix when storing data. - # Just to show data coming in... - assert data["hello"] == "world" + response_xml_template = textwrap.dedent("""\ + + + + + V1.0 + {imsx_messageIdentifier} + + {imsx_codeMajor} + status + {imsx_description} + + + + + + {response} + + """) + # Returns when `action` is unsupported. + # Supported actions: + # - replaceResultRequest. + unsupported_values = { + 'imsx_codeMajor': 'unsupported', + 'imsx_description': 'Target does not support the requested operation.', + 'imsx_messageIdentifier': 'unknown', + 'response': '' + } + # Returns if: + # - past due grades are not accepted and grade is past due + # - score is out of range + # - can't parse response from TP; + # - can't verify OAuth signing or OAuth signing is incorrect. + failure_values = { + 'imsx_codeMajor': 'failure', + 'imsx_description': 'The request has failed.', + 'imsx_messageIdentifier': 'unknown', + 'response': '' + } + + if not self.accept_grades_past_due and self.is_past_due(): + failure_values['imsx_description'] = "Grade is past due" + return Response(response_xml_template.format(**failure_values), content_type="application/xml") + + try: + imsx_messageIdentifier, sourcedId, score, action = self.parse_grade_xml_body(request.body) + except Exception as e: # lint-amnesty, pylint: disable=broad-except + error_message = "Request body XML parsing error: " + escape(str(e)) + log.debug("[LTI]: " + error_message) # lint-amnesty, pylint: disable=logging-not-lazy + failure_values['imsx_description'] = error_message + return Response(response_xml_template.format(**failure_values), content_type="application/xml") + + # Verify OAuth signing. + try: + self.verify_oauth_body_sign(request) + except (ValueError, LTIError) as e: + failure_values['imsx_messageIdentifier'] = escape(imsx_messageIdentifier) + error_message = "OAuth verification error: " + escape(str(e)) + failure_values['imsx_description'] = error_message + log.debug("[LTI]: " + error_message) # lint-amnesty, pylint: disable=logging-not-lazy + return Response(response_xml_template.format(**failure_values), content_type="application/xml") + + real_user = self.runtime.service(self, 'user').get_user_by_anonymous_id(parse.unquote(sourcedId.split(':')[-1])) + if not real_user: # that means we can't save to database, as we do not have real user id. + failure_values['imsx_messageIdentifier'] = escape(imsx_messageIdentifier) + failure_values['imsx_description'] = "User not found." + return Response(response_xml_template.format(**failure_values), content_type="application/xml") + + if action == 'replaceResultRequest': + self.set_user_module_score(real_user, score, self.max_score()) - self.count += 1 - return {"count": self.count} + values = { + 'imsx_codeMajor': 'success', + 'imsx_description': f'Score for {sourcedId} is now {score}', + 'imsx_messageIdentifier': escape(imsx_messageIdentifier), + 'response': '' + } + log.debug("[LTI]: Grade is saved.") + return Response(response_xml_template.format(**values), content_type="application/xml") + + unsupported_values['imsx_messageIdentifier'] = escape(imsx_messageIdentifier) + log.debug("[LTI]: Incorrect action.") + return Response(response_xml_template.format(**unsupported_values), content_type='application/xml') - # TO-DO: change this to create the scenarios you'd like to see in the - # workbench while developing your XBlock. @staticmethod def workbench_scenarios(): """Create canned scenario for display in the workbench.""" @@ -96,9 +652,331 @@ def workbench_scenarios(): ), ] - @staticmethod - def get_dummy(): + def get_user_id(self): + """ + Returns the current user ID, URL-escaped so it is safe to use as a URL component. + """ + user_id = self.runtime.service(self, 'user').get_current_user().opt_attrs.get(ATTR_KEY_ANONYMOUS_USER_ID) + assert user_id is not None + return str(parse.quote(user_id)) + + def get_outcome_service_url(self, service_name="grade_handler"): + """ + Return URL for storing grades. + + To test LTI on sandbox we must use http scheme. + + 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 + """ + return self.runtime.handler_url(self, service_name, thirdparty=True).rstrip('/?') + + def get_resource_link_id(self): + """ + This is an opaque unique identifier that the TC guarantees will be unique + within the TC for every placement of the link. + + If the tool / activity is placed multiple times in the same context, + each of those placements will be distinct. + + This value will also change if the item is exported from one system or + context and imported into another system or context. + + This parameter is required. + + Example: u'edx.org-i4x-2-3-lti-31de800015cf4afb973356dbe81496df' + + Hostname, edx.org, + makes resource_link_id change on import to another system. + + Last part of location, location.name - 31de800015cf4afb973356dbe81496df, + is random hash, updated by course_id, + this makes resource_link_id unique inside single course. + + First part of location is tag-org-course-category, i4x-2-3-lti. + + Location.name itself does not change on import to another course, + but org and course_id change. + + So together with org and course_id in a form of + i4x-2-3-lti-31de800015cf4afb973356dbe81496df this part of resource_link_id: + makes resource_link_id to be unique among courses inside same system. + """ + return str(parse.quote(f"{settings.LMS_BASE}-{self.location.html_id()}")) + + def get_lis_result_sourcedid(self): + """ + This field contains an identifier that indicates the LIS Result Identifier (if any) + associated with this launch. This field identifies a unique row and column within the + TC gradebook. This field is unique for every combination of context_id / resource_link_id / user_id. + This value may change for a particular resource_link_id / user_id from one launch to the next. + The TP should only retain the most recent value for this field for a particular resource_link_id / user_id. + This field is generally optional, but is required for grading. + """ + return "{context}:{resource_link}:{user_id}".format( + context=parse.quote(self.context_id), + resource_link=self.get_resource_link_id(), + user_id=self.get_user_id() + ) + + def get_course(self): + """ + Return course by course id. + """ + return self.runtime.modulestore.get_course(self.course_id) + + @property + def context_id(self): + """ + Return context_id. + + context_id is an opaque identifier that uniquely identifies the context (e.g., a course) + that contains the link being launched. + """ + return str(self.course_id) + + @property + def role(self): + """ + Get system user role and convert it to LTI role. + """ + roles = { + 'student': 'Student', + 'staff': 'Administrator', + 'instructor': 'Instructor', + } + user_role = self.runtime.service(self, 'user').get_current_user().opt_attrs.get(ATTR_KEY_USER_ROLE) + return roles.get(user_role, 'Student') + + def get_icon_class(self): + """ Returns the icon class """ + if self.graded and self.has_score: # pylint: disable=no-member + return 'problem' + return 'other' + + def oauth_params(self, custom_parameters, client_key, client_secret): + """ + Signs request and returns signature and OAuth parameters. + + `custom_paramters` is dict of parsed `custom_parameter` field + `client_key` and `client_secret` are LTI tool credentials. + + Also *anonymous student id* is passed to template and therefore to LTI provider. + """ + + client = oauthlib.oauth1.Client( + client_key=str(client_key), + client_secret=str(client_secret) + ) + + # Must have parameters for correct signing from LTI: + body = { + 'user_id': self.get_user_id(), + 'oauth_callback': 'about:blank', + 'launch_presentation_return_url': '', + 'lti_message_type': 'basic-lti-launch-request', + 'lti_version': 'LTI-1p0', + 'roles': self.role, + + # Parameters required for grading: + 'resource_link_id': self.get_resource_link_id(), + 'lis_result_sourcedid': self.get_lis_result_sourcedid(), + + 'context_id': self.context_id, + } + + if self.has_score: + body.update({ + 'lis_outcome_service_url': self.get_outcome_service_url() + }) + + self.user_email = "" # lint-amnesty, pylint: disable=attribute-defined-outside-init + self.user_username = "" # lint-amnesty, pylint: disable=attribute-defined-outside-init + + # Username and email can't be sent in studio mode, because the user object is not defined. + # To test functionality test in LMS + + real_user_object = self.runtime.service(self, 'user').get_user_by_anonymous_id() + try: + self.user_email = real_user_object.email # lint-amnesty, pylint: disable=attribute-defined-outside-init + except AttributeError: + self.user_email = "" # lint-amnesty, pylint: disable=attribute-defined-outside-init + try: + self.user_username = real_user_object.username # lint-amnesty, pylint: disable=attribute-defined-outside-init + except AttributeError: + self.user_username = "" # lint-amnesty, pylint: disable=attribute-defined-outside-init + + if self.ask_to_send_username and self.user_username: + body["lis_person_sourcedid"] = self.user_username + if self.ask_to_send_email and self.user_email: + body["lis_person_contact_email_primary"] = self.user_email + + # Appending custom parameter for signing. + body.update(custom_parameters) + + headers = { + # This is needed for body encoding: + 'Content-Type': 'application/x-www-form-urlencoded', + } + + try: + __, headers, __ = client.sign( + str(self.launch_url.strip()), + http_method='POST', + body=body, + headers=headers) + except ValueError: # Scheme not in url. + # https://github.com/idan/oauthlib/blob/master/oauthlib/oauth1/rfc5849/signature.py#L136 + # Stubbing headers for now: + log.info( + "LTI block %s in course %s does not have oauth parameters correctly configured.", + self.location, + self.location.course_key, + ) + headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Authorization': 'OAuth oauth_nonce="80966668944732164491378916897", \ +oauth_timestamp="1378916897", oauth_version="1.0", oauth_signature_method="HMAC-SHA1", \ +oauth_consumer_key="", oauth_signature="frVp4JuvT1mVXlxktiAUjQ7%2F1cw%3D"'} + + params = headers['Authorization'] + # Parse headers to pass to template as part of context: + params = dict([param.strip().replace('"', '').split('=') for param in params.split(',')]) + + params['oauth_nonce'] = params['OAuth oauth_nonce'] + del params['OAuth oauth_nonce'] + + # oauthlib encodes signature with + # 'Content-Type': 'application/x-www-form-urlencoded' + # so '='' becomes '%3D'. + # We send form via browser, so browser will encode it again, + # So we need to decode signature back: + params['oauth_signature'] = parse.unquote(params['oauth_signature']).encode('utf-8').decode('utf8') # lint-amnesty, pylint: disable=line-too-long + + # Add LTI parameters to OAuth parameters for sending in form. + params.update(body) + return params + + @classmethod + def parse_grade_xml_body(cls, body): + """ + Parses XML from request.body and returns parsed data + + XML body should contain nsmap with namespace, that is specified in LTI specs. + + Returns tuple: imsx_messageIdentifier, sourcedId, score, action + + Raises Exception if can't parse. + """ + lti_spec_namespace = "http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0" + namespaces = {'def': lti_spec_namespace} + + data = body.strip() + parser = etree.XMLParser(ns_clean=True, recover=True, encoding='utf-8') + root = etree.fromstring(data, parser=parser) + + imsx_messageIdentifier = root.xpath("//def:imsx_messageIdentifier", namespaces=namespaces)[0].text or '' + sourcedId = root.xpath("//def:sourcedId", namespaces=namespaces)[0].text + score = root.xpath("//def:textString", namespaces=namespaces)[0].text + action = root.xpath("//def:imsx_POXBody", namespaces=namespaces)[0].getchildren()[0].tag.replace('{' + lti_spec_namespace + '}', '') # lint-amnesty, pylint: disable=line-too-long + # Raise exception if score is not float or not in range 0.0-1.0 regarding spec. + score = float(score) + if not 0 <= score <= 1: + raise LTIError('score value outside the permitted range of 0-1.') + + return imsx_messageIdentifier, sourcedId, score, action + + def verify_oauth_body_sign(self, request, content_type='application/x-www-form-urlencoded'): + """ + Verify grade request from LTI provider using OAuth body signing. + + Uses http://oauth.googlecode.com/svn/spec/ext/body_hash/1.0/oauth-bodyhash.html:: + + This specification extends the OAuth signature to include integrity checks on HTTP request bodies + with content types other than application/x-www-form-urlencoded. + + Arguments: + request: DjangoWebobRequest. + + Raises: + LTIError if request is incorrect. + """ + + client_key, client_secret = self.get_client_key_secret() # lint-amnesty, pylint: disable=unused-variable + headers = { + 'Authorization': str(request.headers.get('Authorization')), + 'Content-Type': content_type, + } + + sha1 = hashlib.sha1() + sha1.update(request.body) + oauth_body_hash = base64.b64encode(sha1.digest()).decode('utf-8') + oauth_params = signature.collect_parameters(headers=headers, exclude_oauth_signature=False) + oauth_headers = dict(oauth_params) + oauth_signature = oauth_headers.pop('oauth_signature') + mock_request_lti_1 = mock.Mock( + uri=str(parse.unquote(self.get_outcome_service_url())), + http_method=str(request.method), + params=list(oauth_headers.items()), + signature=oauth_signature + ) + mock_request_lti_2 = mock.Mock( + uri=str(parse.unquote(request.url)), + http_method=str(request.method), + params=list(oauth_headers.items()), + signature=oauth_signature + ) + if oauth_body_hash != oauth_headers.get('oauth_body_hash'): + log.error( + "OAuth body hash verification failed, provided: {}, " + "calculated: {}, for url: {}, body is: {}".format( + oauth_headers.get('oauth_body_hash'), + oauth_body_hash, + self.get_outcome_service_url(), + request.body + ) + ) + raise LTIError("OAuth body hash verification is failed.") + + if (not signature.verify_hmac_sha1(mock_request_lti_1, client_secret) and not + signature.verify_hmac_sha1(mock_request_lti_2, client_secret)): + log.error("OAuth signature verification failed, for " + "headers:{} url:{} method:{}".format( + oauth_headers, + self.get_outcome_service_url(), + str(request.method) + )) + raise LTIError("OAuth signature verification has failed.") + + def get_client_key_secret(self): """ - Generate initial i18n with dummy method. + Obtains client_key and client_secret credentials from current course. """ - return translation.gettext_noop("Dummy") + course = self.get_course() + for lti_passport in course.lti_passports: + try: + lti_id, key, secret = [i.strip() for i in lti_passport.split(':')] + except ValueError: + _ = self.runtime.service(self, "i18n").ugettext + msg = _('Could not parse LTI passport: {lti_passport}. Should be "id:key:secret" string.').format( + lti_passport=f'{lti_passport!r}' + ) + raise LTIError(msg) # lint-amnesty, pylint: disable=raise-missing-from + + if lti_id == self.lti_id.strip(): + return key, secret + return '', '' + + def is_past_due(self): + """ + Is it now past this problem's due date, including grace period? + """ + due_date = self.due # pylint: disable=no-member + if self.graceperiod is not None and due_date: # pylint: disable=no-member + close_date = due_date + self.graceperiod # pylint: disable=no-member + else: + close_date = due_date + return close_date is not None and datetime.datetime.now(UTC) > close_date + diff --git a/xblocks_contrib/lti/lti_2_util.py b/xblocks_contrib/lti/lti_2_util.py new file mode 100644 index 0000000..11eba5e --- /dev/null +++ b/xblocks_contrib/lti/lti_2_util.py @@ -0,0 +1,370 @@ +""" +A mixin class for LTI 2.0 functionality. This is really just done to refactor the code to +keep the LTIBlock class from getting too big +""" + + +import base64 +import hashlib +import json +import logging +import re +from unittest import mock +from urllib import parse + +from django.conf import settings +from oauthlib.oauth1 import Client +from webob import Response +from xblock.core import XBlock + +from openedx.core.lib.grade_utils import round_away_from_zero + +log = logging.getLogger(__name__) + +LTI_2_0_REST_SUFFIX_PARSER = re.compile(r"^user/(?P\w+)", re.UNICODE) +LTI_2_0_JSON_CONTENT_TYPE = 'application/vnd.ims.lis.v2.result+json' + + +class LTIError(Exception): + """Error class for LTIBlock and LTI20BlockMixin""" + + +class LTI20BlockMixin: + """ + This class MUST be mixed into LTIBlock. 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/ + so suffix is of the form "user/" + 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/" + + Returns: + webob.response: response to this request. See above for details. + """ + if settings.DEBUG: + self._log_correct_authorization_header(request) + + if not self.accept_grades_past_due and self.is_past_due(): + return Response(status=404) # have to do 404 due to spec, but 400 is better, with error msg in body + + 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.runtime.service(self, 'user').get_user_by_anonymous_id(anon_id) + if not real_user: # that means we can't save to database, as we do not have real user id. + msg = f"[LTI]: Real user not found against anon_id: {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 = str(base64.b64encode(sha1.digest())) + log.debug(f"[LTI] oauth_body_hash = {oauth_body_hash}") + client_key, client_secret = self.get_client_key_secret() + client = Client(client_key, client_secret) + mock_request = mock.Mock( + uri=str(parse.unquote(request.url)), + headers=request.headers, + body="", + decoded_body="", + http_method=str(request.method), + ) + params = client.get_oauth_params(mock_request) + mock_request.oauth_params = params + mock_request.oauth_params.append(('oauth_body_hash', oauth_body_hash)) + sig = client.get_oauth_signature(mock_request) + mock_request.oauth_params.append(('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/". 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(f"[LTI]: {msg}") + raise LTIError(msg) + + def _lti_2_0_result_get_handler(self, request, real_user): + """ + 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.runtime.service(self, 'rebind_user').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).encode('utf-8'), content_type=LTI_2_0_JSON_CONTENT_TYPE) + + # Fall through to returning grade and comment + base_json_obj['resultScore'] = round_away_from_zero(self.module_score, 2) + base_json_obj['comment'] = self.score_comment + return Response(json.dumps(base_json_obj).encode('utf-8'), content_type=LTI_2_0_JSON_CONTENT_TYPE) + + def _lti_2_0_result_del_handler(self, request, real_user): + """ + 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.decode('utf-8')) + 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 block + 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, score_deleted=True) + + def set_user_module_score(self, user, score, max_score, comment="", score_deleted=False): + """ + 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.runtime.service(self, 'rebind_user').rebind_noauth_module_to_user(self, user) + + # have to publish for the progress page... + self.runtime.publish( + self, + 'grade', + { + 'value': scaled_score, + 'max_value': max_score, + 'user_id': user.id, + 'score_deleted': score_deleted, + }, + ) + 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(f"[LTI]: v2.0 result service -- bad Content-Type: {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(f"[LTI]: v2.0 result service -- OAuth body verification failed: {str(err)}") + raise LTIError(str(err)) # lint-amnesty, pylint: disable=raise-missing-from + + 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 = f"Supplied JSON string in request body could not be decoded: {json_str}" + log.info(f"[LTI] {msg}") + raise LTIError(msg) # lint-amnesty, pylint: disable=raise-missing-from + + # 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 not isinstance(json_obj, dict): + if isinstance(json_obj, list) and len(json_obj) >= 1 and isinstance(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(f"[LTI] {msg}") + raise LTIError(msg) + + # '@type' must be "Result" + result_type = json_obj.get("@type") + if result_type != "Result": + msg = f"JSON object does not contain correct @type attribute (should be 'Result', is {result_type})" + log.info(f"[LTI] {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 = f"JSON object does not contain required key {key}" + log.info(f"[LTI] {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(f"[LTI] {msg}") + raise LTIError(msg) + except (TypeError, ValueError) as err: + msg = f"Could not convert resultScore to float: {str(err)}" + log.info(f"[LTI] {msg}") + raise LTIError(msg) # lint-amnesty, pylint: disable=raise-missing-from + + return score, json_obj.get('comment', "") diff --git a/xblocks_contrib/lti/static/README.txt b/xblocks_contrib/lti/static/README.txt new file mode 100644 index 0000000..127da5a --- /dev/null +++ b/xblocks_contrib/lti/static/README.txt @@ -0,0 +1,18 @@ +This static directory is for files that should be included in your kit as plain +static files. + +You can ask the runtime for a URL that will retrieve these files with: + + url = self.runtime.local_resource_url(self, "static/js/lib.js") + +The default implementation is very strict though, and will not serve files from +the static directory. It will serve files from a directory named "public". +Create a directory alongside this one named "public", and put files there. +Then you can get a url with code like this: + + url = self.runtime.local_resource_url(self, "public/js/lib.js") + +The sample code includes a function you can use to read the content of files +in the static directory, like this: + + frag.add_javascript(self.resource_string("static/js/my_block.js")) diff --git a/xblocks_contrib/lti/static/css/lti.css b/xblocks_contrib/lti/static/css/lti.css index 28e3b63..7e9b576 100644 --- a/xblocks_contrib/lti/static/css/lti.css +++ b/xblocks_contrib/lti/static/css/lti.css @@ -1,9 +1,57 @@ /* CSS for LTIBlock */ -.lti .count { - font-weight: bold; +@import url("https://fonts.googleapis.com/css?family=Open+Sans:300,400,400i,600,700"); + +h2.problem-header { + display: inline-block; +} + +div.problem-progress { + display: inline-block; + padding-left: 5px; + color: #666; + font-weight: 100; + font-size: 1em; +} + +div.lti { + margin: 0 auto; +} + +div.lti .wrapper-lti-link { + font-size: 14px; + background-color: #f6f6f6; + padding: 20px; +} + +div.lti .wrapper-lti-link .lti-link { + margin-bottom: 0; + text-align: right; +} + +div.lti .wrapper-lti-link .lti-link .link_lti_new_window { + font-size: 13px; + line-height: 20.72px; +} + +div.lti form.ltiLaunchForm { + display: none; +} + +div.lti iframe.ltiLaunchFrame { + width: 100%; + height: 800px; + display: block; + border: 0px; +} + +div.lti h4.problem-feedback-label { + font-weight: 100; + font-size: 1em; + font-family: "Source Sans", "Open Sans", Verdana, Geneva, sans-serif, sans-serif; } -.lti p { - cursor: pointer; +div.lti div.problem-feedback { + margin-top: 5px; + margin-bottom: 5px; } diff --git a/xblocks_contrib/lti/static/js/src/lti.js b/xblocks_contrib/lti/static/js/src/lti.js index 74e5e76..28d4d0e 100644 --- a/xblocks_contrib/lti/static/js/src/lti.js +++ b/xblocks_contrib/lti/static/js/src/lti.js @@ -1,38 +1,36 @@ - /* JavaScript for LTIBlock. */ -function LTIBlock(runtime, element) { - const updateCount = (result) => { - $('.count', element).text(result.count); - }; - const handlerUrl = runtime.handlerUrl(element, 'increment_count'); - - $('p', element).on('click', (eventObject) => { - $.ajax({ - type: 'POST', - url: handlerUrl, - contentType: 'application/json', - data: JSON.stringify({hello: 'world'}), - success: updateCount - }); - }); +(function() { + 'use strict'; - $(() => { - /* - Use `gettext` provided by django-statici18n for static translations - */ + /** + * This function will process all the attributes from the DOM element passed, taking all of + * the configuration attributes. It uses the request-username and request-email + * to prompt the user to decide if they want to share their personal information + * with the third party application connecting through LTI. + * @constructor + * @param {jQuery} element DOM element with the lti container. + */ + this.LTI = function(element) { + var dataAttrs = $(element).find('.lti').data(), + askToSendUsername = (dataAttrs.askToSendUsername === 'True'), + askToSendEmail = (dataAttrs.askToSendEmail === 'True'); - // eslint-disable-next-line no-undef - const dummyText = gettext('Hello World'); - - // Example usage of interpolation for translated strings - // eslint-disable-next-line no-undef - const message = StringUtils.interpolate( - gettext('You are enrolling in {courseName}'), - { - courseName: 'Rock & Roll 101' + // When the lti button is clicked, provide users the option to + // accept or reject sending their information to a third party + $(element).on('click', '.link_lti_new_window', function() { + if (askToSendUsername && askToSendEmail) { + // eslint-disable-next-line no-alert + return confirm(gettext('Click OK to have your username and e-mail address sent to a 3rd party application.\n\nClick Cancel to return to this page without sending your information.')); + } else if (askToSendUsername) { + // eslint-disable-next-line no-alert + return confirm(gettext('Click OK to have your username sent to a 3rd party application.\n\nClick Cancel to return to this page without sending your information.')); + } else if (askToSendEmail) { + // eslint-disable-next-line no-alert + return confirm(gettext('Click OK to have your e-mail address sent to a 3rd party application.\n\nClick Cancel to return to this page without sending your information.')); + } else { + return true; } - ); - console.log(message); // This is just for demonstration purposes - }); -} + }); + }; +}).call(this); diff --git a/xblocks_contrib/lti/templates/lti.html b/xblocks_contrib/lti/templates/lti.html index 8f9288f..bb94cb7 100644 --- a/xblocks_contrib/lti/templates/lti.html +++ b/xblocks_contrib/lti/templates/lti.html @@ -1,7 +1,70 @@ {% load i18n %} -
-

- LTIBlock: {% trans "count is now" %} {{ count }} {% trans "click me to increment." %} -

+

+ {# Translators: "External resource" means that this learning module is hosted on a platform external to the edX LMS #} + {{display_name}} {% trans 'External resource' %} +

+ +{% if has_score and weight %} +
+ {% 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. #} + {% trans "points" as points_trans %} + {% trans "{points} / {total_points} points" as score_trans %} + ({{ module_score }} / {{ weight }} {{ points_trans }}) + {% else %} + {# Translators: "total_points" is the maximum number of points achievable on this LTI unit #} + {% trans "total_points" as total_points_trans %} + ({{ weight }} {{ total_points_trans }} possible) + {% endif %} +
+{% endif %} + +
+ +{% if launch_url and launch_url != 'http://www.example.com' and not hide_launch %} + {% if open_in_a_new_page %} + + {% else %} + {# The result of the form submit will be rendered here. #} + + {% endif %} +{% elif not hide_launch %} +

+ {{ _('Please provide launch_url. Click "Edit", and fill in the required fields.') }} +

+{% endif %} + +{% if has_score and comment %} + + +{% endif %} +
diff --git a/xblocks_contrib/lti/templates/lti_form.html b/xblocks_contrib/lti/templates/lti_form.html new file mode 100644 index 0000000..7c72487 --- /dev/null +++ b/xblocks_contrib/lti/templates/lti_form.html @@ -0,0 +1,38 @@ +{% load i18n %} + + + + + + LTI + + + {% comment %} + This form will be hidden. + LTI block JavaScript will trigger a "submit" on the form, and the + result will be rendered instead. + {% endcomment %} + + + + diff --git a/xblocks_contrib/lti/tests/test_lti20_unit.py b/xblocks_contrib/lti/tests/test_lti20_unit.py new file mode 100644 index 0000000..6ade72e --- /dev/null +++ b/xblocks_contrib/lti/tests/test_lti20_unit.py @@ -0,0 +1,387 @@ +"""Tests for LTI Xmodule LTIv2.0 functional logic.""" + + +import datetime +import textwrap +import unittest +from unittest.mock import Mock + +from pytz import UTC +from xblock.field_data import DictFieldData + +from lti_2_util import LTIError +from lti import LTIBlock +from xmodule.tests.helpers import StubUserService + +from . import get_test_system + + +class LTI20RESTResultServiceTest(unittest.TestCase): + """Logic tests for LTI block. LTI2.0 REST ResultService""" + + USER_STANDIN = Mock() + USER_STANDIN.id = 999 + + def setUp(self): + super().setUp() + self.runtime = get_test_system(user=self.USER_STANDIN) + self.environ = {'wsgi.url_scheme': 'http', 'REQUEST_METHOD': 'POST'} + self.runtime.publish = Mock() + self.runtime._services['rebind_user'] = Mock() # pylint: disable=protected-access + + self.xblock = LTIBlock(self.runtime, DictFieldData({}), Mock()) + self.lti_id = self.xblock.lti_id + self.xblock.due = None + self.xblock.graceperiod = None + + 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(name='mocked_course', lti_passports=['lti_id:test_client:test_secret']) + modulestore = Mock(name='modulestore') + modulestore.get_course.return_value = mocked_course + self.xblock.runtime.modulestore = modulestore + self.xblock.lti_id = "lti_id" + + test_cases = ( # (before sanitize, after sanitize) + ("plaintext", "plaintext"), + ("a ", "a "), # drops scripts + ("bold 包", "bold 包"), # unicode, and tags pass through + ) + for case in test_cases: + self.xblock.score_comment = case[0] + assert case[1] == self.xblock.get_context()['comment'] + + def test_lti20_rest_bad_contenttype(self): + """ + Input with bad content type + """ + with self.assertRaisesRegex(LTIError, "Content-Type must be"): + request = Mock(headers={'Content-Type': 'Non-existent'}) + self.xblock.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.xblock.verify_oauth_body_sign = Mock(side_effect=LTIError(err_msg)) + with self.assertRaisesRegex(LTIError, err_msg): + request = Mock(headers={'Content-Type': 'application/vnd.ims.lis.v2.result+json'}) + self.xblock.verify_lti_2_0_result_rest_headers(request) + + def test_lti20_rest_good_headers(self): + """ + Input with good oauth body hash verification + """ + self.xblock.verify_oauth_body_sign = Mock(return_value=True) + + request = Mock(headers={'Content-Type': 'application/vnd.ims.lis.v2.result+json'}) + self.xblock.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 + assert self.xblock.verify_oauth_body_sign.called + + BAD_DISPATCH_INPUTS = [ + None, + "", + "abcd" + "notuser/abcd" + "user/" + "user//" + "user/gbere/" + "user/gbere/xsdf" + "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/ + """ + for einput in self.BAD_DISPATCH_INPUTS: + with self.assertRaisesRegex(LTIError, "No valid user id found in endpoint URL"): + self.xblock.parse_lti_2_0_handler_suffix(einput) + + GOOD_DISPATCH_INPUTS = [ + ("user/abcd3", "abcd3"), + ("user/Äbcdè2", "Ä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/ + """ + for ginput, expected in self.GOOD_DISPATCH_INPUTS: + assert self.xblock.parse_lti_2_0_handler_suffix(ginput) == expected + + BAD_JSON_INPUTS = [ + # (bad inputs, error message expected) + ([ + "kk", # ValueError + "{{}", # ValueError + "{}}", # ValueError + 3, # TypeError + {}, # TypeError + ], "Supplied JSON string in request body could not be decoded"), + ([ + "3", # valid json, not array or object + "[]", # valid json, array too small + "[3, {}]", # valid json, 1st element not an object + ], "Supplied JSON string is a list that does not contain an object as the first element"), + ([ + '{"@type": "NOTResult"}', # @type key must have value 'Result' + ], "JSON object does not contain correct @type attribute"), + ([ + # @context missing + '{"@type": "Result", "resultScore": 0.1}', + ], "JSON object does not contain required key"), + ([ + ''' + {"@type": "Result", + "@context": "http://purl.imsglobal.org/ctx/lis/v2/Result", + "resultScore": 100}''' # score out of range + ], "score value outside the permitted range of 0-1."), + ([ + ''' + {"@type": "Result", + "@context": "http://purl.imsglobal.org/ctx/lis/v2/Result", + "resultScore": "1b"}''', # score ValueError + ''' + {"@type": "Result", + "@context": "http://purl.imsglobal.org/ctx/lis/v2/Result", + "resultScore": {}}''', # score TypeError + ], "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.assertRaisesRegex(LTIError, error_message): + self.xblock.parse_lti_2_0_result_json(einput) + + GOOD_JSON_INPUTS = [ + (''' + {"@type": "Result", + "@context": "http://purl.imsglobal.org/ctx/lis/v2/Result", + "resultScore": 0.1}''', ""), # no comment means we expect "" + (''' + [{"@type": "Result", + "@context": "http://purl.imsglobal.org/ctx/lis/v2/Result", + "@id": "anon_id:abcdef0123456789", + "resultScore": 0.1}]''', ""), # OK to have array of objects -- just take the first. @id is okay too + (''' + {"@type": "Result", + "@context": "http://purl.imsglobal.org/ctx/lis/v2/Result", + "resultScore": 0.1, + "comment": "ಠ益ಠ"}''', "ಠ益ಠ"), # 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.xblock.parse_lti_2_0_result_json(json_str) + assert score == 0.1 + assert comment == expected_comment + + GOOD_JSON_PUT = textwrap.dedent(""" + {"@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(""" + {"@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='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': ( + 'OAuth oauth_nonce="135685044251684026041377608307", ' + 'oauth_timestamp="1234567890", oauth_version="1.0", ' + 'oauth_signature_method="HMAC-SHA1", ' + 'oauth_consumer_key="test_client_key", ' + 'oauth_signature="my_signature%3D", ' + 'oauth_body_hash="gz+PeJZuF2//n9hNUnDj2v5kN70="' + ) + } + mock_request.url = 'http://testurl' + mock_request.http_method = method + mock_request.method = method + mock_request.body = body + return mock_request + + def setup_system_xblock_mocks_for_lti20_request_test(self): + """ + Helper fn to set up mocking for lti 2.0 request test + """ + self.xblock.max_score = Mock(return_value=1.0) + self.xblock.get_client_key_secret = Mock(return_value=('test_client_key', 'test_client_secret')) + self.xblock.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_xblock_mocks_for_lti20_request_test() + SCORE = 0.55 # pylint: disable=invalid-name + COMMENT = "ಠ益ಠ" # pylint: disable=invalid-name + self.xblock.module_score = SCORE + self.xblock.score_comment = COMMENT + mock_request = self.get_signed_lti20_mock_request(self.GOOD_JSON_PUT_LIKE_DELETE) + # Now call the handler + response = self.xblock.lti_2_0_result_rest_handler(mock_request, "user/abcd") + # Now assert there's no score + assert response.status_code == 200 + assert self.xblock.module_score is None + assert self.xblock.score_comment == '' + (_, evt_type, called_grade_obj), _ = self.runtime.publish.call_args # pylint: disable=unpacking-non-sequence + assert called_grade_obj ==\ + {'user_id': self.USER_STANDIN.id, 'value': None, 'max_value': None, 'score_deleted': True} + assert evt_type == 'grade' + + def test_lti20_delete_success(self): + """ + The happy path for LTI 2.0 DELETE + """ + self.setup_system_xblock_mocks_for_lti20_request_test() + SCORE = 0.55 # pylint: disable=invalid-name + COMMENT = "ಠ益ಠ" # pylint: disable=invalid-name + self.xblock.module_score = SCORE + self.xblock.score_comment = COMMENT + mock_request = self.get_signed_lti20_mock_request(b"", method='DELETE') + # Now call the handler + response = self.xblock.lti_2_0_result_rest_handler(mock_request, "user/abcd") + # Now assert there's no score + assert response.status_code == 200 + assert self.xblock.module_score is None + assert self.xblock.score_comment == '' + (_, evt_type, called_grade_obj), _ = self.runtime.publish.call_args # pylint: disable=unpacking-non-sequence + assert called_grade_obj ==\ + {'user_id': self.USER_STANDIN.id, 'value': None, 'max_value': None, 'score_deleted': True} + assert 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_xblock_mocks_for_lti20_request_test() + mock_request = self.get_signed_lti20_mock_request(self.GOOD_JSON_PUT) + # Now call the handler + response = self.xblock.lti_2_0_result_rest_handler(mock_request, "user/abcd") + # Now assert + assert response.status_code == 200 + assert self.xblock.module_score == 0.1 + assert self.xblock.score_comment == 'ಠ益ಠ' + (_, evt_type, called_grade_obj), _ = self.runtime.publish.call_args # pylint: disable=unpacking-non-sequence + assert evt_type == 'grade' + assert called_grade_obj ==\ + {'user_id': self.USER_STANDIN.id, 'value': 0.1, 'max_value': 1.0, 'score_deleted': False} + + def test_lti20_get_no_score_success(self): + """ + The happy path for LTI 2.0 GET when there's no score + """ + self.setup_system_xblock_mocks_for_lti20_request_test() + mock_request = self.get_signed_lti20_mock_request(b"", method='GET') + # Now call the handler + response = self.xblock.lti_2_0_result_rest_handler(mock_request, "user/abcd") + # Now assert + assert response.status_code == 200 + assert 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_xblock_mocks_for_lti20_request_test() + SCORE = 0.55 # pylint: disable=invalid-name + COMMENT = "ಠ益ಠ" # pylint: disable=invalid-name + self.xblock.module_score = SCORE + self.xblock.score_comment = COMMENT + mock_request = self.get_signed_lti20_mock_request(b"", method='GET') + # Now call the handler + response = self.xblock.lti_2_0_result_rest_handler(mock_request, "user/abcd") + # Now assert + assert response.status_code == 200 + assert 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_xblock_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.xblock.lti_2_0_result_rest_handler(mock_request, "user/abcd") + assert 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_xblock_mocks_for_lti20_request_test() + self.xblock.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.xblock.lti_2_0_result_rest_handler(mock_request, "user/abcd") + assert 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_xblock_mocks_for_lti20_request_test() + mock_request = self.get_signed_lti20_mock_request(self.GOOD_JSON_PUT) + response = self.xblock.lti_2_0_result_rest_handler(mock_request, None) + assert 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_xblock_mocks_for_lti20_request_test() + self.xblock.parse_lti_2_0_result_json = Mock(side_effect=LTIError()) + mock_request = self.get_signed_lti20_mock_request(self.GOOD_JSON_PUT) + response = self.xblock.lti_2_0_result_rest_handler(mock_request, "user/abcd") + assert 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_xblock_mocks_for_lti20_request_test() + self.runtime._services['user'] = StubUserService(user=None) # pylint: disable=protected-access + mock_request = self.get_signed_lti20_mock_request(self.GOOD_JSON_PUT) + response = self.xblock.lti_2_0_result_rest_handler(mock_request, "user/abcd") + assert response.status_code == 404 + + def test_lti20_request_handler_grade_past_due(self): + """ + Test that we get a 404 when accept_grades_past_due is False and it is past due + """ + self.setup_system_xblock_mocks_for_lti20_request_test() + self.xblock.due = datetime.datetime.now(UTC) + self.xblock.accept_grades_past_due = False + mock_request = self.get_signed_lti20_mock_request(self.GOOD_JSON_PUT) + response = self.xblock.lti_2_0_result_rest_handler(mock_request, "user/abcd") + assert response.status_code == 404 diff --git a/xblocks_contrib/lti/tests/test_lti.py b/xblocks_contrib/lti/tests/test_lti_unit.py similarity index 100% rename from xblocks_contrib/lti/tests/test_lti.py rename to xblocks_contrib/lti/tests/test_lti_unit.py