pax_global_header 0000666 0000000 0000000 00000000064 13120512510 0014500 g ustar 00root root 0000000 0000000 52 comment=41f5e28ae2566050911aa01421b0cebab9456d9a lti-0.9.2/ 0000775 0000000 0000000 00000000000 13120512510 0012300 5 ustar 00root root 0000000 0000000 lti-0.9.2/.gitignore 0000664 0000000 0000000 00000000342 13120512510 0014267 0 ustar 00root root 0000000 0000000 *.py[co] # Packages *.egg *.egg-info dist build eggs parts bin var sdist develop-eggs .installed.cfg # Installer logs pip-log.txt # virtualenv venv # tox .eggs .tox # pytest .cache # Coverage .coverage* # Pycharm .idea lti-0.9.2/.travis.yml 0000664 0000000 0000000 00000002140 13120512510 0014406 0 ustar 00root root 0000000 0000000 sudo: false language: python python: - 2.7 - 3.5 - 3.6 install: pip install tox-travis codecov script: tox after_success: codecov branches: only: - master - /^\d+(\.\d+)*$/ deploy: provider: pypi user: pylti password: secure: TQ0/wEA4Z96GcPPpW4+BwVLq1Oh8YDihD1ugelIwgqIfpVJNF3lSpahazocAn6Lu53Z+I+qReyFMLBpdKBZAQN4d8501V8X029uY7l2Dj7qyWsgOlJmud8BtJZizZ2jgmdg670sZSrEAipfxJ0Rm5f7ItrTWLEEDZG2FQ88T2Ao6CQuOejqqm+UP2AL9he+BjbKN4cOBiVDyYY4KaayzCwue02JMDjTrQdUUD2ebjCa1DuEMLlsiy0Qwvfi59GBM6TygF9Tp4r+S4oCSEP7IoEmOEdykG0vhRlnugMtItl4L/m1DGL/xoClBPHTRP29JJ6c4yaoC4CxH7uGbpeW1EKOrncNx2GxMLco17S7UiBmIQudrvMDSAeepj1BCPUK+QU++OZo9+ZVcA9sctMcz0EieCwh1gXeO8nXtlsbtDzwMUaHTI1C1voSDRXcYGs0UdayGqN/+Rxv26AmJUJymNmcCZdZTfKHR8KLmEmz9UX/RzXad/en9LtKPWZSmwXio/Lrv+EfScvm/Q0Lec6fz2rkiMHWrG4Bvs+4oN4/5wPqC/KqatEWEXIRF8//Ksq+ffhIthApMfvKkE5Y1OwSi5ShGGLntJusSgdB3nr/sy6xEVFR5qshTiISysnKb7cjjxI9S8cQ4lX9HpUNdY6XC0moK1LZwlSsz4hV8AT5aTHA= on: tags: true python: 3.6 condition: $TOXENV != desc distributions: sdist bdist_wheel matrix: fast_finish: true include: - python: 3.6 env: TOXENV=desc lti-0.9.2/AUTHORS.rst 0000664 0000000 0000000 00000001264 13120512510 0014162 0 ustar 00root root 0000000 0000000 This project is the iterative work of several people, under several different project names. The current core contributors are: * Ryan Hiebert `@ryanhiebert`_ * Jay Luker `@lbjay`_ .. _`@ryanhiebert`: https://github.com/ryanhiebert .. _`@lbjay`: https://github.com/lbjay The project originally started life as ``ims_lti_py``. Then Jay Luker moved the project to ``dce_lti_py``, which was later brought under the mantle of the Python LTI Initiative with the name ``lti``. The core contributors to the original ``ims_lti_py`` are: * Anson MacKeracher `@amackera`_ * Jero Sutlovic `@jsutlovic`_ .. _`@amackera`: https://github.com/amackera .. _`@jsutlovic`: https://github.com/jsutlovic lti-0.9.2/HISTORY.rst 0000664 0000000 0000000 00000003535 13120512510 0014201 0 ustar 00root root 0000000 0000000 0.9.2 (2017-06-15) ++++++++++++++++++ * Pass through params with empty values (#47) 0.9.1 (2017-06-15) ++++++++++++++++++ * Fix the PyPI long description. 0.9.0 (2017-06-14) ++++++++++++++++++ * Add test of from_post_request (#45) * Code to allow registration of an LTI 2 proxy (#42) * Fix attribute name (#44) * Content item (#41) * Fix for bug in ToolConsumer urlencoding (#40) * PEP8 (#35) * Add Python 3.6 support (#34) 0.8.4 (2016-07-26) ++++++++++++++++++ * Fix proxy validator (#30). 0.8.3 (2016-07-21) ++++++++++++++++++ * Fix flask request initialization (#28). 0.8.2 (2016-07-06) ++++++++++++++++++ * Do not require secret for tool provider (#26). 0.8.1 (2016-06-22) ++++++++++++++++++ * Python 3 compatibility. 0.8.0 (2016-05-15) ++++++++++++++++++ * Fork from dce_lti_py_, and rename to ``lti`` at version 0.7.4. * Convert text files to reStructured Text. * Use README as PyPI long description. .. _dce_lti_py: https://github.com/harvard-dce/dce_lti_py 0.7.4 (2015-10-16) ++++++++++++++++++ * Include ``oauth_body_hash`` parameter in outcome request. 0.7.3 (2015-05-28) ++++++++++++++++++ * Add some launch params specific to the Canvas editor. * Add contributor section to README. 0.7.2 (2015-05-01) ++++++++++++++++++ * Use ``find_packages`` in ``setup.py`` to find contrib packages. 0.7.1 (2015-04-30) ++++++++++++++++++ * Fork from _ims_lti_py_, and rename to ``dce_lti_py`` at version 0.6. * Update README and add HISTORY. .. _ims_lti_py: https://github.com/tophatmonocle/ims_lti_py 0.7.0 (2015-04-30) ++++++++++++++++++ * Update project to utilize oauthlib. * Convert from python-oauth2 to oauthlib and requests-oauthlib. * Refactor out the use of mixin classes. * Make ``LaunchParams`` a first-class object. * Major rewrite and rename of the test suite. * Use SemVer version identifier. * Use pytest and tox for test running. lti-0.9.2/LICENSE.rst 0000664 0000000 0000000 00000007023 13120512510 0014116 0 ustar 00root root 0000000 0000000 This project is the iterative work of several people, under several different project names. Due to this, several licenses may apply to different parts of the code base. All licenses are the MIT license, but are granted from differing copyright holders. Each license is for the code that was created in the associated project. lti --- Copyright (C) 2016 Python LTI Initiative Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. dce_lti_py ---------- Copyright (C) 2015 President and Fellows of Harvard College Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ims_lti_py ---------- Copyright (C) 2011 Top Hat Monocle Corp. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. lti-0.9.2/README.rst 0000664 0000000 0000000 00000011042 13120512510 0013765 0 ustar 00root root 0000000 0000000 ==================================== lti: Learning Tools Interoperability ==================================== .. image:: https://travis-ci.org/pylti/lti.svg?branch=master :target: https://travis-ci.org/pylti/lti .. image:: https://codecov.io/gh/pylti/lti/branch/master/graph/badge.svg :target: https://codecov.io/gh/pylti/lti .. image:: https://badges.gitter.im/pylti/lti.svg :alt: Join the chat at https://gitter.im/pylti/lti :target: https://gitter.im/pylti/lti?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge .. image:: https://requires.io/github/pylti/lti/requirements.svg?branch=master :target: https://requires.io/github/pylti/lti/requirements/?branch=master :alt: Requirements Status ``lti`` is a Python library implementing the Learning Tools Interperability (LTI) standard. It is based on dce_lti_py_, which is based on ims_lti_py_. .. _dce_lti_py: https://github.com/harvard-dce/dce_lti_py .. _ims_lti_py: https://github.com/tophatmonocle/ims_lti_py Installation ============ .. code-block:: sh pip install lti Dependencies ============ * lxml_ * oauthlib_ * requests-oauthlib_ .. _lxml: https://github.com/lxml/lxml .. _oauthlib: https://github.com/idan/oauthlib .. _requests-oauthlib: https://github.com/requests/requests-oauthlib Usage ===== The primary goal of this library is to provide classes for building Python LTI tool providers (LTI apps). To that end, the functionality that you're looking for is probably in the ``ToolConfig`` and ``ToolProvider`` classes (``ToolConsumer`` is available too, if you want to consume LTI Providers). Tool Config Example (Django) ---------------------------- Here's an example of a Django view you might use as the configuration URL when registering your app with the LTI consumer. .. code-block:: python from lti import ToolConfig from django.http import HttpResponse def tool_config(request): # basic stuff app_title = 'My App' app_description = 'An example LTI App' launch_view_name = 'lti_launch' launch_url = request.build_absolute_uri(reverse('lti_launch')) # maybe you've got some extensions extensions = { 'my_extensions_provider': { # extension settings... } } lti_tool_config = ToolConfig( title=app_title, launch_url=launch_url, secure_launch_url=launch_url, extensions=extensions, description = app_description ) return HttpResponse(lti_tool_config.to_xml(), content_type='text/xml') Tool Provider OAuth Request Validation Example (Django) ------------------------------------------------------- .. code-block:: python from lti.contrib.django import DjangoToolProvider from my_app import RequestValidator # create the tool provider instance tool_provider = DjangoToolProvider.from_django_request(request=request) # the tool provider uses the 'oauthlib' library which requires an instance # of a validator class when doing the oauth request signature checking. # see https://oauthlib.readthedocs.org/en/latest/oauth1/validator.html for # info on how to create one validator = RequestValidator() # validate the oauth request signature ok = tool_provider.is_valid_request(validator) # do stuff if ok / not ok Tool Consumer Example (Django) ------------------------------ In your view: .. code-block:: python def index(request): consumer = ToolConsumer( consumer_key='my_key_given_from_provider', consumer_secret='super_secret', launch_url='provider_url', params={ 'lti_message_type': 'basic-lti-launch-request' } ) return render( request, 'lti_consumer/index.html', { 'launch_data': consumer.generate_launch_data(), 'launch_url': consumer.launch_url } ) At the template: .. code-block:: html
Testing ======= Unit tests can be run by executing .. code-block:: sh tox This uses tox_ to set up and run the test environment. .. _tox: https://tox.readthedocs.org/ lti-0.9.2/setup.cfg 0000664 0000000 0000000 00000000221 13120512510 0014114 0 ustar 00root root 0000000 0000000 [wheel] universal = 1 [coverage:run] branch = True source = lti [coverage:paths] source = src/lti .tox/*/lib/python*/site-packages/lti lti-0.9.2/setup.py 0000664 0000000 0000000 00000001551 13120512510 0014014 0 ustar 00root root 0000000 0000000 """Set up the lti package.""" from setuptools import setup, find_packages setup( name='lti', version='0.9.2', description='A python library for building and/or consuming LTI apps', long_description=open('README.rst', 'rb').read().decode('utf-8'), maintainer='Ryan Hiebert', maintainer_email='ryan@ryanhiebert.com', url='https://github.com/pylti/lti', package_dir={'': 'src'}, packages=find_packages('src'), install_requires=[ 'lxml', 'oauthlib', 'requests-oauthlib', ], license='MIT License', keywords='lti', zip_safe=True, classifiers=[ 'Programming Language :: Python :: 2', 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', ], ) lti-0.9.2/src/ 0000775 0000000 0000000 00000000000 13120512510 0013067 5 ustar 00root root 0000000 0000000 lti-0.9.2/src/lti/ 0000775 0000000 0000000 00000000000 13120512510 0013657 5 ustar 00root root 0000000 0000000 lti-0.9.2/src/lti/__init__.py 0000664 0000000 0000000 00000000750 13120512510 0015772 0 ustar 00root root 0000000 0000000 DEFAULT_LTI_VERSION = 'LTI-1.0' # Classes from .launch_params import LaunchParams from .tool_base import ToolBase from .tool_config import ToolConfig from .tool_consumer import ToolConsumer from .tool_provider import ToolProvider from .outcome_request import OutcomeRequest from .outcome_response import OutcomeResponse from .contentitem_response import ContentItemResponse from .tool_proxy import ToolProxy # Exceptions from .utils import InvalidLTIConfigError, InvalidLTIRequestError lti-0.9.2/src/lti/contentitem_response.py 0000664 0000000 0000000 00000000422 13120512510 0020476 0 ustar 00root root 0000000 0000000 from .launch_params import CONTENT_PARAMS_REQUIRED from .tool_outbound import ToolOutbound class ContentItemResponse(ToolOutbound): def has_required_params(self): return all([ self.launch_params.get(x) for x in CONTENT_PARAMS_REQUIRED ]) lti-0.9.2/src/lti/contrib/ 0000775 0000000 0000000 00000000000 13120512510 0015317 5 ustar 00root root 0000000 0000000 lti-0.9.2/src/lti/contrib/__init__.py 0000664 0000000 0000000 00000000000 13120512510 0017416 0 ustar 00root root 0000000 0000000 lti-0.9.2/src/lti/contrib/django/ 0000775 0000000 0000000 00000000000 13120512510 0016561 5 ustar 00root root 0000000 0000000 lti-0.9.2/src/lti/contrib/django/__init__.py 0000664 0000000 0000000 00000000065 13120512510 0020673 0 ustar 00root root 0000000 0000000 from .django_tool_provider import DjangoToolProvider lti-0.9.2/src/lti/contrib/django/django_tool_provider.py 0000664 0000000 0000000 00000002433 13120512510 0023346 0 ustar 00root root 0000000 0000000 from lti import ToolProvider from django.shortcuts import redirect class DjangoToolProvider(ToolProvider): ''' ToolProvider that works with Django requests ''' @classmethod def from_django_request(cls, secret=None, request=None): if request is None: raise ValueError('request must be supplied') params = request.POST.copy() # django shoves a bunch of other junk in META that we don't care about headers = dict([(k, request.META[k]) for k in request.META if k.upper().startswith('HTTP_') or k.upper().startswith('CONTENT_')]) url = request.build_absolute_uri() return cls.from_unpacked_request(secret, params, url, headers) def success_redirect(self, msg='', log=''): ''' Shortcut for redirecting Django view to LTI Consumer with messages ''' self.lti_msg = msg self.lti_log = log return redirect(self.build_return_url()) def error_redirect(self, errormsg='', errorlog=''): ''' Shortcut for redirecting Django view to LTI Consumer with errors ''' self.lti_errormsg = errormsg self.lti_errorlog = errorlog return redirect(self.build_return_url()) lti-0.9.2/src/lti/contrib/flask/ 0000775 0000000 0000000 00000000000 13120512510 0016417 5 ustar 00root root 0000000 0000000 lti-0.9.2/src/lti/contrib/flask/__init__.py 0000664 0000000 0000000 00000000063 13120512510 0020527 0 ustar 00root root 0000000 0000000 from .flask_tool_provider import FlaskToolProvider lti-0.9.2/src/lti/contrib/flask/flask_tool_provider.py 0000664 0000000 0000000 00000000727 13120512510 0023046 0 ustar 00root root 0000000 0000000 from lti import ToolProvider class FlaskToolProvider(ToolProvider): ''' ToolProvider that works with Flask requests ''' @classmethod def from_flask_request(cls, secret=None, request=None): if request is None: raise ValueError('request must be supplied') params = request.form.copy() headers = dict(request.headers) url = request.url return cls.from_unpacked_request(secret, params, url, headers) lti-0.9.2/src/lti/launch_params.py 0000664 0000000 0000000 00000011670 13120512510 0017053 0 ustar 00root root 0000000 0000000 import sys from collections import MutableMapping from . import DEFAULT_LTI_VERSION py = sys.version_info if py < (2, 6, 0): bytes = str def touni(s, enc='utf8', err='strict'): return s.decode(enc, err) if isinstance(s, bytes) else unicode(s) LAUNCH_PARAMS_REQUIRED = [ 'lti_message_type', 'lti_version', 'resource_link_id' ] LAUNCH_PARAMS_RECOMMENDED = [ 'resource_link_description', 'resource_link_title', 'user_id', 'user_image', 'roles', 'lis_person_name_given', 'lis_person_name_family', 'lis_person_name_full', 'lis_person_contact_email_primary', 'role_scope_mentor', 'context_id', 'context_label', 'context_title', 'context_type', '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', ] LAUNCH_PARAMS_LIS = [ 'lis_course_section_sourcedid', 'lis_course_offering_sourcedid', 'lis_outcome_service_url', 'lis_person_sourcedid', 'lis_result_sourcedid', ] LAUNCH_PARAMS_RETURN_URL = [ 'lti_errormsg', 'lti_errorlog', 'lti_msg', 'lti_log' ] LAUNCH_PARAMS_OAUTH = [ 'oauth_consumer_key', 'oauth_signature_method', 'oauth_timestamp', 'oauth_nonce', 'oauth_version', 'oauth_signature', 'oauth_callback' ] LAUNCH_PARAMS_IS_LIST = [ 'roles', 'role_scope_mentor', 'context_type', 'accept_media_types', 'accept_presentation_document_targets' ] LAUNCH_PARAMS_CANVAS = [ 'selection_directive', 'text' ] CONTENT_PARAMS_REQUEST = [ 'accept_media_types', 'accept_presentation_document_targets', 'content_item_return_url', 'accept_unsigned', 'accept_multiple', 'accept_copy_advice', 'auto_create', 'title', 'data', 'can_confirm' ] CONTENT_PARAMS_RESPONSE = [ 'content_items', 'lti_msg', 'lti_log', 'lti_errormsg', 'lti_errorlog' ] CONTENT_PARAMS_REQUIRED = [ 'lti_message_type', 'lti_version' ] REGISTRATION_PARAMS = [ 'tc_profile_url', 'reg_password', 'reg_key' ] LAUNCH_PARAMS = ( LAUNCH_PARAMS_REQUIRED + LAUNCH_PARAMS_RECOMMENDED + LAUNCH_PARAMS_RETURN_URL + LAUNCH_PARAMS_OAUTH + LAUNCH_PARAMS_LIS + LAUNCH_PARAMS_CANVAS + CONTENT_PARAMS_REQUEST + CONTENT_PARAMS_RESPONSE + REGISTRATION_PARAMS ) def valid_param(param): if param.startswith('custom_') or param.startswith('ext_'): return True elif param in LAUNCH_PARAMS: return True return False class InvalidLaunchParamError(ValueError): def __init__(self, param): message = "{} is not a valid launch param".format(param) super(Exception, self).__init__(message) class LaunchParams(MutableMapping): """ Represents the params for an LTI launch request. Provides dict-like behavior through the use of the MutableMapping ABC mixin. Strictly enforces that params are valid LTI params. """ def __init__(self, *args, **kwargs): self._params = dict() self.update(*args, **kwargs) # now verify we only got valid launch params for k in self.keys(): if not valid_param(k): raise InvalidLaunchParamError(k) # enforce some defaults if 'lti_version' not in self: self['lti_version'] = DEFAULT_LTI_VERSION if 'lti_message_type' not in self: self['lti_message_type'] = 'basic-lti-launch-request' def set_non_spec_param(self, param, val): self._params[param] = val def get_non_spec_param(self, param): return self._params.get(param) def _param_value(self, param): if param in LAUNCH_PARAMS_IS_LIST: return [x.strip() for x in self._params[param].split(',')] else: return self._params[param] def __len__(self): return len(self._params) def __getitem__(self, item): if not valid_param(item): raise KeyError("{} is not a valid launch param".format(item)) try: return self._param_value(item) except KeyError: # catch and raise new KeyError in the proper context raise KeyError(item) def __setitem__(self, key, value): if not valid_param(key): raise InvalidLaunchParamError(key) if key in LAUNCH_PARAMS_IS_LIST: if isinstance(value, list): value = ','.join([x.strip() for x in value]) self._params[key] = value def __delitem__(self, key): if key in self._params: del self._params[key] def __iter__(self): return iter(self._params) lti-0.9.2/src/lti/outcome_request.py 0000664 0000000 0000000 00000020544 13120512510 0017461 0 ustar 00root root 0000000 0000000 from collections import defaultdict from lxml import etree, objectify import requests from requests_oauthlib import OAuth1 from requests_oauthlib.oauth1_auth import SIGNATURE_TYPE_AUTH_HEADER from .outcome_response import OutcomeResponse from .utils import InvalidLTIConfigError REPLACE_REQUEST = 'replaceResult' DELETE_REQUEST = 'deleteResult' READ_REQUEST = 'readResult' VALID_ATTRIBUTES = [ 'operation', 'score', 'result_data', 'outcome_response', 'message_identifier', 'lis_outcome_service_url', 'lis_result_sourcedid', 'consumer_key', 'consumer_secret', 'post_request' ] class OutcomeRequest(object): ''' Class for consuming & generating LTI Outcome Requests. Outcome Request documentation: http://www.imsglobal.org/LTI/v1p1/ltiIMGv1p1.html#_Toc319560472 This class can be used both by Tool Providers and Tool Consumers, though they each use it differently. The TP will use it to POST an OAuth-signed request to the TC. A TC will use it to parse such a request from a TP. ''' def __init__(self, opts=defaultdict(lambda: None)): # Initialize all our accessors to None for attr in VALID_ATTRIBUTES: setattr(self, attr, None) # Store specified options in our accessors for (key, val) in opts.items(): if key in VALID_ATTRIBUTES: setattr(self, key, val) else: raise InvalidLTIConfigError( "Invalid outcome request option: {}".format(key) ) @staticmethod def from_post_request(post_request): ''' Convenience method for creating a new OutcomeRequest from a request object. post_request is assumed to be a Django HttpRequest object ''' request = OutcomeRequest() request.post_request = post_request request.process_xml(post_request.body) return request def post_replace_result(self, score, result_data=None): ''' POSTs the given score to the Tool Consumer with a replaceResult. OPTIONAL: result_data must be a dictionary Note: ONLY ONE of these values can be in the dict at a time, due to the Canvas specification. 'text' : str text 'url' : str url ''' self.operation = REPLACE_REQUEST self.score = score self.result_data = result_data if result_data is not None: if len(result_data) > 1: error_msg = ('Dictionary result_data can only have one entry. ' '{0} entries were found.'.format(len(result_data))) raise InvalidLTIConfigError(error_msg) elif 'text' not in result_data and 'url' not in result_data: error_msg = ('Dictionary result_data can only have the key ' '"text" or the key "url".') raise InvalidLTIConfigError(error_msg) else: return self.post_outcome_request() else: return self.post_outcome_request() def post_delete_result(self): ''' POSTs a deleteRequest to the Tool Consumer. ''' self.operation = DELETE_REQUEST return self.post_outcome_request() def post_read_result(self): ''' POSTS a readResult to the Tool Consumer. ''' self.operation = READ_REQUEST return self.post_outcome_request() def is_replace_request(self): ''' Check whether this request is a replaceResult request. ''' return self.operation == REPLACE_REQUEST def is_delete_request(self): ''' Check whether this request is a deleteResult request. ''' return self.operation == DELETE_REQUEST def is_read_request(self): ''' Check whether this request is a readResult request. ''' return self.operation == READ_REQUEST def was_outcome_post_successful(self): return self.outcome_response and self.outcome_response.is_success() def post_outcome_request(self, **kwargs): ''' POST an OAuth signed request to the Tool Consumer. ''' if not self.has_required_attributes(): raise InvalidLTIConfigError( 'OutcomeRequest does not have all required attributes') header_oauth = OAuth1(self.consumer_key, self.consumer_secret, signature_type=SIGNATURE_TYPE_AUTH_HEADER, force_include_body=True, **kwargs) headers = {'Content-type': 'application/xml'} resp = requests.post(self.lis_outcome_service_url, auth=header_oauth, data=self.generate_request_xml(), headers=headers) outcome_resp = OutcomeResponse.from_post_response(resp, resp.content) self.outcome_response = outcome_resp return self.outcome_response def process_xml(self, xml): ''' Parse Outcome Request data from XML. ''' root = objectify.fromstring(xml) self.message_identifier = str( root.imsx_POXHeader.imsx_POXRequestHeaderInfo. imsx_messageIdentifier) try: result = root.imsx_POXBody.replaceResultRequest self.operation = REPLACE_REQUEST # Get result sourced id from resultRecord self.lis_result_sourcedid = result.resultRecord.\ sourcedGUID.sourcedId self.score = str(result.resultRecord.result. resultScore.textString) except: pass try: result = root.imsx_POXBody.deleteResultRequest self.operation = DELETE_REQUEST # Get result sourced id from resultRecord self.lis_result_sourcedid = result.resultRecord.\ sourcedGUID.sourcedId except: pass try: result = root.imsx_POXBody.readResultRequest self.operation = READ_REQUEST # Get result sourced id from resultRecord self.lis_result_sourcedid = result.resultRecord.\ sourcedGUID.sourcedId except: pass def has_required_attributes(self): return self.consumer_key is not None\ and self.consumer_secret is not None\ and self.lis_outcome_service_url is not None\ and self.lis_result_sourcedid is not None\ and self.operation is not None def generate_request_xml(self): root = etree.Element( 'imsx_POXEnvelopeRequest', xmlns='http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0') header = etree.SubElement(root, 'imsx_POXHeader') header_info = etree.SubElement(header, 'imsx_POXRequestHeaderInfo') version = etree.SubElement(header_info, 'imsx_version') version.text = 'V1.0' message_identifier = etree.SubElement(header_info, 'imsx_messageIdentifier') message_identifier.text = self.message_identifier body = etree.SubElement(root, 'imsx_POXBody') request = etree.SubElement(body, '%s%s' % (self.operation, 'Request')) record = etree.SubElement(request, 'resultRecord') guid = etree.SubElement(record, 'sourcedGUID') sourcedid = etree.SubElement(guid, 'sourcedId') sourcedid.text = self.lis_result_sourcedid if self.score is not None: result = etree.SubElement(record, 'result') result_score = etree.SubElement(result, 'resultScore') language = etree.SubElement(result_score, 'language') language.text = 'en' text_string = etree.SubElement(result_score, 'textString') text_string.text = self.score.__str__() if self.result_data: resultData = etree.SubElement(result, 'resultData') if 'text' in self.result_data: resultDataText = etree.SubElement(resultData, 'text') resultDataText.text = self.result_data['text'] elif 'url' in self.result_data: resultDataURL = etree.SubElement(resultData, 'url') resultDataURL.text = self.result_data['url'] return etree.tostring(root, xml_declaration=True, encoding='utf-8') lti-0.9.2/src/lti/outcome_response.py 0000664 0000000 0000000 00000013026 13120512510 0017624 0 ustar 00root root 0000000 0000000 from lxml import etree, objectify from .utils import InvalidLTIConfigError CODE_MAJOR_CODES = [ 'success', 'processing', 'failure', 'unsupported' ] SEVERITY_CODES = [ 'status', 'warning', 'error' ] VALID_ATTRIBUTES = [ 'request_type', 'score', 'message_identifier', 'response_code', 'post_response', 'code_major', 'severity', 'description', 'operation', 'message_ref_identifier' ] class OutcomeResponse(object): ''' This class consumes & generates LTI Outcome Responses. Response documentation: http://www.imsglobal.org/LTI/v1p1/ltiIMGv1p1.html#_Toc319560472 Error code documentation: http://www.imsglobal.org/gws/gwsv1p0/imsgws_baseProfv1p0.html#1639667 This class can be used by both Tool Providers and Tool Consumers, though each will use it differently. TPs will use it to partse the result of an OutcomeRequest to the TC. A TC will use it to generate proper response XML to send back to a TP. ''' def __init__(self, **kwargs): # Initialize all class accessors to None for attr in VALID_ATTRIBUTES: setattr(self, attr, None) # Store specified options in our options member for (key, val) in kwargs.items(): if key in VALID_ATTRIBUTES: setattr(self, key, val) else: raise InvalidLTIConfigError( "Invalid outcome response option: {}".format(key) ) @staticmethod def from_post_response(post_response, content): ''' Convenience method for creating a new OutcomeResponse from a response object. ''' response = OutcomeResponse() response.post_response = post_response response.response_code = post_response.status_code response.process_xml(content) return response def is_success(self): return self.code_major == 'success' def is_processing(self): return self.code_major == 'processing' def is_failure(self): return self.code_major == 'failure' def is_unsupported(self): return self.code_major == 'unsupported' def has_warning(self): return self.severity == 'warning' def has_error(self): return self.severity == 'error' def process_xml(self, xml): ''' Parse OutcomeResponse data form XML. ''' try: root = objectify.fromstring(xml) # Get message idenifier from header info self.message_identifier = root.imsx_POXHeader.\ imsx_POXResponseHeaderInfo.\ imsx_messageIdentifier status_node = root.imsx_POXHeader.\ imsx_POXResponseHeaderInfo.\ imsx_statusInfo # Get status parameters from header info status self.code_major = status_node.imsx_codeMajor self.severity = status_node.imsx_severity self.description = status_node.imsx_description self.message_ref_identifier = str( status_node.imsx_messageRefIdentifier) self.operation = status_node.imsx_operationRefIdentifier try: # Try to get the score self.score = str(root.imsx_POXBody.readResultResponse. result.resultScore.textString) except AttributeError: # Not a readResult, just ignore! pass except: pass def generate_response_xml(self): ''' Generate XML based on the current configuration. ''' root = etree.Element( 'imsx_POXEnvelopeResponse', xmlns='http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0') header = etree.SubElement(root, 'imsx_POXHeader') header_info = etree.SubElement(header, 'imsx_POXResponseHeaderInfo') version = etree.SubElement(header_info, 'imsx_version') version.text = 'V1.0' message_identifier = etree.SubElement(header_info, 'imsx_messageIdentifier') message_identifier.text = str(self.message_identifier) status_info = etree.SubElement(header_info, 'imsx_statusInfo') code_major = etree.SubElement(status_info, 'imsx_codeMajor') code_major.text = str(self.code_major) severity = etree.SubElement(status_info, 'imsx_severity') severity.text = str(self.severity) description = etree.SubElement(status_info, 'imsx_description') description.text = str(self.description) message_ref_identifier = etree.SubElement( status_info, 'imsx_messageRefIdentifier') message_ref_identifier.text = str(self.message_ref_identifier) operation_ref_identifier = etree.SubElement( status_info, 'imsx_operationRefIdentifier') operation_ref_identifier.text = str(self.operation) body = etree.SubElement(root, 'imsx_POXBody') response = etree.SubElement(body, '%s%s' % (self.operation, 'Response')) if self.score: result = etree.SubElement(response, 'result') result_score = etree.SubElement(result, 'resultScore') language = etree.SubElement(result_score, 'language') language.text = 'en' text_string = etree.SubElement(result_score, 'textString') text_string.text = str(self.score) return etree.tostring(root, xml_declaration=True) lti-0.9.2/src/lti/tool_base.py 0000664 0000000 0000000 00000004554 13120512510 0016210 0 ustar 00root root 0000000 0000000 from .launch_params import LaunchParams, valid_param ROLES_STUDENT = ['student', 'learner'] ROLES_INSTRUCTOR = ['instructor', 'faculty', 'staff'] class ToolBase(object): def __init__(self, consumer_key=None, consumer_secret=None, params=None): self.consumer_key = consumer_key self.consumer_secret = consumer_secret if params is None: params = {} if isinstance(params, LaunchParams): self.launch_params = params else: self.launch_params = LaunchParams(params) def __getattr__(self, attr): if not valid_param(attr): raise AttributeError( "{} is not a valid launch param attribute".format(attr)) try: return self.launch_params[attr] except KeyError: return None def __setattr__(self, key, value): if valid_param(key): self.launch_params[key] = value else: self.__dict__[key] = value def has_role(self, role): return self.launch_params.get('roles') and any( x.lower() == role.lower() for x in self.launch_params['roles']) def is_student(self): return any(self.has_role(x) for x in ROLES_STUDENT) def is_instructor(self): return any(self.has_role(x) for x in ROLES_INSTRUCTOR) def is_launch_request(self): msg_type = self.launch_params.get('lti_message_type') return msg_type == 'basic-lti-launch-request' def is_content_request(self): msg_type = self.launch_params.get('lti_message_type') return msg_type == 'ContentItemSelectionRequest' def set_custom_param(self, key, val): setattr(self, 'custom_' + key, val) def get_custom_param(self, key): return getattr(self, 'custom_' + key) def set_non_spec_param(self, key, val): self.launch_params.set_non_spec_param(key, val) def get_non_spec_param(self, key): return self.launch_params.get_non_spec_param(key) def set_ext_param(self, key, val): setattr(self, 'ext_' + key, val) def get_ext_param(self, key): return getattr(self, 'ext_' + key) def to_params(self): params = dict(self.launch_params) # stringify any list values for k, v in params.items(): if isinstance(v, list): params[k] = ','.join(v) return params lti-0.9.2/src/lti/tool_config.py 0000664 0000000 0000000 00000025406 13120512510 0016542 0 ustar 00root root 0000000 0000000 from collections import defaultdict from lxml import etree, objectify from .utils import InvalidLTIConfigError VALID_ATTRIBUTES = [ 'title', 'description', 'launch_url', 'secure_launch_url', 'icon', 'secure_icon', 'cartridge_bundle', 'cartridge_icon', 'vendor_code', 'vendor_name', 'vendor_description', 'vendor_url', 'vendor_contact_email', 'vendor_contact_name' ] NSMAP = { 'blti': 'http://www.imsglobal.org/xsd/imsbasiclti_v1p0', 'xsi': "http://www.w3.org/2001/XMLSchema-instance", 'lticp': 'http://www.imsglobal.org/xsd/imslticp_v1p0', 'lticm': 'http://www.imsglobal.org/xsd/imslticm_v1p0', } class ToolConfig(object): ''' Object used to represent LTI configuration. Capable of creating and reading the Common Cartridge XML representation of LTI links as described here: http://www.imsglobal.org/LTI/v1p1/ltiIMGv1p1.html#_Toc319560470 TODO: Usage description ''' def __init__(self, **kwargs): ''' Create a new ToolConfig with the given options. ''' # Initialize all class accessors to None for attr in VALID_ATTRIBUTES: setattr(self, attr, None) for attr in ['custom_params', 'extensions']: if attr in kwargs: attr_val = kwargs.pop(attr) else: attr_val = defaultdict(lambda: None) setattr(self, attr, attr_val) # Iterate over all provided options and save to class instance members for (key, val) in kwargs.items(): if key in VALID_ATTRIBUTES: setattr(self, key, val) else: raise InvalidLTIConfigError( "Invalid outcome request option: {}".format(key) ) @staticmethod def create_from_xml(xml): ''' Create a ToolConfig from the given XML. ''' config = ToolConfig() config.process_xml(xml) return config def set_custom_param(self, key, val): ''' Set a custom parameter to provided value. ''' self.custom_params[key] = val def get_custom_param(self, key): ''' Gets a custom parameter. It not yet set, returns None object. ''' return self.custom_params[key] def set_ext_params(self, ext_key, ext_params): ''' Set the extension parameters for a specific vendor. ''' self.extensions[ext_key] = ext_params def get_ext_params(self, ext_key): ''' Get extension paramaters for provided extension. It not set, returns None object. ''' return self.extensions[ext_key] def set_ext_param(self, ext_key, param_key, val): ''' Set the provided parameter in a set of extension parameters. ''' self.extensions.setdefault(ext_key, defaultdict(lambda: None)) self.extensions[ext_key][param_key] = val def get_ext_param(self, ext_key, param_key): ''' Get specific param in set of provided extension parameters. ''' if ext_key in self.extensions: return self.extensions[ext_key].get(param_key) def process_xml(self, xml): ''' Parse tool configuration data out of the Common Cartridge LTI link XML. ''' root = objectify.fromstring(xml, parser=etree.XMLParser()) # Parse all children of the root node for child in root.getchildren(): if 'title' in child.tag: self.title = child.text if 'description' in child.tag: self.description = child.text if 'secure_launch_url' in child.tag: self.secure_launch_url = child.text elif 'launch_url' in child.tag: self.launch_url = child.text if 'icon' in child.tag: self.icon = child.text if 'secure_icon' in child.tag: self.secure_icon = child.text if 'cartridge_bundle' in child.tag: self.cartridge_bundle = child.attrib['identifierref'] if 'catridge_icon' in child.tag: self.cartridge_icon = child.atrib['identifierref'] if 'vendor' in child.tag: # Parse vendor tag for v_child in child.getchildren(): if 'code' in v_child.tag: self.vendor_code = v_child.text if 'description' in v_child.tag: self.vendor_description = v_child.text if 'name' in v_child.tag: self.vendor_name = v_child.text if 'url' in v_child.tag: self.vendor_url = v_child.text if 'contact' in v_child.tag: # Parse contact tag for email and name for c_child in v_child: if 'name' in c_child.tag: self.vendor_contact_name = c_child.text if 'email' in c_child.tag: self.vendor_contact_email = c_child.text if 'custom' in child.tag: # Parse custom tags for custom_child in child.getchildren(): self.custom_params[custom_child.attrib['name']] =\ custom_child.text if 'extensions' in child.tag: platform = child.attrib['platform'] properties = {} # Parse extension tags for ext_child in child.getchildren(): if 'property' in ext_child.tag: properties[ext_child.attrib['name']] = ext_child.text elif 'options' in ext_child.tag: opt_name = ext_child.attrib['name'] options = {} for option_child in ext_child.getchildren(): options[option_child.attrib['name']] =\ option_child.text properties[opt_name] = options self.set_ext_params(platform, properties) def recursive_options(self, element, params): for key, val in sorted(params.items()): if isinstance(val, dict): options_node = etree.SubElement( element, '{%s}%s' % (NSMAP['lticm'], 'options'), name=key, ) for key, val in sorted(val.items()): self.recursive_options(options_node, {key: val}) else: param_node = etree.SubElement( element, '{%s}%s' % (NSMAP['lticm'], 'property'), name=key, ) param_node.text = val def to_xml(self, opts=defaultdict(lambda: None)): ''' Generate XML from the current settings. ''' if not self.launch_url or not self.secure_launch_url: raise InvalidLTIConfigError('Invalid LTI configuration') root = etree.Element( 'cartridge_basiclti_link', attrib={ '{%s}%s' % (NSMAP['xsi'], 'schemaLocation'): 'http://www.imsglobal.org/xsd/imslticc_v1p0 http://www.imsglobal.org/xsd/lti/ltiv1p0/imslticc_v1p0.xsd http://www.imsglobal.org/xsd/imsbasiclti_v1p0 http://www.imsglobal.org/xsd/lti/ltiv1p0/imsbasiclti_v1p0p1.xsd http://www.imsglobal.org/xsd/imslticm_v1p0 http://www.imsglobal.org/xsd/lti/ltiv1p0/imslticm_v1p0.xsd http://www.imsglobal.org/xsd/imslticp_v1p0 http://www.imsglobal.org/xsd/lti/ltiv1p0/imslticp_v1p0.xsd', 'xmlns': 'http://www.imsglobal.org/xsd/imslticc_v1p0', }, nsmap=NSMAP, ) for key in ['title', 'description', 'launch_url', 'secure_launch_url']: option = etree.SubElement(root, '{%s}%s' % (NSMAP['blti'], key)) option.text = getattr(self, key) vendor_keys = ['name', 'code', 'description', 'url'] if any('vendor_' + key for key in vendor_keys) or\ self.vendor_contact_email: vendor_node = etree.SubElement( root, '{%s}%s' % (NSMAP['blti'], 'vendor'), ) for key in vendor_keys: if getattr(self, 'vendor_' + key) is not None: v_node = etree.SubElement( vendor_node, '{%s}%s' % (NSMAP['lticp'], key), ) v_node.text = getattr(self, 'vendor_' + key) if getattr(self, 'vendor_contact_email'): v_node = etree.SubElement( vendor_node, '{%s}%s' % (NSMAP['lticp'], 'contact'), ) c_name = etree.SubElement( v_node, '{%s}%s' % (NSMAP['lticp'], 'name'), ) c_name.text = self.vendor_contact_name c_email = etree.SubElement( v_node, '{%s}%s' % (NSMAP['lticp'], 'email'), ) c_email.text = self.vendor_contact_email # Custom params if len(self.custom_params) != 0: custom_node = etree.SubElement( root, '{%s}%s' % (NSMAP['blti'], 'custom'), ) for (key, val) in sorted(self.custom_params.items()): c_node = etree.SubElement( custom_node, '{%s}%s' % (NSMAP['lticm'], 'property'), ) c_node.set('name', key) c_node.text = val # Extension params if len(self.extensions) != 0: for (key, params) in sorted(self.extensions.items()): extension_node = etree.SubElement( root, '{%s}%s' % (NSMAP['blti'], 'extensions'), platform=key, ) self.recursive_options(extension_node, params) if getattr(self, 'cartridge_bundle'): identifierref = etree.SubElement( root, 'cartridge_bundle', identifierref=self.cartridge_bundle, ) if getattr(self, 'cartridge_icon'): identifierref = etree.SubElement( root, 'cartridge_icon', identifierref=self.cartridge_icon, ) declaration = b'' return declaration + etree.tostring(root, encoding='utf-8') lti-0.9.2/src/lti/tool_consumer.py 0000664 0000000 0000000 00000000777 13120512510 0017134 0 ustar 00root root 0000000 0000000 from .launch_params import LAUNCH_PARAMS_REQUIRED from .tool_outbound import ToolOutbound class ToolConsumer(ToolOutbound): def has_required_params(self): return all([ self.launch_params.get(x) for x in LAUNCH_PARAMS_REQUIRED ]) def set_config(self, config): ''' Set launch data from a ToolConfig. ''' if self.launch_url is None: self.launch_url = config.launch_url self.launch_params.update(config.custom_params) lti-0.9.2/src/lti/tool_outbound.py 0000664 0000000 0000000 00000003644 13120512510 0017134 0 ustar 00root root 0000000 0000000 from requests import Request from requests_oauthlib import OAuth1 from requests_oauthlib.oauth1_auth import SIGNATURE_TYPE_BODY from .tool_base import ToolBase from .launch_params import LAUNCH_PARAMS_REQUIRED from .utils import parse_qs, InvalidLTIConfigError class ToolOutbound(ToolBase): def __init__(self, consumer_key, consumer_secret, params=None, launch_url=None): ''' Create new Outbound Tool. See ToolConsumer and ContentItemResponse for examples ''' # allow launch_url to be specified in launch_params for # backwards compatibility if launch_url is None: if 'launch_url' not in params: raise InvalidLTIConfigError('missing \'launch_url\' arg!') else: launch_url = params['launch_url'] del params['launch_url'] self.launch_url = launch_url super(ToolOutbound, self).__init__(consumer_key, consumer_secret, params=params) def has_required_params(self): return True def generate_launch_request(self, **kwargs): """ returns a Oauth v1 "signed" requests.PreparedRequest instance """ if not self.has_required_params(): raise InvalidLTIConfigError( 'Consumer\'s launch params missing one of ' + str(LAUNCH_PARAMS_REQUIRED) ) params = self.to_params() r = Request('POST', self.launch_url, data=params).prepare() sign = OAuth1(self.consumer_key, self.consumer_secret, signature_type=SIGNATURE_TYPE_BODY, **kwargs) return sign(r) def generate_launch_data(self, **kwargs): """ Provided for backwards compatibility """ r = self.generate_launch_request(**kwargs) return parse_qs(r.body.decode('utf-8'), keep_blank_values=True) lti-0.9.2/src/lti/tool_provider.py 0000664 0000000 0000000 00000014357 13120512510 0017132 0 ustar 00root root 0000000 0000000 from .utils import InvalidLTIRequestError from .launch_params import LaunchParams from .tool_base import ToolBase from oauthlib.oauth1 import SignatureOnlyEndpoint from oauthlib.oauth1.rfc5849 import CONTENT_TYPE_FORM_URLENCODED from requests.structures import CaseInsensitiveDict from .outcome_request import OutcomeRequest from collections import defaultdict try: from urllib.parse import urlencode, urlsplit, urlunsplit, parse_qsl except ImportError: # Python 2 from urllib import urlencode from urlparse import urlsplit, urlunsplit, parse_qsl class ToolProvider(ToolBase): ''' Implements the LTI Tool Provider. ''' @classmethod def from_unpacked_request(cls, secret, params, url, headers): launch_params = LaunchParams(params) if 'oauth_consumer_key' not in launch_params: raise InvalidLTIRequestError("oauth_consumer_key not found!") return cls(consumer_key=launch_params['oauth_consumer_key'], consumer_secret=secret, params=launch_params, launch_url=url, launch_headers=headers) def __init__(self, consumer_key=None, consumer_secret=None, params=None, launch_url=None, launch_headers=None): super(ToolProvider, self).__init__(consumer_key, consumer_secret, params=params) self.outcome_requests = [] self._last_outcome_request = None self.launch_url = launch_url self.launch_headers = launch_headers or CaseInsensitiveDict() if 'Content-Type' not in self.launch_headers: self.launch_headers['Content-Type'] = CONTENT_TYPE_FORM_URLENCODED def is_valid_request(self, validator): validator = ProxyValidator(validator) endpoint = SignatureOnlyEndpoint(validator) valid, request = endpoint.validate_request( self.launch_url, 'POST', self.to_params(), self.launch_headers ) if valid and not self.consumer_key and not self.consumer_secret: # Gather the key and secret self.consumer_key = self.launch_params['oauth_consumer_key'] self.consumer_secret = validator.secret return valid def is_outcome_service(self): ''' Check if the Tool Launch expects an Outcome Result. ''' return self.launch_params.get('lis_outcome_service_url') and \ self.launch_params.get('lis_result_sourcedid') def username(self, default=None): ''' Return the full, given, or family name if set. ''' for item in ['lis_person_name_given', 'lis_person_name_family', 'lis_person_name_full']: if item in self.launch_params: return self.launch_params[item] return default def build_return_url(self): ''' If the Tool Consumer sent a return URL, add any set messages to the URL. ''' if not self.launch_presentation_return_url: return None lti_message_fields = ['lti_errormsg', 'lti_errorlog', 'lti_msg', 'lti_log'] messages = dict([(key, getattr(self, key)) for key in lti_message_fields if getattr(self, key, None)]) # Disassemble original return URL and reassemble with our options added original = urlsplit(self.launch_presentation_return_url) combined = messages.copy() combined.update(dict(parse_qsl(original.query))) combined_query = urlencode(combined) return urlunsplit(( original.scheme, original.netloc, original.path, combined_query, original.fragment )) def post_replace_result(self, score, outcome_opts=defaultdict(lambda: None), result_data=None): ''' POSTs the given score to the Tool Consumer with a replaceResult. Returns OutcomeResponse object and stores it in self.outcome_request OPTIONAL: result_data must be a dictionary Note: ONLY ONE of these values can be in the dict at a time, due to the Canvas specification. 'text' : str text 'url' : str url ''' return self.new_request(outcome_opts).post_replace_result(score, result_data) def post_delete_result(self, outcome_opts=defaultdict(lambda: None)): ''' POSTs a delete request to the Tool Consumer. ''' return self.new_request(outcome_opts).post_delete_result() def post_read_result(self, outcome_opts=defaultdict(lambda: None)): ''' POSTs the given score to the Tool Consumer with a replaceResult, the returned OutcomeResponse will have the score. ''' return self.new_request(outcome_opts).post_read_result() def last_outcome_request(self): ''' Returns the most recent OutcomeRequest. ''' return self.outcome_requests[-1] def last_outcome_success(self): ''' Convenience method for determining the success of the last OutcomeRequest. ''' return all((self._last_outcome_request, self._last_outcome_request.was_outcome_post_successful())) def new_request(self, defaults): opts = dict(defaults) opts.update({ 'consumer_key': self.consumer_key, 'consumer_secret': self.consumer_secret, 'lis_outcome_service_url': self.lis_outcome_service_url, 'lis_result_sourcedid': self.lis_result_sourcedid }) self.outcome_requests.append(OutcomeRequest(opts=opts)) self._last_outcome_request = self.outcome_requests[-1] return self._last_outcome_request class ProxyValidator(object): ''' Proxies a RequestValidator to save the client secret. ''' def __init__(self, validator): self._validator = validator def __getattr__(self, name): value = getattr(self._validator, name) if name == 'get_client_secret': def save_secret(*args, **kwargs): self.secret = value(*args, **kwargs) return self.secret return save_secret return value lti-0.9.2/src/lti/tool_proxy.py 0000664 0000000 0000000 00000002306 13120512510 0016450 0 ustar 00root root 0000000 0000000 from requests import Request from .tool_base import ToolBase import requests import json from requests_oauthlib import OAuth1 from requests_oauthlib.oauth1_auth import SIGNATURE_TYPE_AUTH_HEADER class ToolProxy(ToolBase): def load_tc_profile(self): response = requests.get(self.tool_consumer_profile_url) self.tc_profile = json.loads(response.text) @property def tool_consumer_profile_url(self): return self.launch_params['tc_profile_url'] def find_registration_url(self): for service in self.tc_profile["service_offered"]: if "application/vnd.ims.lti.v2.toolproxy+json" in service["format"] and "POST" in service["action"]: return service["endpoint"] def register_proxy(self, tool_profile): register_url = self.find_registration_url() r = Request("POST", register_url, data=json.dumps(tool_profile, indent=4), headers={'Content-Type':'application/vnd.ims.lti.v2.toolproxy+json'}).prepare() sign = OAuth1(self.launch_params['reg_key'], self.launch_params['reg_password'], signature_type=SIGNATURE_TYPE_AUTH_HEADER, force_include_body=True) signed = sign(r) return signed lti-0.9.2/src/lti/utils.py 0000664 0000000 0000000 00000001266 13120512510 0015376 0 ustar 00root root 0000000 0000000 from uuid import uuid1 try: import urllib.parse as urlparse except ImportError: import urlparse # Python 2 def parse_qs(qs, keep_blank_values=False): params = urlparse.parse_qs( qs, keep_blank_values=int(keep_blank_values)).items() return dict((k, v if len(v) > 1 else v[0]) for k, v in params) def generate_identifier(): return uuid1().__str__() class InvalidLTIConfigError(Exception): def __init__(self, value): self.value = value def __str__(self): return repr(self.value) class InvalidLTIRequestError(Exception): def __init__(self, value): self.value = value def __str__(self): return repr(self.value) lti-0.9.2/tests/ 0000775 0000000 0000000 00000000000 13120512510 0013442 5 ustar 00root root 0000000 0000000 lti-0.9.2/tests/test_contentitem_response.py 0000664 0000000 0000000 00000012166 13120512510 0021330 0 ustar 00root root 0000000 0000000 from lti import ContentItemResponse, LaunchParams from lti.utils import parse_qs, InvalidLTIConfigError import unittest from oauthlib.common import generate_client_id, generate_token, unquote from requests import PreparedRequest class TestContentItemResponse(unittest.TestCase): def setUp(self): pass def test_constructor(self): client_id = generate_client_id() client_secret = generate_token() tc = ContentItemResponse(client_id, client_secret, launch_url='http://example.edu') self.assertIsInstance(tc.launch_params, LaunchParams) lp = LaunchParams() tc = ContentItemResponse(client_id, client_secret, launch_url='http://example.edu', params=lp) self.assertEqual(tc.launch_params, lp) lp_dict = {'resource_link_id': 1} tc = ContentItemResponse(client_id, client_secret, launch_url='http://example.edu', params=lp_dict) self.assertIsInstance(tc.launch_params, LaunchParams) self.assertEqual(tc.launch_params._params.get('resource_link_id'), 1) # no launch_url should raise exception self.failUnlessRaises(InvalidLTIConfigError, ContentItemResponse, client_id, client_secret, params=lp_dict) # but confirm that 'launch_url' can still be passed in params # (backwards compatibility) lp_dict['launch_url'] = 'http://example.edu' tc = ContentItemResponse(client_id, client_secret, params=lp_dict) self.assertEqual(tc.launch_url, 'http://example.edu') def test_has_required_params(self): client_id = generate_client_id() client_secret = generate_token() tc = ContentItemResponse(client_id, client_secret, launch_url='http://example.edu') #Can't assert false for has_required_params as the only required params are lti_version and lti_message_type #However should consider checking the message type in the future tc.launch_params['lti_version'] = 'LTI-1p0' tc.launch_params['lti_message_type'] = 'ContentItemSelection' self.assertTrue(tc.has_required_params()) def test_generate_launch_request(self): launch_params = { 'lti_version': 'foo', 'lti_message_type': 'bar', 'resource_link_id': 'baz' } tc = ContentItemResponse('client_key', 'client_secret', launch_url='http://example.edu/', params=launch_params) launch_req = tc.generate_launch_request(nonce='abcd1234', timestamp='1234567890') self.assertIsInstance(launch_req, PreparedRequest) got = parse_qs(unquote(launch_req.body.decode('utf-8'))) correct = launch_params.copy() correct.update({ 'oauth_nonce': 'abcd1234', 'oauth_timestamp': '1234567890', 'oauth_version': '1.0', 'oauth_signature_method': 'HMAC-SHA1', 'oauth_consumer_key': 'client_key', 'oauth_signature': 'u2xlj 1gF4y 6gKHNeiL9cN3tOI=', }) self.assertEqual(got, correct) def test_launch_request_with_qs(self): """ test that qs params in launch url are ok """ launch_params = { 'lti_version': 'abc', 'lti_message_type': 'def', 'resource_link_id': '123' } tc = ContentItemResponse('client_key', 'client_secret', launch_url='http://example.edu/foo?bar=1', params=launch_params) launch_req = tc.generate_launch_request(nonce='wxyz7890', timestamp='2345678901') got = parse_qs(unquote(launch_req.body.decode('utf-8'))) correct = launch_params.copy() correct.update({ 'oauth_nonce': 'wxyz7890', 'oauth_timestamp': '2345678901', 'oauth_version': '1.0', 'oauth_signature_method': 'HMAC-SHA1', 'oauth_consumer_key': 'client_key', 'oauth_signature': 'UH2l86Wq/g5Mu64GpCRcec6tEYY=', }) self.assertEqual(got, correct) def test_generate_launch_data(self): launch_params = { 'lti_version': 'abc', 'lti_message_type': 'def', 'resource_link_id': '123' } tc = ContentItemResponse('client_key', 'client_secret', launch_url='http://example.edu/', params=launch_params) got = tc.generate_launch_data(nonce='wxyz7890', timestamp='2345678901') correct = launch_params.copy() correct.update({ 'oauth_nonce': 'wxyz7890', 'oauth_timestamp': '2345678901', 'oauth_version': '1.0', 'oauth_signature_method': 'HMAC-SHA1', 'oauth_consumer_key': 'client_key', 'oauth_signature': 'gXIAk60dLsrh6YQGT5ZGK6tHDGY=', }) self.assertEqual(got, correct) lti-0.9.2/tests/test_launch_params.py 0000664 0000000 0000000 00000004664 13120512510 0017702 0 ustar 00root root 0000000 0000000 import unittest from lti import LaunchParams, DEFAULT_LTI_VERSION, InvalidLTIConfigError from lti.launch_params import InvalidLaunchParamError class TestLaunchParams(unittest.TestCase): def test_constructor(self): lp = LaunchParams() self.assertTrue(lp['lti_version'], DEFAULT_LTI_VERSION) self.assertTrue(lp['lti_message_type'], 'basic-lti-launch-request') lp = LaunchParams({ 'lti_version': 'LTI-foo', 'lti_message_type': 'bar', 'resource_link_id': 123 }) self.assertTrue(lp['resource_link_id'], 123) self.assertTrue(lp['lti_version'], 'LTI-foo') self.failUnlessRaises(InvalidLaunchParamError, LaunchParams, { 'foo': 'bar' }) def test_get_item(self): lp = LaunchParams() self.assertEqual(lp['lti_version'], DEFAULT_LTI_VERSION) with self.assertRaises(KeyError): foo = lp['foo'] def test_set_item(self): lp = LaunchParams() lp['lti_version'] = 'bar' self.assertEqual(lp['lti_version'], 'bar') def test_list_params(self): lp = LaunchParams({'roles': 'foo,bar,baz'}) self.assertEqual(lp['roles'], ['foo','bar','baz']) self.assertEqual(lp._params['roles'], 'foo,bar,baz') lp['roles'] = ['bar','baz'] self.assertEqual(lp['roles'], ['bar','baz']) self.assertEqual(lp._params['roles'], 'bar,baz') lp['roles'] = 'blah, bluh ' self.assertEqual(lp['roles'], ['blah','bluh']) def test_non_spec_params(self): lp = LaunchParams() lp.set_non_spec_param('foo', 'bar') self.assertEqual(lp.get_non_spec_param('foo'), 'bar') self.assertEqual(lp._params['foo'], 'bar') with self.assertRaises(KeyError): lp['foo'] def test_dict_behavior(self): lp = LaunchParams({ 'lti_version': 'foo', 'lti_message_type': 'bar' }) self.assertEqual(len(lp), 2) lp.update({'resource_link_id': 1}) self.assertEqual(len(lp), 3) self.failUnlessRaises(InvalidLaunchParamError, lp.update, { 'foo': 'bar' }) self.assertEqual( set(lp.keys()), {'lti_version', 'lti_message_type', 'resource_link_id'} ) self.assertEqual(dict(lp), { 'lti_version': 'foo', 'lti_message_type': 'bar', 'resource_link_id': 1 }) lti-0.9.2/tests/test_outcome_request.py 0000664 0000000 0000000 00000012256 13120512510 0020304 0 ustar 00root root 0000000 0000000 from lti.outcome_request import REPLACE_REQUEST from lti import OutcomeRequest, OutcomeResponse, InvalidLTIConfigError import unittest from oauthlib.common import unquote from httmock import all_requests, HTTMock from django.conf import settings from django.test import RequestFactory settings.configure() EXPECTED_XML = b'''