pax_global_header00006660000000000000000000000064133435370410014515gustar00rootroot0000000000000052 comment=87d7f9b9935db51a5d585055195ed8b7b84af339 lti-0.9.4/000077500000000000000000000000001334353704100123175ustar00rootroot00000000000000lti-0.9.4/.gitignore000066400000000000000000000003601334353704100143060ustar00rootroot00000000000000*.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 .pytest_cache # Coverage .coverage* # Pycharm .idea lti-0.9.4/.travis.yml000066400000000000000000000021401334353704100144250ustar00rootroot00000000000000sudo: 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.4/AUTHORS.rst000066400000000000000000000012641334353704100142010ustar00rootroot00000000000000This 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.4/HISTORY.rst000066400000000000000000000043601334353704100142150ustar00rootroot000000000000000.9.4 (2018-09-04) ++++++++++++++++++ * Add secure_icon to tool config xml (#62) * Add oauth_token as a valid launch parameter (#68) 0.9.3 (2018-06-20) ++++++++++++++++++ * Check full tagname when parsing xml (#60) * Fix issue with cartridge_icon parsing from xml (#58) * Add icon to tool config xml (#57) * Handling Non-Standard LTI Params (#55) * tool_provider: Fix doc for post_read_result (#53) 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.4/LICENSE.rst000066400000000000000000000070231334353704100141350ustar00rootroot00000000000000This 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.4/README.rst000066400000000000000000000114211334353704100140050ustar00rootroot00000000000000==================================== 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 ) # or you may need some additional LTI parameters lti_tool_config.cartridge_bundle = 'BLTI001_Bundle' lti_tool_config.cartridge_icon = 'BLTI001_Icon' lti_tool_config.icon = 'http://www.example.com/icon.png' 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
{% for key, value in launch_data.items %} {% endfor %}
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.4/setup.cfg000066400000000000000000000002211334353704100141330ustar00rootroot00000000000000[wheel] universal = 1 [coverage:run] branch = True source = lti [coverage:paths] source = src/lti .tox/*/lib/python*/site-packages/lti lti-0.9.4/setup.py000066400000000000000000000015511334353704100140330ustar00rootroot00000000000000"""Set up the lti package.""" from setuptools import setup, find_packages setup( name='lti', version='0.9.4', 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.4/src/000077500000000000000000000000001334353704100131065ustar00rootroot00000000000000lti-0.9.4/src/lti/000077500000000000000000000000001334353704100136765ustar00rootroot00000000000000lti-0.9.4/src/lti/__init__.py000066400000000000000000000007501334353704100160110ustar00rootroot00000000000000DEFAULT_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.4/src/lti/contentitem_response.py000066400000000000000000000004221334353704100205150ustar00rootroot00000000000000 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.4/src/lti/contrib/000077500000000000000000000000001334353704100153365ustar00rootroot00000000000000lti-0.9.4/src/lti/contrib/__init__.py000066400000000000000000000000001334353704100174350ustar00rootroot00000000000000lti-0.9.4/src/lti/contrib/django/000077500000000000000000000000001334353704100166005ustar00rootroot00000000000000lti-0.9.4/src/lti/contrib/django/__init__.py000066400000000000000000000000651334353704100207120ustar00rootroot00000000000000from .django_tool_provider import DjangoToolProvider lti-0.9.4/src/lti/contrib/django/django_tool_provider.py000066400000000000000000000024331334353704100233650ustar00rootroot00000000000000 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.4/src/lti/contrib/flask/000077500000000000000000000000001334353704100164365ustar00rootroot00000000000000lti-0.9.4/src/lti/contrib/flask/__init__.py000066400000000000000000000000631334353704100205460ustar00rootroot00000000000000from .flask_tool_provider import FlaskToolProvider lti-0.9.4/src/lti/contrib/flask/flask_tool_provider.py000066400000000000000000000007271334353704100230650ustar00rootroot00000000000000from 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.4/src/lti/launch_params.py000066400000000000000000000120371334353704100170700ustar00rootroot00000000000000import 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', 'oauth_token' ] 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 self.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 valid_param(self, param): return valid_param(param) def __len__(self): return len(self._params) def __getitem__(self, item): if not self.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 self.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.4/src/lti/outcome_request.py000066400000000000000000000205441334353704100175000ustar00rootroot00000000000000from 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.4/src/lti/outcome_response.py000066400000000000000000000130261334353704100176430ustar00rootroot00000000000000from 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.4/src/lti/tool_base.py000066400000000000000000000045541334353704100162270ustar00rootroot00000000000000 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.4/src/lti/tool_config.py000066400000000000000000000266231334353704100165630ustar00rootroot00000000000000from collections import defaultdict from lxml import etree from lxml import objectify from lxml.etree import QName 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 QName(child).localname == 'title': self.title = child.text if QName(child).localname == 'description': self.description = child.text if QName(child).localname == 'secure_launch_url': self.secure_launch_url = child.text if QName(child).localname == 'launch_url': self.launch_url = child.text if QName(child).localname == 'icon': self.icon = child.text if QName(child).localname == 'secure_icon': self.secure_icon = child.text if QName(child).localname == 'cartridge_bundle': self.cartridge_bundle = child.attrib['identifierref'] if QName(child).localname == 'cartridge_icon': self.cartridge_icon = child.attrib['identifierref'] if QName(child).localname == 'vendor': # Parse vendor tag for v_child in child.getchildren(): if QName(v_child).localname == 'code': self.vendor_code = v_child.text if QName(v_child).localname == 'description': self.vendor_description = v_child.text if QName(v_child).localname == 'name': self.vendor_name = v_child.text if QName(v_child).localname == 'url': self.vendor_url = v_child.text if QName(v_child).localname == 'contact': # Parse contact tag for email and name for c_child in v_child: if QName(c_child).localname == 'name': self.vendor_contact_name = c_child.text if QName(c_child).localname == 'email': self.vendor_contact_email = c_child.text if QName(child).localname == 'custom': # Parse custom tags for custom_child in child.getchildren(): self.custom_params[custom_child.attrib['name']] =\ custom_child.text if QName(child).localname == 'extensions': platform = child.attrib['platform'] properties = {} # Parse extension tags for ext_child in child.getchildren(): if QName(ext_child).localname == 'property': properties[ext_child.attrib['name']] = ext_child.text elif QName(ext_child).localname == 'options': 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) if getattr(self, 'icon'): option = etree.SubElement(root, '{%s}%s' % (NSMAP['blti'], 'icon')) option.text = getattr(self, 'icon') if getattr(self, 'secure_icon'): option = etree.SubElement(root, '{%s}%s' % (NSMAP['blti'], 'secure_icon')) option.text = getattr(self, 'secure_icon') 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.4/src/lti/tool_consumer.py000066400000000000000000000007771334353704100171530ustar00rootroot00000000000000 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.4/src/lti/tool_outbound.py000066400000000000000000000036441334353704100171530ustar00rootroot00000000000000 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.4/src/lti/tool_provider.py000066400000000000000000000144361334353704100171470ustar00rootroot00000000000000from .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. ''' launch_params_class = LaunchParams @classmethod def from_unpacked_request(cls, secret, params, url, headers): launch_params = cls.launch_params_class(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 readResult. 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.4/src/lti/tool_proxy.py000066400000000000000000000023061334353704100164670ustar00rootroot00000000000000from 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.4/src/lti/utils.py000066400000000000000000000012661334353704100154150ustar00rootroot00000000000000from 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.4/tests/000077500000000000000000000000001334353704100134615ustar00rootroot00000000000000lti-0.9.4/tests/test_contentitem_response.py000066400000000000000000000121661334353704100213470ustar00rootroot00000000000000from 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.4/tests/test_launch_params.py000066400000000000000000000046641334353704100177210ustar00rootroot00000000000000import 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.4/tests/test_outcome_request.py000066400000000000000000000122561334353704100203230ustar00rootroot00000000000000from 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''' V1.0 123456789 %s ''' REPLACE_RESULT_XML = EXPECTED_XML[:] % b''' 261-154-728-17-784 en 5 ''' READ_RESULT_XML = EXPECTED_XML[:] % b''' 261-154-728-17-784 ''' DELETE_RESULT_XML = EXPECTED_XML[:] % b''' 261-154-728-17-784 ''' @all_requests def response_content(url, request): return {'status_code': 200, 'content': 'Oh hai'} class TestOutcomeRequest(unittest.TestCase): def test_parse_replace_result_xml(self): ''' Should parse replaceResult XML. ''' request = OutcomeRequest() request.process_xml(REPLACE_RESULT_XML) self.assertEqual(request.operation, 'replaceResult') self.assertEqual(request.lis_result_sourcedid, '261-154-728-17-784') self.assertEqual(request.message_identifier, '123456789') self.assertEqual(request.score, '5') def test_parse_read_result_xml(self): ''' Should parse readResult XML. ''' request = OutcomeRequest() request.process_xml(READ_RESULT_XML) self.assertEqual(request.operation, 'readResult') self.assertEqual(request.lis_result_sourcedid, '261-154-728-17-784') self.assertEqual(request.message_identifier, '123456789') self.assertEqual(request.score, None) def test_parse_delete_result_xml(self): ''' Should parse deleteRequest XML. ''' request = OutcomeRequest() request.process_xml(DELETE_RESULT_XML) self.assertEqual(request.operation, 'deleteResult') self.assertEqual(request.lis_result_sourcedid, '261-154-728-17-784') self.assertEqual(request.message_identifier, '123456789') self.assertEqual(request.score, None) def test_has_required_attributes(self): request = OutcomeRequest() self.assertFalse(request.has_required_attributes()) request.consumer_key = 'foo' request.consumer_secret = 'bar' self.assertFalse(request.has_required_attributes()) request.lis_outcome_service_url = 'http://example.edu/' request.lis_result_sourcedid = 1 request.operation = 'baz' self.assertTrue(request.has_required_attributes()) def test_post_outcome_request(self): request = OutcomeRequest() self.assertRaises(InvalidLTIConfigError, request.post_outcome_request) request.consumer_key = 'consumer' request.consumer_secret = 'secret' request.lis_outcome_service_url = 'http://example.edu/' request.lis_result_sourcedid = 'foo' request.operation = REPLACE_REQUEST with HTTMock(response_content): resp = request.post_outcome_request( nonce='my_nonce', timestamp='1234567890' ) self.assertIsInstance(resp, OutcomeResponse) request = resp.post_response.request self.assertTrue('authorization' in request.headers) auth_header = unquote(request.headers['authorization'].decode('utf-8')) correct = ('OAuth ' 'oauth_nonce="my_nonce", oauth_timestamp="1234567890", ' 'oauth_version="1.0", oauth_signature_method="HMAC-SHA1", ' 'oauth_consumer_key="consumer", ' 'oauth_body_hash="glWvnsZZ8lMif1ATz8Tx64CTTaY=", ' 'oauth_signature="XR6A1CmUauXZdJZXa1pJpTQi6OQ="') self.assertEqual(auth_header, correct) def test_from_post_request(self): factory = RequestFactory() post_request = factory.post('/', data=REPLACE_RESULT_XML, content_type='application/xml' ) request = OutcomeRequest.from_post_request(post_request) self.assertEqual(request.operation, 'replaceResult') self.assertEqual(request.lis_result_sourcedid, '261-154-728-17-784') self.assertEqual(request.message_identifier, '123456789') self.assertEqual(request.score, '5') lti-0.9.4/tests/test_outcome_response.py000066400000000000000000000101331334353704100204610ustar00rootroot00000000000000from lti import OutcomeResponse from lxml import etree import mock import unittest RESPONSE_XML = b""" V1.0 success status 123456789 replaceResult """ def normalize_xml(xml_str): parser = etree.XMLParser(remove_blank_text=True) root = etree.XML(xml_str, parser) return etree.tostring(root, with_tail=False, xml_declaration=True) class TestOutcomeResponse(unittest.TestCase): def mock_response(self, response_xml): resp = mock.Mock() resp.status_code = '200' resp.data = response_xml return resp def test_parse_replace_result_response_xml(self): ''' Should parse replaceResult response XML. ''' fake = self.mock_response(RESPONSE_XML) response = OutcomeResponse.from_post_response(fake, RESPONSE_XML) self.assertTrue(response.is_success()) self.assertEqual(response.code_major, 'success') self.assertEqual(response.severity, 'status') self.assertEqual(response.description, '') self.assertEqual(response.message_ref_identifier, '123456789') self.assertEqual(response.operation, 'replaceResult') self.assertEqual(response.score, None) def test_parse_read_result_response_xml(self): ''' Should parse readResult response XML. ''' read_xml = RESPONSE_XML.replace( b'', b''' en 0.91 ''') fake = self.mock_response(read_xml) response = OutcomeResponse.from_post_response(fake, read_xml) self.assertTrue(response.is_success()) self.assertEqual(response.code_major, 'success') self.assertEqual(response.severity, 'status') self.assertEqual(response.description, '') self.assertEqual(response.message_ref_identifier, '123456789') self.assertEqual(response.score, '0.91') def test_parse_delete_result_response_xml(self): ''' Should parse deleteResult response XML. ''' delete_xml = RESPONSE_XML.replace(b'replaceResult', b'deleteResult') fake = self.mock_response(delete_xml) result = OutcomeResponse.from_post_response(fake, delete_xml) self.assertTrue(result.is_success()) self.assertEqual(result.code_major, 'success') self.assertEqual(result.severity, 'status') self.assertEqual(result.description, '') self.assertEqual(result.message_ref_identifier, '123456789') self.assertEqual(result.operation, 'deleteResult') self.assertEqual(result.score, None) def test_recognize_failure_response(self): ''' Should recognize a failure response. ''' failure_xml = RESPONSE_XML.replace(b'success', b'failure') fake = self.mock_response(failure_xml) result = OutcomeResponse.from_post_response(fake, failure_xml) self.assertTrue(result.is_failure()) def test_generate_response_xml(self): ''' Should generate response XML. ''' response = OutcomeResponse() response.process_xml(RESPONSE_XML) correct = normalize_xml(RESPONSE_XML) got = normalize_xml(response.generate_response_xml()) self.assertEqual(got, correct) lti-0.9.4/tests/test_tool_base.py000066400000000000000000000056471334353704100170550ustar00rootroot00000000000000 import unittest from oauthlib.common import generate_client_id, generate_token from lti import LaunchParams, ToolBase, DEFAULT_LTI_VERSION def create_tb(key=None, secret=None, lp=None): key = key or generate_client_id() secret = secret or generate_token() lp = lp or LaunchParams() return ToolBase(key, secret, lp) class TestToolBase(unittest.TestCase): def test_constructor(self): tb = create_tb() self.assertIsInstance(tb.launch_params, LaunchParams) lp = LaunchParams({'resource_link_id': 1}) tb = create_tb(lp=lp) self.assertEqual(tb.launch_params, lp) lp_dict = {'resource_link_id': 3} tb = create_tb(lp=lp_dict) self.assertEqual(tb.launch_params['resource_link_id'], 3) def test_get_attr(self): tb = create_tb() self.assertEqual(tb.lti_version, DEFAULT_LTI_VERSION) resource_link_id = generate_token() tb = create_tb(lp={'resource_link_id': resource_link_id}) self.assertEqual(tb.resource_link_id, resource_link_id) # should raise AttributeError for attributes that are not valid params with self.assertRaises(AttributeError) as cm: foo = tb.foo # otherwise return None self.assertIsNone(tb.context_id) def test_set_attr(self): tb = create_tb() tb.foo = 'bar' self.assertTrue('foo' in tb.__dict__) tb.context_id = 2345 self.assertFalse('context_id' in tb.__dict__) self.assertEqual(tb.launch_params['context_id'], 2345) def test_has_role(self): tb = create_tb(lp={'roles': 'foo,bar,BLERG'}) self.assertTrue(tb.has_role('foo')) self.assertTrue(tb.has_role('FOO')) self.assertTrue(tb.has_role('blerg')) self.assertFalse(tb.has_role('baz')) def test_is_student(self): tb = create_tb(lp={'roles': 'foo, student'}) self.assertTrue(tb.is_student()) tb = create_tb(lp={'roles': 'bar,Learner'}) self.assertTrue(tb.is_student()) tb = create_tb(lp={'roles': 'foo,bar'}) self.assertFalse(tb.is_student()) def test_is_instructor(self): tb = create_tb(lp={'roles': 'foo,staff'}) self.assertTrue(tb.is_instructor()) tb = create_tb(lp={'roles': 'foo, student'}) self.assertFalse(tb.is_instructor()) def test_is_launch_request(self): tb = create_tb() self.assertTrue(tb.is_launch_request()) tb = create_tb(lp={'lti_message_type': 'foo'}) self.assertFalse(tb.is_launch_request()) def test_custom_ext_params(self): tb = create_tb() tb.set_custom_param('foo', 'bar') self.assertEqual(tb.launch_params['custom_foo'], 'bar') self.assertEqual(tb.get_custom_param('foo'), 'bar') tb.set_ext_param('baz', 'blergh') self.assertEqual(tb.launch_params['ext_baz'], 'blergh') self.assertEqual(tb.get_ext_param('baz'), 'blergh') lti-0.9.4/tests/test_tool_config.py000066400000000000000000000241341334353704100174000ustar00rootroot00000000000000from lti import ToolConfig, InvalidLTIConfigError from lxml import etree import unittest CC_LTI_XML = b''' Test Config Description of boringness http://www.example.com/lti https://www.example.com/lti test.tool test We test things http://www.example.com/about Joe Support support@example.com customval1 customval2 extval1 extval2 optval1 optval2 ext1val ''' CC_LTI_WITH_SUBOPTIONS_XML = b''' Test Config Description of boringness http://www.example.com/lti https://www.example.com/lti test.tool test We test things http://www.example.com/about Joe Support support@example.com customval1 customval2 extval1 extval2 Image Library Biblioteca de Imagenes ext1val ''' CC_LTI_OPTIONAL_PARAMS_XML = b''' Test config http://www.example.com http://www.example.com http://wil.to/_/beardslap.gif https://www.example.com/secure_icon.png ''' def normalize_xml(xml_str): parser = etree.XMLParser(remove_blank_text=True) root = etree.XML(xml_str, parser) return etree.tostring(root, with_tail=False) class TestToolConfig(unittest.TestCase): def test_generate_xml(self): ''' Should generate the expected config xml. ''' config = ToolConfig(title = "Test Config", secure_launch_url = "https://www.example.com/lti", custom_params = {"custom1": "customval1"}) config.description ='Description of boringness' config.launch_url = 'http://www.example.com/lti' config.vendor_code = 'test' config.vendor_name = 'test.tool' config.vendor_description = 'We test things' config.vendor_url = 'http://www.example.com/about' config.vendor_contact_email = 'support@example.com' config.vendor_contact_name = 'Joe Support' config.set_custom_param('custom2', 'customval2') config.set_ext_params('example.com', { 'extkey1': 'extval1' }) config.set_ext_param('example.com', 'extkey2', 'extval2') config.set_ext_param('example.com', 'extopt1', { 'optkey1': 'optval1', 'optkey2': 'optval2' }) config.set_ext_param('two.example.com', 'ext1key', 'ext1val') config.cartridge_bundle = 'BLTI001_Bundle' correct = normalize_xml(CC_LTI_XML) got = normalize_xml(config.to_xml()) self.assertEqual(got, correct) def test_allow_suboptions(self): config = ToolConfig(title = "Test Config", secure_launch_url = "https://www.example.com/lti", custom_params = {"custom1": "customval1"}) config.description ='Description of boringness' config.launch_url = 'http://www.example.com/lti' config.vendor_code = 'test' config.vendor_name = 'test.tool' config.vendor_description = 'We test things' config.vendor_url = 'http://www.example.com/about' config.vendor_contact_email = 'support@example.com' config.vendor_contact_name = 'Joe Support' config.set_custom_param('custom2', 'customval2') config.set_ext_params('example.com', { 'extkey1': 'extval1' }) config.set_ext_param('example.com', 'extkey2', 'extval2') config.set_ext_param('example.com', 'extopt1', { 'optkey1': 'optval1', 'optkey2': 'optval2' }) config.set_ext_param('example.com', 'extopt1', { 'labels':{ 'en':'Image Library', 'es':'Biblioteca de Imagenes' } }) config.set_ext_param('two.example.com', 'ext1key', 'ext1val') config.cartridge_bundle = 'BLTI001_Bundle' correct = normalize_xml(CC_LTI_WITH_SUBOPTIONS_XML) got = normalize_xml(config.to_xml()) self.assertEqual(got, correct) def test_optional_config_parameters(self): ''' Should contain cartridge_icon, and blti:icon. ''' config = ToolConfig(title = "Test config", launch_url = "http://www.example.com", secure_launch_url = "http://www.example.com") config.icon = 'http://wil.to/_/beardslap.gif' config.secure_icon = 'https://www.example.com/secure_icon.png' config.cartridge_icon = 'BLTI001_Icon' correct = normalize_xml(CC_LTI_OPTIONAL_PARAMS_XML) got = normalize_xml(config.to_xml()) self.assertEqual(got, correct) def test_can_parse_optional_config_parameters(self): ''' Config should have cartridge_icon and blti:icon set ''' config = ToolConfig.create_from_xml(CC_LTI_OPTIONAL_PARAMS_XML) self.assertEqual(config.cartridge_icon, 'BLTI001_Icon') self.assertEqual(config.icon, 'http://wil.to/_/beardslap.gif') self.assertEqual(config.secure_icon, 'https://www.example.com/secure_icon.png') def test_read_xml_config(self): ''' Should read an XML config. ''' config = ToolConfig.create_from_xml(CC_LTI_XML) self.assertEqual(normalize_xml(config.to_xml()), normalize_xml(CC_LTI_XML)) def test_invalid_config_xml(self): ''' Should not allow creating invalid config xml. ''' config = ToolConfig(title = 'Test Config') self.assertRaises(InvalidLTIConfigError, config.to_xml) lti-0.9.4/tests/test_tool_consumer.py000066400000000000000000000126521334353704100177700ustar00rootroot00000000000000from lti import ToolConsumer, 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 TestToolConsumer(unittest.TestCase): def setUp(self): pass def test_constructor(self): client_id = generate_client_id() client_secret = generate_token() tc = ToolConsumer(client_id, client_secret, launch_url='http://example.edu') self.assertIsInstance(tc.launch_params, LaunchParams) lp = LaunchParams() tc = ToolConsumer(client_id, client_secret, launch_url='http://example.edu', params=lp) self.assertEqual(tc.launch_params, lp) lp_dict = {'resource_link_id': 1} tc = ToolConsumer(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, ToolConsumer, 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 = ToolConsumer(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 = ToolConsumer(client_id, client_secret, launch_url='http://example.edu') self.assertFalse(tc.has_required_params()) tc.launch_params['resource_link_id'] = generate_token() 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 = ToolConsumer('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 = ToolConsumer('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 = ToolConsumer('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) def test_generate_launch_data_with_empty_value(self): launch_params = { 'lti_version': 'abc', 'lti_message_type': 'def', 'resource_link_id': '123', 'custom_test_value': '', } tc = ToolConsumer('client_key', 'client_secret', launch_url='http://example.edu/', params=launch_params) got = tc.generate_launch_data(nonce='wxyz7890', timestamp='2345678901') self.assertEqual(got['custom_test_value'], '') lti-0.9.4/tests/test_tool_provider.py000066400000000000000000000325351334353704100177710ustar00rootroot00000000000000import unittest try: from urllib.parse import urlsplit except ImportError: from urlparse import urlsplit # Python 2 from mock import Mock, patch from oauthlib.common import generate_client_id from oauthlib.common import generate_token from oauthlib.oauth1 import SignatureOnlyEndpoint from oauthlib.oauth1 import RequestValidator from lti import LaunchParams, OutcomeRequest, ToolProvider from lti.utils import parse_qs, InvalidLTIConfigError from lti.tool_provider import ProxyValidator def create_tp(key=None, secret=None, lp=None, launch_url=None, launch_headers=None, tp_class=ToolProvider): key = key or generate_client_id() secret = secret or generate_token() launch_params = tp_class.launch_params_class() if lp is not None: launch_params.update(lp) launch_url = launch_url or "http://example.edu" launch_headers = launch_headers or {} return tp_class(key, secret, launch_params, launch_url, launch_headers) class CustomLaunchParams(LaunchParams): def valid_param(self, param): result = super(CustomLaunchParams, self).valid_param(param) return result or param in ['basiclti_submit', 'launch_url'] class CustomToolProvider(ToolProvider): launch_params_class = CustomLaunchParams class TestToolProvider(unittest.TestCase): def test_constructor(self): tp = create_tp() self.assertIsInstance(tp.launch_params, LaunchParams) tp = create_tp(launch_headers={'foo': 'bar'}) self.assertEqual(tp.launch_headers['foo'], 'bar') def test_is_valid_request(self): """ just checks that the TP sends the correct args to the endpoint """ key = generate_client_id() secret = generate_token() lp = { 'lti_version': 'foo', 'lti_message_type': 'bar', 'resource_link_id': 123 } launch_url = 'http://example.edu/foo/bar' launch_headers = {'Content-Type': 'baz'} tp = create_tp(key, secret, lp, launch_url, launch_headers) with patch.object(SignatureOnlyEndpoint, 'validate_request') as mv: mv.return_value = True, None # Tuple of valid, request self.assertTrue(tp.is_valid_request(Mock())) call_url, call_method, call_params, call_headers = mv.call_args[0] self.assertEqual(call_url, launch_url) self.assertEqual(call_method, 'POST') self.assertEqual(call_params, lp) self.assertEqual(call_headers, launch_headers) def test_is_valid_request_no_key_or_secret(self): """ Checks that the key and secret will be populated during validation. """ key = 'spamspamspam' secret_ = 'eggseggsegss' lp = LaunchParams({ 'lti_version': 'foo', 'lti_message_type': 'bar', 'resource_link_id': '123', 'oauth_consumer_key': key, 'oauth_nonce': '9069031379649850801466828504', 'oauth_timestamp': '1466828504', 'oauth_version': '1.0', 'oauth_signature_method': 'HMAC-SHA1', 'oauth_signature': 'WZ9IHyFnKgDKBvnAfNSL3aOVteg=', }) launch_url = 'https://example.edu/foo/bar' launch_headers = {'Content-Type': 'application/x-www-form-urlencoded'} class TpValidator(RequestValidator): dummy_client = '' def validate_timestamp_and_nonce(self, timestamp, nonce, request, request_token=None, access_token=None): return True def validate_client_key(self, client_key, request): return True def get_client_secret(self, client_key, request): return secret_ secret = secret_ # Fool the ProxyValidator tp = ToolProvider(params=lp, launch_url=launch_url, launch_headers=launch_headers) SOE = SignatureOnlyEndpoint with patch.object(SOE, '_check_mandatory_parameters'): with patch.object(SOE, '_check_signature', return_value=True): self.assertTrue(tp.is_valid_request(TpValidator())) self.assertEqual(tp.consumer_key, key) self.assertEqual(tp.consumer_secret, secret_) def test_proxy_validator(self): ''' Should store the secret when get_client_secret is called. ''' class TpValidator(RequestValidator): def get_client_secret(self, client_key, request): return 'eggseggseggs' pv = ProxyValidator(TpValidator()) self.assertFalse(hasattr(pv, 'secret')) self.assertEqual( pv.get_client_secret('spamspamspam', None), 'eggseggseggs') self.assertEqual(pv.secret, 'eggseggseggs') def test_outcome_service(self): ''' Should recognize an outcome service. ''' tp = create_tp() self.assertFalse(tp.is_outcome_service()) tp = create_tp(lp={'lis_result_sourcedid': 1}) self.assertFalse(tp.is_outcome_service()) tp = create_tp(lp={ 'lis_outcome_service_url': 'foo', 'lis_result_sourcedid': 1 }) self.assertTrue(tp.is_outcome_service()) def test_return_url_with_messages(self): ''' Should generate a return url with messages. ''' tp = create_tp() self.assertIsNone(tp.build_return_url()) tp = create_tp(lp={ 'launch_presentation_return_url': 'http://foo.edu/done' }) self.assertEqual(tp.build_return_url(), 'http://foo.edu/done') tp = create_tp(lp={ 'launch_presentation_return_url': 'http://foo.edu/done', 'lti_errormsg': 'user error', 'lti_errorlog': 'lms error', 'lti_msg': 'user message', 'lti_log': 'lms message' }) return_url = tp.build_return_url() parsed = urlsplit(return_url) self.assertEqual(parsed.hostname, 'foo.edu') self.assertEqual(parsed.path, '/done') self.assertEqual(parse_qs(parsed.query), { 'lti_errormsg': 'user error', 'lti_errorlog': 'lms error', 'lti_msg': 'user message', 'lti_log': 'lms message' }) def test_username(self): ''' Should find the best username. ''' tp = create_tp() self.assertEqual(tp.username('guy'), 'guy') tp.lis_person_name_full = 'full' self.assertEqual(tp.username('guy'), 'full') tp.lis_person_name_family = 'family' self.assertEqual(tp.username('guy'), 'family') tp.lis_person_name_given = 'given' self.assertEqual(tp.username('guy'), 'given') def test_new_request(self): key = generate_client_id() secret = generate_token() lp = { 'lti_version': 'foo', 'lti_message_type': 'bar', 'resource_link_id': 123 } tp = create_tp(key, secret, lp) req = tp.new_request({}) self.assertIsInstance(req, OutcomeRequest) self.assertEqual(req, tp._last_outcome_request) self.assertEqual(req.consumer_key, key) self.assertEqual(len(tp.outcome_requests), 1) # outcome request should get assigned attr req = tp.new_request({'score': 1.0}) self.assertEqual(req.score, 1.0) self.assertEqual(len(tp.outcome_requests), 2) # but can't override some fields req = tp.new_request({'consumer_key': 'foo'}) self.assertEqual(req.consumer_key, key) self.assertEqual(len(tp.outcome_requests), 3) # should fail if we use an invalid opt self.assertRaises(InvalidLTIConfigError, tp.new_request, {'foo': 1}) self.assertEqual(len(tp.outcome_requests), 3) def test_last_outcome_success(self): tp = create_tp() mock = Mock() mock.was_outcome_post_successful.return_value = True tp._last_outcome_request = mock self.assertTrue(tp.last_outcome_success()) def test_last_outcome_request(self): tp = create_tp() tp.outcome_requests = ['foo','bar'] self.assertEqual(tp.last_outcome_request(), 'bar') def test_custom_launch_params(self): key = generate_client_id() secret = generate_token() lp = { 'lti_version': 'foo', 'lti_message_type': 'bar', 'resource_link_id': 123, 'launch_url': 'more_foo', 'basiclti_submit': 'more_bar' } launch_url = 'http://example.edu/foo/bar' launch_headers = {'Content-Type': 'baz'} tp = create_tp(key, secret, lp, launch_url, launch_headers, tp_class=CustomToolProvider) with patch.object(SignatureOnlyEndpoint, 'validate_request') as mv: mv.return_value = True, None # Tuple of valid, request self.assertTrue(tp.is_valid_request(Mock())) call_url, call_method, call_params, call_headers = mv.call_args[0] self.assertEqual(call_url, launch_url) self.assertEqual(call_method, 'POST') self.assertEqual(call_params, lp) self.assertEqual(call_headers, launch_headers) # mock the django.shortcuts import to allow testing mock = Mock() mock.shortcuts.redirect.return_value = 'foo' mock_modules = { 'django': mock, 'django.shortcuts': mock.shortcuts } class TestDjangoToolProvider(unittest.TestCase): @patch.dict('sys.modules', mock_modules) def test_from_django_request(self): from lti.contrib.django import DjangoToolProvider secret = generate_token() mock_req = Mock() mock_req.POST = {'oauth_consumer_key': 'foo'} mock_req.META = {'CONTENT_TYPE': 'bar'} mock_req.build_absolute_uri.return_value = 'http://example.edu/foo/bar' tp = DjangoToolProvider.from_django_request(secret, mock_req) self.assertEqual(tp.consumer_key, 'foo') self.assertEqual(tp.launch_headers['CONTENT_TYPE'], 'bar') self.assertEqual(tp.launch_url, 'http://example.edu/foo/bar') @patch.dict('sys.modules', mock_modules) def test_request_required(self): from lti.contrib.django import DjangoToolProvider with self.assertRaises(ValueError): DjangoToolProvider.from_django_request() @patch.dict('sys.modules', mock_modules) def test_secret_not_required(self): from lti.contrib.django import DjangoToolProvider mock_req = Mock() mock_req.POST = {'oauth_consumer_key': 'foo'} mock_req.META = {'CONTENT_TYPE': 'bar'} mock_req.build_absolute_uri.return_value = 'http://example.edu/foo/bar' tp = DjangoToolProvider.from_django_request(request=mock_req) self.assertEqual(tp.consumer_key, 'foo') self.assertEqual(tp.launch_headers['CONTENT_TYPE'], 'bar') self.assertEqual(tp.launch_url, 'http://example.edu/foo/bar') @patch.dict('sys.modules', mock_modules) def test_success_redirect(self): from lti.contrib.django import DjangoToolProvider tp = create_tp(lp={ 'launch_presentation_return_url': 'http://example.edu/foo' }, tp_class=DjangoToolProvider) redirect_retval = tp.success_redirect(msg='bar', log='baz') self.assertEqual(redirect_retval, 'foo') redirect_url, = mock.shortcuts.redirect.call_args[0] parsed = urlsplit(redirect_url) self.assertEqual(parse_qs(parsed.query), { 'lti_msg': 'bar', 'lti_log': 'baz' }) @patch.dict('sys.modules', mock_modules) def test_error_redirect(self): from lti.contrib.django import DjangoToolProvider tp = create_tp(lp={ 'launch_presentation_return_url': 'http://example.edu/bar' }, tp_class=DjangoToolProvider) redirect_retval = tp.error_redirect(errormsg='abcd', errorlog='efgh') self.assertEqual(redirect_retval, 'foo') redirect_url, = mock.shortcuts.redirect.call_args[0] parsed = urlsplit(redirect_url) self.assertEqual(parse_qs(parsed.query), { 'lti_errormsg': 'abcd', 'lti_errorlog': 'efgh' }) class TestFlaskToolProvider(unittest.TestCase): def test_from_flask_request(self): from lti.contrib.flask import FlaskToolProvider secret = generate_token() mock_req = Mock() mock_req.form = {'oauth_consumer_key': 'foo'} mock_req.headers = {'Content-type': 'bar'} mock_req.url = 'http://example.edu/foo/bar' tp = FlaskToolProvider.from_flask_request(secret, mock_req) self.assertEqual(tp.consumer_key, 'foo') self.assertEqual(tp.launch_headers['Content-type'], 'bar') self.assertEqual(tp.launch_url, 'http://example.edu/foo/bar') def test_request_required(self): from lti.contrib.flask import FlaskToolProvider with self.assertRaises(ValueError): FlaskToolProvider.from_flask_request() def test_secret_not_required(self): from lti.contrib.flask import FlaskToolProvider mock_req = Mock() mock_req.form = {'oauth_consumer_key': 'foo'} mock_req.headers = {'Content-type': 'bar'} mock_req.url = 'http://example.edu/foo/bar' tp = FlaskToolProvider.from_flask_request(request=mock_req) self.assertEqual(tp.consumer_key, 'foo') self.assertEqual(tp.launch_headers['Content-type'], 'bar') self.assertEqual(tp.launch_url, 'http://example.edu/foo/bar') lti-0.9.4/tests/test_tool_proxy.py000066400000000000000000000365241334353704100173220ustar00rootroot00000000000000import unittest from mock import Mock, patch from lti import ToolProxy import requests import json from oauthlib.oauth1 import SignatureOnlyEndpoint test_profile = {'@context': ['http://purl.imsglobal.org/ctx/lti/v2/ToolConsumerProfile'], '@id': 'https://canvas.instructure.com/api/lti/courses/1157004/tool_consumer_profile/339b6700-e4cb-47c5-a54f-3ee0064921a9', '@type': 'ToolConsumerProfile', 'capability_offered': ['basic-lti-launch-request', 'User.id', 'Canvas.api.domain', 'LtiLink.custom.url', 'ToolProxyBinding.custom.url', 'ToolProxy.custom.url', 'Canvas.placements.accountNavigation', 'Canvas.placements.courseNavigation', 'Canvas.placements.assignmentSelection', 'Canvas.placements.linkSelection', 'Canvas.placements.postGrades', 'User.username', 'Person.email.primary', 'vnd.Canvas.Person.email.sis', 'Person.name.given', 'Person.name.family', 'Person.name.full', 'CourseSection.sourcedId', 'Person.sourcedId', 'Membership.role', 'ToolConsumerProfile.url', 'Security.splitSecret', 'Context.id', 'ToolConsumerInstance.guid', 'CourseSection.sourcedId', 'Membership.role', 'Person.email.primary', 'Person.name.given', 'Person.name.family', 'Person.name.full', 'Person.sourcedId', 'User.id', 'User.image', 'Message.documentTarget', 'Message.locale', 'Context.id', 'vnd.Canvas.root_account.uuid'], 'guid': '339b6700-e4cb-47c5-a54f-3ee0064921a9', 'lti_version': 'LTI-2p0', 'product_instance': {'guid': '07adb3e60637ff02d9ea11c7c74f1ca921699bd7.canvas.instructure.com', 'product_info': {'product_family': {'code': 'canvas', 'vendor': {'code': 'https://instructure.com', 'timestamp': '2008-03-27T06:00:00Z', 'vendor_name': {'default_value': 'Instructure', 'key': 'vendor.name'}}}, 'product_name': {'default_value': 'Canvas ' 'by ' 'Instructure', 'key': 'product.name'}, 'product_version': 'none'}, 'service_owner': {'description': {'default_value': 'Free ' 'For ' 'Teachers', 'key': 'service_owner.description'}, 'service_owner_name': {'default_value': 'Free ' 'For ' 'Teachers', 'key': 'service_owner.name'}}}, 'security_profile': [{'digest_algorithm': 'HMAC-SHA1', 'security_profile_name': 'lti_oauth_hash_message_security'}, {'digest_algorithm': 'HS256', 'security_profile_name': 'oauth2_access_token_ws_security'}], 'service_offered': [{'@id': 'https://canvas.instructure.com/api/lti/courses/1157004/tool_consumer_profile/339b6700-e4cb-47c5-a54f-3ee0064921a9#ToolProxy.collection', '@type': 'RestService', 'action': ['POST'], 'endpoint': 'https://canvas.instructure.com/api/lti/courses/1157004/tool_proxy', 'format': ['application/vnd.ims.lti.v2.toolproxy+json']}, {'@id': 'https://canvas.instructure.com/api/lti/courses/1157004/tool_consumer_profile/339b6700-e4cb-47c5-a54f-3ee0064921a9#ToolProxy.item', '@type': 'RestService', 'action': ['GET'], 'endpoint': 'https://canvas.instructure.com/api/lti/tool_proxy/{tool_proxy_guid}', 'format': ['application/vnd.ims.lti.v2.toolproxy+json']}, {'@id': 'https://canvas.instructure.com/api/lti/courses/1157004/tool_consumer_profile/339b6700-e4cb-47c5-a54f-3ee0064921a9#vnd.Canvas.authorization', '@type': 'RestService', 'action': ['POST'], 'endpoint': 'https://canvas.instructure.com/api/lti/courses/1157004/authorize', 'format': ['application/json']}, {'@id': 'https://canvas.instructure.com/api/lti/courses/1157004/tool_consumer_profile/339b6700-e4cb-47c5-a54f-3ee0064921a9#ToolProxySettings', '@type': 'RestService', 'action': ['GET', 'PUT'], 'endpoint': 'https://canvas.instructure.com/api/lti/tool_settings/tool_proxy/{tool_proxy_id}', 'format': ['application/vnd.ims.lti.v2.toolsettings+json', 'application/vnd.ims.lti.v2.toolsettings.simple+json']}, {'@id': 'https://canvas.instructure.com/api/lti/courses/1157004/tool_consumer_profile/339b6700-e4cb-47c5-a54f-3ee0064921a9#ToolProxyBindingSettings', '@type': 'RestService', 'action': ['GET', 'PUT'], 'endpoint': 'https://canvas.instructure.com/api/lti/tool_settings/bindings/{binding_id}', 'format': ["application/vnd.ims.lti.v2.toolsettings+json'", 'application/vnd.ims.lti.v2.toolsettings.simple+json']}, {'@id': 'https://canvas.instructure.com/api/lti/courses/1157004/tool_consumer_profile/339b6700-e4cb-47c5-a54f-3ee0064921a9#LtiLinkSettings', '@type': 'RestService', 'action': ['GET', 'PUT'], 'endpoint': 'https://canvas.instructure.com/api/lti/tool_settings/links/{tool_proxy_id}', 'format': ['application/vnd.ims.lti.v2.toolsettings+json', 'application/vnd.ims.lti.v2.toolsettings.simple+json']}]} test_params = {'ext_api_domain': 'canvas.instructure.com', 'ext_tool_consumer_instance_guid': '07adb3e60637ff02d9ea11c7c74f1ca921699bd7.canvas.instructure.com', 'launch_presentation_document_target': 'iframe', 'launch_presentation_return_url': 'https://canvas.instructure.com/courses/1157004/lti/registration_return', 'lti_message_type': 'ToolProxyRegistrationRequest', 'lti_version': 'LTI-2p0', 'reg_key': 'eb9031ac-2e12-422e-8238-beb9c41419b3', 'reg_password': 'f781d41d-6f9e-4b02-b11b-fe4ffa704ac1', 'tc_profile_url': 'https://canvas.instructure.com/api/lti/courses/1157004/tool_consumer_profile'} class TestToolProxy(unittest.TestCase): def test_load_tc_profile(self): #Mock out the call to the requests library response = Mock() response.text = json.dumps(test_profile) proxy = ToolProxy(params=test_params) with patch('lti.tool_proxy.requests.get') as mock_get: mock_get.return_value = response proxy.load_tc_profile() self.assertEqual(proxy.tc_profile, test_profile) def test_tool_consumer_profile_url(self): proxy = ToolProxy(params=test_params) self.assertEqual(proxy.tool_consumer_profile_url, test_params['tc_profile_url']) def test_find_registration_url(self): proxy = ToolProxy(params=test_params) proxy.tc_profile = test_profile registration_url = proxy.find_registration_url() self.assertEqual(registration_url, 'https://canvas.instructure.com/api/lti/courses/1157004/tool_proxy') def test_not_find_registration_url(self): proxy = ToolProxy(params=test_params) proxy.tc_profile = {'@context': ['http://purl.imsglobal.org/ctx/lti/v2/ToolConsumerProfile'], '@id': 'https://canvas.instructure.com/api/lti/courses/1157004/tool_consumer_profile/339b6700-e4cb-47c5-a54f-3ee0064921a9', '@type': 'ToolConsumerProfile', 'capability_offered': ['basic-lti-launch-request', 'vnd.Canvas.root_account.uuid'], 'guid': '339b6700-e4cb-47c5-a54f-3ee0064921a9', 'lti_version': 'LTI-2p0', 'product_instance': {'guid': '07adb3e60637ff02d9ea11c7c74f1ca921699bd7.canvas.instructure.com', 'product_info': {'product_family': {'code': 'canvas', 'vendor': {'code': 'https://instructure.com', 'timestamp': '2008-03-27T06:00:00Z', 'vendor_name': {'default_value': 'Instructure', 'key': 'vendor.name'}}}, 'product_name': {'default_value': 'Canvas ' 'by ' 'Instructure', 'key': 'product.name'}, 'product_version': 'none'}, 'service_owner': {'description': {'default_value': 'Free ' 'For ' 'Teachers', 'key': 'service_owner.description'}, 'service_owner_name': {'default_value': 'Free ' 'For ' 'Teachers', 'key': 'service_owner.name'}}}, 'security_profile': [{'digest_algorithm': 'HMAC-SHA1', 'security_profile_name': 'lti_oauth_hash_message_security'}, {'digest_algorithm': 'HS256', 'security_profile_name': 'oauth2_access_token_ws_security'}], 'service_offered': [{'@id': 'https://canvas.instructure.com/api/lti/courses/1157004/tool_consumer_profile/339b6700-e4cb-47c5-a54f-3ee0064921a9#ToolProxy.item', '@type': 'RestService', 'action': ['GET'], 'endpoint': 'https://canvas.instructure.com/api/lti/tool_proxy/{tool_proxy_guid}', 'format': ['application/vnd.ims.lti.v2.toolproxy+json']}, {'@id': 'https://canvas.instructure.com/api/lti/courses/1157004/tool_consumer_profile/339b6700-e4cb-47c5-a54f-3ee0064921a9#vnd.Canvas.authorization', '@type': 'RestService', 'action': ['POST'], 'endpoint': 'https://canvas.instructure.com/api/lti/courses/1157004/authorize', 'format': ['application/json']}, {'@id': 'https://canvas.instructure.com/api/lti/courses/1157004/tool_consumer_profile/339b6700-e4cb-47c5-a54f-3ee0064921a9#ToolProxySettings', '@type': 'RestService', 'action': ['GET', 'PUT'], 'endpoint': 'https://canvas.instructure.com/api/lti/tool_settings/tool_proxy/{tool_proxy_id}', 'format': ['application/vnd.ims.lti.v2.toolsettings+json', 'application/vnd.ims.lti.v2.toolsettings.simple+json']}, {'@id': 'https://canvas.instructure.com/api/lti/courses/1157004/tool_consumer_profile/339b6700-e4cb-47c5-a54f-3ee0064921a9#ToolProxyBindingSettings', '@type': 'RestService', 'action': ['GET', 'PUT'], 'endpoint': 'https://canvas.instructure.com/api/lti/tool_settings/bindings/{binding_id}', 'format': ["application/vnd.ims.lti.v2.toolsettings+json'", 'application/vnd.ims.lti.v2.toolsettings.simple+json']}, {'@id': 'https://canvas.instructure.com/api/lti/courses/1157004/tool_consumer_profile/339b6700-e4cb-47c5-a54f-3ee0064921a9#LtiLinkSettings', '@type': 'RestService', 'action': ['GET', 'PUT'], 'endpoint': 'https://canvas.instructure.com/api/lti/tool_settings/links/{tool_proxy_id}', 'format': ['application/vnd.ims.lti.v2.toolsettings+json', 'application/vnd.ims.lti.v2.toolsettings.simple+json']}]} registration_url = proxy.find_registration_url() self.assertIsNone(registration_url) def test_register_proxy(self): proxy = ToolProxy(params=test_params) proxy.tc_profile = test_profile signed_request = proxy.register_proxy({'tool_profile': 'A Real Tool Profile Goes here'}) self.assertIsInstance(signed_request, requests.PreparedRequest)lti-0.9.4/tox.ini000066400000000000000000000013161334353704100136330ustar00rootroot00000000000000[tox] envlist = py27, py35, py36 [testenv] commands = # We use parallel mode and then combine here so that # coverage.py will take the paths like # .tox/py34/lib/python2.7/site-packages/lti/__init__.py # and collapse them into src/lti/__init__.py. coverage run --parallel-mode -m pytest --capture=no --strict {posargs} coverage combine coverage report -m deps = coverage pytest mock httmock django # disable setting of random hash seed. without this the tool config xml tests # fail to to inconsistent order of attributes. setenv = PYTHONHASHSEED = 0 [testenv:desc] deps = docutils Pygments commands = python setup.py check --restructuredtext --strict