├── .github ├── dependabot.yml └── workflows │ └── build-and-test.yml ├── .gitignore ├── AUTHORS.md ├── CHANGES.txt ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── lti_provider ├── __init__.py ├── admin.py ├── auth.py ├── lti.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_auto_20151231_1107.py │ ├── 0003_auto_20151231_1109.py │ ├── 0004_lticoursecontext_enable.py │ ├── 0005_auto_20171009_1234.py │ ├── 0006_auto_20180205_1636.py │ ├── 0007_alter_lticoursecontext_lms_course_context.py │ └── __init__.py ├── mixins.py ├── models.py ├── templates │ └── lti_provider │ │ ├── config.xml │ │ ├── fail_auth.html │ │ ├── fail_course_configuration.html │ │ └── landing_page.html ├── templatetags │ ├── __init__.py │ └── lti_utils.py ├── tests │ ├── __init__.py │ ├── factories.py │ ├── test_auth.py │ ├── test_lti.py │ ├── test_templatetags.py │ ├── test_views.py │ └── urls.py ├── urls.py └── views.py ├── runtests.py ├── setup.py └── test_reqs.txt /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "10:00" 8 | open-pull-requests-limit: 10 9 | ignore: 10 | - dependency-name: importlib-metadata 11 | versions: 12 | - 3.6.0 13 | - 3.9.0 14 | - 4.0.0 15 | - package-ecosystem: github-actions 16 | directory: "/" 17 | schedule: 18 | interval: daily 19 | time: "10:00" 20 | -------------------------------------------------------------------------------- /.github/workflows/build-and-test.yml: -------------------------------------------------------------------------------- 1 | name: build-and-test 2 | on: [push, workflow_dispatch] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-24.04 6 | strategy: 7 | matrix: 8 | python-version: ["3.12"] 9 | django-version: ["4.2"] 10 | steps: 11 | - uses: actions/checkout@v4 12 | 13 | - name: Set up Python ${{ matrix.python-version }} 14 | uses: actions/setup-python@v5 15 | with: 16 | python-version: ${{ matrix.python-version }} 17 | 18 | - name: Install Django 19 | run: | 20 | python -m pip install "Django~=${{ matrix.django-version }}" 21 | 22 | - name: Build and Test 23 | run: make 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ._* 2 | *~ 3 | *.pyc 4 | ve 5 | \#* 6 | .project 7 | .pydevproject 8 | .settings 9 | .coverage 10 | reports 11 | build 12 | dist 13 | django_lti_provider.egg-info/ 14 | local_settings.py -------------------------------------------------------------------------------- /AUTHORS.md: -------------------------------------------------------------------------------- 1 | Maintainer 2 | ---------- 3 | Columbia University Center for Teaching & Learning 4 | 5 | Original Authors 6 | ---------------- 7 | * Susan Dreher (@sdreher) 8 | * Nik Nyby (@nikolas) 9 | 10 | Contributors 11 | ---------------- 12 | A list of much-appreciated contributors who have submitted patches and reported bugs: 13 | * Nicolas Can, University of Lille, France (@ptitloup) 14 | * Srijan Anand, Senior Backend Developer at Valiance Analytics, Noida (@srijannnd) 15 | * AZ Vasquez (@thedpws) 16 | * Tylor Dodge (dodget) 17 | * Kyle Lawlor-Bagcal (wgwz) 18 | -------------------------------------------------------------------------------- /CHANGES.txt: -------------------------------------------------------------------------------- 1 | 1.1.0 2 | ================== 3 | * Add LTI 1.3 support 4 | 5 | 1.0.0 (2023-10-26) 6 | ================== 7 | * Django 3 compatability 8 | * No longer testing against Django 2.2 9 | 10 | 0.4.7 11 | ================== 12 | * Provide a default value for allow_ta_access setting. 13 | 14 | 0.4.6 15 | ================== 16 | * Add is_ta to launch view context 17 | 18 | 0.4.5 19 | ================== 20 | * Allow for teaching assistants to have full faculty status 21 | 22 | 0.4.4 23 | ================== 24 | * Update dynamic assignment syntax to make them more generic 25 | 26 | 0.4.3 27 | ================== 28 | * Support dynamic assignments for older Canvas versions 29 | 30 | 0.4.2 31 | ================== 32 | * Allow for dynamic assignments 33 | * Update dependencies 34 | 35 | 0.4.1 36 | ================== 37 | * Allow configuration of course_navigation as a dictionary (dodget) 38 | 39 | 0.4.0 40 | ================== 41 | * Fix course-aware flow infinite redirect 42 | * Add Python3 support 43 | * Drop Django 2.0 from testing 44 | 45 | 0.3.4 46 | ================== 47 | * Add X-Frame view exceptions 48 | * Allow for custom field configuration in config.xml 49 | 50 | 0.3.3 (2018-02-13) 51 | =================== 52 | * Configurable LTI parameters 53 | 54 | 55 | 0.3.2 (2018-02-06) 56 | =================== 57 | * MySQL database compatibility 58 | 59 | 0.3.1 (2018-01-19) 60 | =================== 61 | * Change how incoming requests are assessed/verified 62 | 63 | 0.3.0 (2017-12-05) 64 | =================== 65 | * Add django 2.0 support 66 | 67 | 0.2.3 (2017-11-30) 68 | =================== 69 | * Add success / failure messaging around grade passback 70 | 71 | 0.2.2 (2017-11-03) 72 | =================== 73 | * Fixed an encoding error on LTI init in Python 3 74 | 75 | 0.2.1 (2017-10-26) 76 | =================== 77 | * Python 3 fix: items() instead of iteritems() 78 | * Make LTIRoutingView exempt from CSRF checks 79 | 80 | 0.2.0 (2017-10-20) 81 | =================== 82 | * Session awareness 83 | * Refactoring auth failure and course configuration paths 84 | * Add multiple assignment routing 85 | * Documentation 86 | * pypi release 87 | 88 | 0.1.0 (2016-07-09) 89 | =================== 90 | * initial release 91 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at ccnmtl-dev@columbia.edu. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | We'd love for you to contribute to our source code! Here are the guidelines we'd like you to follow: 4 | 5 | - [Code of Conduct](#coc) 6 | - [Issues and Bugs](#issue) 7 | - [Getting Started](#start) 8 | - [Coding Rules](#rules) 9 | - [Making Trivial Changes](#trivial) 10 | - [Making Changes](#changes) 11 | - [Submitting Changes](#submit) 12 | - [Further Info](#info) 13 | 14 | ## Code of Conduct 15 | Help us stay open and inclusive by following our [Code of Conduct](https://github.com/ccnmtl/django-lti-provider/blob/master/CODE_OF_CONDUCT.md). 16 | 17 | ## Found an Issue? 18 | If you find a bug in the source code or a mistake in the documentation, you can help us by 19 | submitting an issue to our [GitHub issue tracker](https://github.com/ccnmtl/django-lti-provider/issues). Even better you can submit a Pull Request with a fix :heart_eyes:. 20 | 21 | **Please see the Submission Guidelines below**. 22 | 23 | ## Getting Started 24 | 25 | * Make sure you have a [GitHub account](https://github.com/signup/free) 26 | * Submit a ticket for your issue, assuming one does not already exist. 27 | * Clearly describe the issue including steps to reproduce when it is a bug. 28 | * Make sure you fill in the earliest version that you know has the issue. 29 | * Fork the repository into your account on GitHub 30 | 31 | ## Coding Rules 32 | To ensure consistency throughout the source code, please keep these rules in mind as you are working: 33 | 34 | * All features or bug fixes **must be tested** by one or more unit tests. 35 | * We follow the conventions contained in: 36 | * Python's [PEP8 Style Guide](https://www.python.org/dev/peps/pep-0008/) (enforced by [flake8](https://pypi.python.org/pypi/flake8)) 37 | * Javscript's [ESLint](http://eslint.org/) errors and warnings. 38 | * The master branch is continuously integrated by [Travis-CI](https://travis-ci.org/), and all tests must pass before merging. 39 | 40 | ## Making Changes 41 | 42 | * Create a topic branch from where you want to base your work. 43 | * This is usually the master branch. 44 | * Only target release branches if you are certain your fix must be on that 45 | branch. 46 | * To quickly create a topic branch based on master; `git checkout -b 47 | fix/master/my_contribution master`. Please avoid working directly on the 48 | `master` branch. 49 | * Create your patch, **including appropriate test cases**. 50 | * Make commits of logical units. 51 | * Run `make` to make sure the code passes all tests. 52 | * Make sure your commit messages are in the proper format. 53 | 54 | ## Submitting Changes 55 | 56 | * Push your changes to a topic branch in your fork of the repository. 57 | * Submit a pull request to the repository in the ccnmtl organization. 58 | * The core team reviews Pull Requests on a regular basis, and will provide feedback 59 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | django-lti-provider is a python helper that supplies lti functionality to 2 | django applications. The library is used by our Mediathread application 3 | (http://mediathread.info). django-lti-provider was developed at the Columbia 4 | Center for Learning (http://ctl.columbia.edu). 5 | 6 | django-lti-provider depends on several other free open-source software as 7 | dependencies, which come with other licenses (compatible with the GPL) 8 | specified in [ test_reqs.txt ] including Django 9 | (http://www.djangoproject.com/) which is BSD-licensed. 10 | 11 | Authors: 12 | Susan Dreher 13 | Anders Pearson 14 | 15 | This program is free software; you can redistribute it and/or modify 16 | it under the terms of the GNU General Public License as published by 17 | the Free Software Foundation; either version 2 of the License, or 18 | any later version. 19 | 20 | This program is distributed in the hope that it will be useful, 21 | but WITHOUT ANY WARRANTY; without even the implied warranty of 22 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 23 | GNU General Public License for more details. 24 | 25 | http://www.gnu.org/licenses/gpl.html 26 | 27 | Copyright (c) 2017 Center for Teaching and Learning at Columbia University 28 | 29 | Code licensed GNU GPLv3 (https://www.gnu.org/licenses/gpl-3.0.en.html) 30 | 31 | ---------- 32 | The code is licensed under aGNU GPLv3 33 | https://www.gnu.org/licenses/gpl-3.0.en.html 34 | 35 | GNU GENERAL PUBLIC LICENSE 36 | 37 | Version 3, 29 June 2007 38 | 39 | Copyright © 2007 Free Software Foundation, Inc. 40 | 41 | Everyone is permitted to copy and distribute verbatim copies of this license 42 | document, but changing it is not allowed. 43 | 44 | Preamble 45 | 46 | The GNU General Public License is a free, copyleft license for software and 47 | other kinds of works. 48 | 49 | The licenses for most software and other practical works are designed to take 50 | away your freedom to share and change the works. By contrast, the GNU General 51 | Public License is intended to guarantee your freedom to share and change all 52 | versions of a program--to make sure it remains free software for all its users. 53 | We, the Free Software Foundation, use the GNU General Public License for most 54 | of our software; it applies also to any other work released this way by its 55 | authors. You can apply it to your programs, too. 56 | 57 | When we speak of free software, we are referring to freedom, not price. Our 58 | General Public Licenses are designed to make sure that you have the freedom to 59 | distribute copies of free software (and charge for them if you wish), that you 60 | receive source code or can get it if you want it, that you can change the 61 | software or use pieces of it in new free programs, and that you know you can do 62 | these things. 63 | 64 | To protect your rights, we need to prevent others from denying you these rights 65 | or asking you to surrender the rights. Therefore, you have certain 66 | responsibilities if you distribute copies of the software, or if you modify it: 67 | responsibilities to respect the freedom of others. 68 | 69 | For example, if you distribute copies of such a program, whether gratis or for 70 | a fee, you must pass on to the recipients the same freedoms that you received. 71 | You must make sure that they, too, receive or can get the source code. And you 72 | must show them these terms so they know their rights. 73 | 74 | Developers that use the GNU GPL protect your rights with two steps: (1) assert 75 | copyright on the software, and (2) offer you this License giving you legal 76 | permission to copy, distribute and/or modify it. 77 | 78 | For the developers' and authors' protection, the GPL clearly explains that 79 | there is no warranty for this free software. For both users' and authors' sake, 80 | the GPL requires that modified versions be marked as changed, so that their 81 | problems will not be attributed erroneously to authors of previous versions. 82 | 83 | Some devices are designed to deny users access to install or run modified 84 | versions of the software inside them, although the manufacturer can do so. This 85 | is fundamentally incompatible with the aim of protecting users' freedom to 86 | change the software. The systematic pattern of such abuse occurs in the area of 87 | products for individuals to use, which is precisely where it is most 88 | unacceptable. Therefore, we have designed this version of the GPL to prohibit 89 | the practice for those products. If such problems arise substantially in other 90 | domains, we stand ready to extend this provision to those domains in future 91 | versions of the GPL, as needed to protect the freedom of users. 92 | 93 | Finally, every program is threatened constantly by software patents. States 94 | should not allow patents to restrict development and use of software on 95 | general-purpose computers, but in those that do, we wish to avoid the special 96 | danger that patents applied to a free program could make it effectively 97 | proprietary. To prevent this, the GPL assures that patents cannot be used to 98 | render the program non-free. 99 | 100 | The precise terms and conditions for copying, distribution and modification 101 | follow. 102 | 103 | TERMS AND CONDITIONS 104 | 105 | 0. Definitions. 106 | 107 | “This License” refers to version 3 of the GNU General Public License. 108 | 109 | “Copyright” also means copyright-like laws that apply to other kinds of works, 110 | such as semiconductor masks. 111 | 112 | “The Program” refers to any copyrightable work licensed under this License. 113 | Each licensee is addressed as “you”. “Licensees” and “recipients” may be 114 | individuals or organizations. 115 | 116 | To “modify” a work means to copy from or adapt all or part of the work in a 117 | fashion requiring copyright permission, other than the making of an exact copy. 118 | The resulting work is called a “modified version” of the earlier work or a work 119 | “based on” the earlier work. 120 | 121 | A “covered work” means either the unmodified Program or a work based on the 122 | Program. 123 | 124 | To “propagate” a work means to do anything with it that, without permission, 125 | would make you directly or secondarily liable for infringement under applicable 126 | copyright law, except executing it on a computer or modifying a private copy. 127 | Propagation includes copying, distribution (with or without modification), 128 | making available to the public, and in some countries other activities as well. 129 | 130 | To “convey” a work means any kind of propagation that enables other parties to 131 | make or receive copies. Mere interaction with a user through a computer 132 | network, with no transfer of a copy, is not conveying. 133 | 134 | An interactive user interface displays “Appropriate Legal Notices” to the 135 | extent that it includes a convenient and prominently visible feature that (1) 136 | displays an appropriate copyright notice, and (2) tells the user that there is 137 | no warranty for the work (except to the extent that warranties are provided), 138 | that licensees may convey the work under this License, and how to view a copy 139 | of this License. If the interface presents a list of user commands or options, 140 | such as a menu, a prominent item in the list meets this criterion. 141 | 142 | 1. Source Code. 143 | 144 | The “source code” for a work means the preferred form of the work for making 145 | modifications to it. “Object code” means any non-source form of a work. 146 | 147 | A “Standard Interface” means an interface that either is an official standard 148 | defined by a recognized standards body, or, in the case of interfaces specified 149 | for a particular programming language, one that is widely used among developers 150 | working in that language. 151 | 152 | The “System Libraries” of an executable work include anything, other than the 153 | work as a whole, that (a) is included in the normal form of packaging a Major 154 | Component, but which is not part of that Major Component, and (b) serves only 155 | to enable use of the work with that Major Component, or to implement a Standard 156 | Interface for which an implementation is available to the public in source code 157 | form. A “Major Component”, in this context, means a major essential component 158 | (kernel, window system, and so on) of the specific operating system (if any) on 159 | which the executable work runs, or a compiler used to produce the work, or an 160 | object code interpreter used to run it. 161 | 162 | The “Corresponding Source” for a work in object code form means all the source 163 | code needed to generate, install, and (for an executable work) run the object 164 | code and to modify the work, including scripts to control those activities. 165 | However, it does not include the work's System Libraries, or general-purpose 166 | tools or generally available free programs which are used unmodified in 167 | performing those activities but which are not part of the work. For example, 168 | Corresponding Source includes interface definition files associated with source 169 | files for the work, and the source code for shared libraries and dynamically 170 | linked subprograms that the work is specifically designed to require, such as 171 | by intimate data communication or control flow between those subprograms and 172 | other parts of the work. 173 | 174 | The Corresponding Source need not include anything that users can regenerate 175 | automatically from other parts of the Corresponding Source. 176 | 177 | The Corresponding Source for a work in source code form is that same work. 178 | 179 | 2. Basic Permissions. 180 | 181 | All rights granted under this License are granted for the term of copyright on 182 | the Program, and are irrevocable provided the stated conditions are met. This 183 | License explicitly affirms your unlimited permission to run the unmodified 184 | Program. The output from running a covered work is covered by this License only 185 | if the output, given its content, constitutes a covered work. This License 186 | acknowledges your rights of fair use or other equivalent, as provided by 187 | copyright law. 188 | 189 | You may make, run and propagate covered works that you do not convey, without 190 | conditions so long as your license otherwise remains in force. You may convey 191 | covered works to others for the sole purpose of having them make modifications 192 | exclusively for you, or provide you with facilities for running those works, 193 | provided that you comply with the terms of this License in conveying all 194 | material for which you do not control copyright. Those thus making or running 195 | the covered works for you must do so exclusively on your behalf, under your 196 | direction and control, on terms that prohibit them from making any copies of 197 | your copyrighted material outside their relationship with you. 198 | 199 | Conveying under any other circumstances is permitted solely under the 200 | conditions stated below. Sublicensing is not allowed; section 10 makes it 201 | unnecessary. 202 | 203 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 204 | 205 | No covered work shall be deemed part of an effective technological measure 206 | under any applicable law fulfilling obligations under article 11 of the WIPO 207 | copyright treaty adopted on 20 December 1996, or similar laws prohibiting or 208 | restricting circumvention of such measures. 209 | 210 | When you convey a covered work, you waive any legal power to forbid 211 | circumvention of technological measures to the extent such circumvention is 212 | effected by exercising rights under this License with respect to the covered 213 | work, and you disclaim any intention to limit operation or modification of the 214 | work as a means of enforcing, against the work's users, your or third parties' 215 | legal rights to forbid circumvention of technological measures. 216 | 217 | 4. Conveying Verbatim Copies. 218 | 219 | You may convey verbatim copies of the Program's source code as you receive it, 220 | in any medium, provided that you conspicuously and appropriately publish on 221 | each copy an appropriate copyright notice; keep intact all notices stating that 222 | this License and any non-permissive terms added in accord with section 7 apply 223 | to the code; keep intact all notices of the absence of any warranty; and give 224 | all recipients a copy of this License along with the Program. 225 | 226 | You may charge any price or no price for each copy that you convey, and you may 227 | offer support or warranty protection for a fee. 228 | 229 | 5. Conveying Modified Source Versions. 230 | 231 | You may convey a work based on the Program, or the modifications to produce it 232 | from the Program, in the form of source code under the terms of section 4, 233 | provided that you also meet all of these conditions: 234 | 235 | a) The work must carry prominent notices stating that you modified it, and 236 | giving a relevant date. 237 | b) The work must carry prominent notices stating that it is released under this 238 | License and any conditions added under section 7. This requirement modifies the 239 | requirement in section 4 to “keep intact all notices”. 240 | c) You must license the entire work, as a whole, under this License to anyone 241 | who comes into possession of a copy. This License will therefore apply, along 242 | with any applicable section 7 additional terms, to the whole of the work, and 243 | all its parts, regardless of how they are packaged. This License gives no 244 | permission to license the work in any other way, but it does not invalidate 245 | such permission if you have separately received it. 246 | d) If the work has interactive user interfaces, each must display Appropriate 247 | Legal Notices; however, if the Program has interactive interfaces that do not 248 | display Appropriate Legal Notices, your work need not make them do so. 249 | A compilation of a covered work with other separate and independent works, 250 | which are not by their nature extensions of the covered work, and which are not 251 | combined with it such as to form a larger program, in or on a volume of a 252 | storage or distribution medium, is called an “aggregate” if the compilation and 253 | its resulting copyright are not used to limit the access or legal rights of the 254 | compilation's users beyond what the individual works permit. Inclusion of a 255 | covered work in an aggregate does not cause this License to apply to the other 256 | parts of the aggregate. 257 | 258 | 6. Conveying Non-Source Forms. 259 | 260 | You may convey a covered work in object code form under the terms of sections 4 261 | and 5, provided that you also convey the machine-readable Corresponding Source 262 | under the terms of this License, in one of these ways: 263 | 264 | a) Convey the object code in, or embodied in, a physical product (including a 265 | physical distribution medium), accompanied by the Corresponding Source fixed on 266 | a durable physical medium customarily used for software interchange. 267 | b) Convey the object code in, or embodied in, a physical product (including a 268 | physical distribution medium), accompanied by a written offer, valid for at 269 | least three years and valid for as long as you offer spare parts or customer 270 | support for that product model, to give anyone who possesses the object code 271 | either (1) a copy of the Corresponding Source for all the software in the 272 | product that is covered by this License, on a durable physical medium 273 | customarily used for software interchange, for a price no more than your 274 | reasonable cost of physically performing this conveying of source, or (2) 275 | access to copy the Corresponding Source from a network server at no charge. 276 | c) Convey individual copies of the object code with a copy of the written offer 277 | to provide the Corresponding Source. This alternative is allowed only 278 | occasionally and noncommercially, and only if you received the object code with 279 | such an offer, in accord with subsection 6b. 280 | d) Convey the object code by offering access from a designated place (gratis or 281 | for a charge), and offer equivalent access to the Corresponding Source in the 282 | same way through the same place at no further charge. You need not require 283 | recipients to copy the Corresponding Source along with the object code. If the 284 | place to copy the object code is a network server, the Corresponding Source may 285 | be on a different server (operated by you or a third party) that supports 286 | equivalent copying facilities, provided you maintain clear directions next to 287 | the object code saying where to find the Corresponding Source. Regardless of 288 | what server hosts the Corresponding Source, you remain obligated to ensure that 289 | it is available for as long as needed to satisfy these requirements. 290 | e) Convey the object code using peer-to-peer transmission, provided you inform 291 | other peers where the object code and Corresponding Source of the work are 292 | being offered to the general public at no charge under subsection 6d. 293 | A separable portion of the object code, whose source code is excluded from the 294 | Corresponding Source as a System Library, need not be included in conveying the 295 | object code work. 296 | 297 | A “User Product” is either (1) a “consumer product”, which means any tangible 298 | personal property which is normally used for personal, family, or household 299 | purposes, or (2) anything designed or sold for incorporation into a dwelling. 300 | In determining whether a product is a consumer product, doubtful cases shall be 301 | resolved in favor of coverage. For a particular product received by a 302 | particular user, “normally used” refers to a typical or common use of that 303 | class of product, regardless of the status of the particular user or of the way 304 | in which the particular user actually uses, or expects or is expected to use, 305 | the product. A product is a consumer product regardless of whether the product 306 | has substantial commercial, industrial or non-consumer uses, unless such uses 307 | represent the only significant mode of use of the product. 308 | 309 | “Installation Information” for a User Product means any methods, procedures, 310 | authorization keys, or other information required to install and execute 311 | modified versions of a covered work in that User Product from a modified 312 | version of its Corresponding Source. The information must suffice to ensure 313 | that the continued functioning of the modified object code is in no case 314 | prevented or interfered with solely because modification has been made. 315 | 316 | If you convey an object code work under this section in, or with, or 317 | specifically for use in, a User Product, and the conveying occurs as part of a 318 | transaction in which the right of possession and use of the User Product is 319 | transferred to the recipient in perpetuity or for a fixed term (regardless of 320 | how the transaction is characterized), the Corresponding Source conveyed under 321 | this section must be accompanied by the Installation Information. But this 322 | requirement does not apply if neither you nor any third party retains the 323 | ability to install modified object code on the User Product (for example, the 324 | work has been installed in ROM). 325 | 326 | The requirement to provide Installation Information does not include a 327 | requirement to continue to provide support service, warranty, or updates for a 328 | work that has been modified or installed by the recipient, or for the User 329 | Product in which it has been modified or installed. Access to a network may be 330 | denied when the modification itself materially and adversely affects the 331 | operation of the network or violates the rules and protocols for communication 332 | across the network. 333 | 334 | Corresponding Source conveyed, and Installation Information provided, in accord 335 | with this section must be in a format that is publicly documented (and with an 336 | implementation available to the public in source code form), and must require 337 | no special password or key for unpacking, reading or copying. 338 | 339 | 7. Additional Terms. 340 | 341 | “Additional permissions” are terms that supplement the terms of this License by 342 | making exceptions from one or more of its conditions. Additional permissions 343 | that are applicable to the entire Program shall be treated as though they were 344 | included in this License, to the extent that they are valid under applicable 345 | law. If additional permissions apply only to part of the Program, that part may 346 | be used separately under those permissions, but the entire Program remains 347 | governed by this License without regard to the additional permissions. 348 | 349 | When you convey a copy of a covered work, you may at your option remove any 350 | additional permissions from that copy, or from any part of it. (Additional 351 | permissions may be written to require their own removal in certain cases when 352 | you modify the work.) You may place additional permissions on material, added 353 | by you to a covered work, for which you have or can give appropriate copyright 354 | permission. 355 | 356 | Notwithstanding any other provision of this License, for material you add to a 357 | covered work, you may (if authorized by the copyright holders of that material) 358 | supplement the terms of this License with terms: 359 | 360 | a) Disclaiming warranty or limiting liability differently from the terms of 361 | sections 15 and 16 of this License; or 362 | b) Requiring preservation of specified reasonable legal notices or author 363 | attributions in that material or in the Appropriate Legal Notices displayed by 364 | works containing it; or 365 | c) Prohibiting misrepresentation of the origin of that material, or requiring 366 | that modified versions of such material be marked in reasonable ways as 367 | different from the original version; or 368 | d) Limiting the use for publicity purposes of names of licensors or authors of 369 | the material; or 370 | e) Declining to grant rights under trademark law for use of some trade names, 371 | trademarks, or service marks; or 372 | f) Requiring indemnification of licensors and authors of that material by 373 | anyone who conveys the material (or modified versions of it) with contractual 374 | assumptions of liability to the recipient, for any liability that these 375 | contractual assumptions directly impose on those licensors and authors. 376 | All other non-permissive additional terms are considered “further restrictions” 377 | within the meaning of section 10. If the Program as you received it, or any 378 | part of it, contains a notice stating that it is governed by this License along 379 | with a term that is a further restriction, you may remove that term. If a 380 | license document contains a further restriction but permits relicensing or 381 | conveying under this License, you may add to a covered work material governed 382 | by the terms of that license document, provided that the further restriction 383 | does not survive such relicensing or conveying. 384 | 385 | If you add terms to a covered work in accord with this section, you must place, 386 | in the relevant source files, a statement of the additional terms that apply to 387 | those files, or a notice indicating where to find the applicable terms. 388 | 389 | Additional terms, permissive or non-permissive, may be stated in the form of a 390 | separately written license, or stated as exceptions; the above requirements 391 | apply either way. 392 | 393 | 8. Termination. 394 | 395 | You may not propagate or modify a covered work except as expressly provided 396 | under this License. Any attempt otherwise to propagate or modify it is void, 397 | and will automatically terminate your rights under this License (including any 398 | patent licenses granted under the third paragraph of section 11). 399 | 400 | However, if you cease all violation of this License, then your license from a 401 | particular copyright holder is reinstated (a) provisionally, unless and until 402 | the copyright holder explicitly and finally terminates your license, and (b) 403 | permanently, if the copyright holder fails to notify you of the violation by 404 | some reasonable means prior to 60 days after the cessation. 405 | 406 | Moreover, your license from a particular copyright holder is reinstated 407 | permanently if the copyright holder notifies you of the violation by some 408 | reasonable means, this is the first time you have received notice of violation 409 | of this License (for any work) from that copyright holder, and you cure the 410 | violation prior to 30 days after your receipt of the notice. 411 | 412 | Termination of your rights under this section does not terminate the licenses 413 | of parties who have received copies or rights from you under this License. If 414 | your rights have been terminated and not permanently reinstated, you do not 415 | qualify to receive new licenses for the same material under section 10. 416 | 417 | 9. Acceptance Not Required for Having Copies. 418 | 419 | You are not required to accept this License in order to receive or run a copy 420 | of the Program. Ancillary propagation of a covered work occurring solely as a 421 | consequence of using peer-to-peer transmission to receive a copy likewise does 422 | not require acceptance. However, nothing other than this License grants you 423 | permission to propagate or modify any covered work. These actions infringe 424 | copyright if you do not accept this License. Therefore, by modifying or 425 | propagating a covered work, you indicate your acceptance of this License to do 426 | so. 427 | 428 | 10. Automatic Licensing of Downstream Recipients. 429 | 430 | Each time you convey a covered work, the recipient automatically receives a 431 | license from the original licensors, to run, modify and propagate that work, 432 | subject to this License. You are not responsible for enforcing compliance by 433 | third parties with this License. 434 | 435 | An “entity transaction” is a transaction transferring control of an 436 | organization, or substantially all assets of one, or subdividing an 437 | organization, or merging organizations. If propagation of a covered work 438 | results from an entity transaction, each party to that transaction who receives 439 | a copy of the work also receives whatever licenses to the work the party's 440 | predecessor in interest had or could give under the previous paragraph, plus a 441 | right to possession of the Corresponding Source of the work from the 442 | predecessor in interest, if the predecessor has it or can get it with 443 | reasonable efforts. 444 | 445 | You may not impose any further restrictions on the exercise of the rights 446 | granted or affirmed under this License. For example, you may not impose a 447 | license fee, royalty, or other charge for exercise of rights granted under this 448 | License, and you may not initiate litigation (including a cross-claim or 449 | counterclaim in a lawsuit) alleging that any patent claim is infringed by 450 | making, using, selling, offering for sale, or importing the Program or any 451 | portion of it. 452 | 453 | 11. Patents. 454 | 455 | A “contributor” is a copyright holder who authorizes use under this License of 456 | the Program or a work on which the Program is based. The work thus licensed is 457 | called the contributor's “contributor version”. 458 | 459 | A contributor's “essential patent claims” are all patent claims owned or 460 | controlled by the contributor, whether already acquired or hereafter acquired, 461 | that would be infringed by some manner, permitted by this License, of making, 462 | using, or selling its contributor version, but do not include claims that would 463 | be infringed only as a consequence of further modification of the contributor 464 | version. For purposes of this definition, “control” includes the right to grant 465 | patent sublicenses in a manner consistent with the requirements of this 466 | License. 467 | 468 | Each contributor grants you a non-exclusive, worldwide, royalty-free patent 469 | license under the contributor's essential patent claims, to make, use, sell, 470 | offer for sale, import and otherwise run, modify and propagate the contents of 471 | its contributor version. 472 | 473 | In the following three paragraphs, a “patent license” is any express agreement 474 | or commitment, however denominated, not to enforce a patent (such as an express 475 | permission to practice a patent or covenant not to sue for patent 476 | infringement). To “grant” such a patent license to a party means to make such 477 | an agreement or commitment not to enforce a patent against the party. 478 | 479 | If you convey a covered work, knowingly relying on a patent license, and the 480 | Corresponding Source of the work is not available for anyone to copy, free of 481 | charge and under the terms of this License, through a publicly available 482 | network server or other readily accessible means, then you must either (1) 483 | cause the Corresponding Source to be so available, or (2) arrange to deprive 484 | yourself of the benefit of the patent license for this particular work, or (3) 485 | arrange, in a manner consistent with the requirements of this License, to 486 | extend the patent license to downstream recipients. “Knowingly relying” means 487 | you have actual knowledge that, but for the patent license, your conveying the 488 | covered work in a country, or your recipient's use of the covered work in a 489 | country, would infringe one or more identifiable patents in that country that 490 | you have reason to believe are valid. 491 | 492 | If, pursuant to or in connection with a single transaction or arrangement, you 493 | convey, or propagate by procuring conveyance of, a covered work, and grant a 494 | patent license to some of the parties receiving the covered work authorizing 495 | them to use, propagate, modify or convey a specific copy of the covered work, 496 | then the patent license you grant is automatically extended to all recipients 497 | of the covered work and works based on it. 498 | 499 | A patent license is “discriminatory” if it does not include within the scope of 500 | its coverage, prohibits the exercise of, or is conditioned on the non-exercise 501 | of one or more of the rights that are specifically granted under this License. 502 | You may not convey a covered work if you are a party to an arrangement with a 503 | third party that is in the business of distributing software, under which you 504 | make payment to the third party based on the extent of your activity of 505 | conveying the work, and under which the third party grants, to any of the 506 | parties who would receive the covered work from you, a discriminatory patent 507 | license (a) in connection with copies of the covered work conveyed by you (or 508 | copies made from those copies), or (b) primarily for and in connection with 509 | specific products or compilations that contain the covered work, unless you 510 | entered into that arrangement, or that patent license was granted, prior to 28 511 | March 2007. 512 | 513 | Nothing in this License shall be construed as excluding or limiting any implied 514 | license or other defenses to infringement that may otherwise be available to 515 | you under applicable patent law. 516 | 517 | 12. No Surrender of Others' Freedom. 518 | 519 | If conditions are imposed on you (whether by court order, agreement or 520 | otherwise) that contradict the conditions of this License, they do not excuse 521 | you from the conditions of this License. If you cannot convey a covered work so 522 | as to satisfy simultaneously your obligations under this License and any other 523 | pertinent obligations, then as a consequence you may not convey it at all. For 524 | example, if you agree to terms that obligate you to collect a royalty for 525 | further conveying from those to whom you convey the Program, the only way you 526 | could satisfy both those terms and this License would be to refrain entirely 527 | from conveying the Program. 528 | 529 | 13. Use with the GNU Affero General Public License. 530 | 531 | Notwithstanding any other provision of this License, you have permission to 532 | link or combine any covered work with a work licensed under version 3 of the 533 | GNU Affero General Public License into a single combined work, and to convey 534 | the resulting work. The terms of this License will continue to apply to the 535 | part which is the covered work, but the special requirements of the GNU Affero 536 | General Public License, section 13, concerning interaction through a network 537 | will apply to the combination as such. 538 | 539 | 14. Revised Versions of this License. 540 | 541 | The Free Software Foundation may publish revised and/or new versions of the GNU 542 | General Public License from time to time. Such new versions will be similar in 543 | spirit to the present version, but may differ in detail to address new problems 544 | or concerns. 545 | 546 | Each version is given a distinguishing version number. If the Program specifies 547 | that a certain numbered version of the GNU General Public License “or any later 548 | version” applies to it, you have the option of following the terms and 549 | conditions either of that numbered version or of any later version published by 550 | the Free Software Foundation. If the Program does not specify a version number 551 | of the GNU General Public License, you may choose any version ever published by 552 | the Free Software Foundation. 553 | 554 | If the Program specifies that a proxy can decide which future versions of the 555 | GNU General Public License can be used, that proxy's public statement of 556 | acceptance of a version permanently authorizes you to choose that version for 557 | the Program. 558 | 559 | Later license versions may give you additional or different permissions. 560 | However, no additional obligations are imposed on any author or copyright 561 | holder as a result of your choosing to follow a later version. 562 | 563 | 15. Disclaimer of Warranty. 564 | 565 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE 566 | LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER 567 | PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER 568 | EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 569 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE 570 | QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE 571 | DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR 572 | CORRECTION. 573 | 574 | 16. Limitation of Liability. 575 | 576 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY 577 | COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS 578 | PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, 579 | INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE 580 | THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED 581 | INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE 582 | PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY 583 | HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 584 | 585 | 17. Interpretation of Sections 15 and 16. 586 | 587 | If the disclaimer of warranty and limitation of liability provided above cannot 588 | be given local legal effect according to their terms, reviewing courts shall 589 | apply local law that most closely approximates an absolute waiver of all civil 590 | liability in connection with the Program, unless a warranty or assumption of 591 | liability accompanies a copy of the Program in return for a fee. 592 | 593 | END OF TERMS AND CONDITIONS -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include lti_provider * 2 | 3 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PY_DIRS=lti_provider 2 | VE ?= ./ve 3 | REQUIREMENTS ?= test_reqs.txt 4 | SYS_PYTHON ?= python3 5 | PY_SENTINAL ?= $(VE)/sentinal 6 | WHEEL_VERSION ?= 0.42.0 7 | PIP_VERSION ?= 24.0 8 | MAX_COMPLEXITY ?= 7 9 | INTERFACE ?= localhost 10 | RUNSERVER_PORT ?= 8000 11 | PY_DIRS ?= $(APP) 12 | DJANGO ?= "Django==3.2.10" 13 | 14 | # Travis has issues here. See: 15 | # https://github.com/travis-ci/travis-ci/issues/9524 16 | ifeq ($(TRAVIS),true) 17 | FLAKE8 ?= flake8 18 | PIP ?= pip 19 | else 20 | FLAKE8 ?= $(VE)/bin/flake8 21 | PIP ?= $(VE)/bin/pip 22 | endif 23 | 24 | 25 | all: flake8 test 26 | 27 | clean: 28 | rm -rf $(VE) 29 | find . -name '*.pyc' -exec rm {} \; 30 | 31 | $(PY_SENTINAL): 32 | rm -rf $(VE) 33 | $(SYS_PYTHON) -m venv $(VE) 34 | $(PIP) install pip==$(PIP_VERSION) 35 | $(PIP) install --upgrade setuptools 36 | $(PIP) install wheel==$(WHEEL_VERSION) 37 | $(PIP) install --no-deps --requirement $(REQUIREMENTS) 38 | $(PIP) install "$(DJANGO)" 39 | touch $@ 40 | 41 | test: $(REQUIREMENTS) $(PY_SENTINAL) 42 | ./ve/bin/python runtests.py 43 | 44 | flake8: $(PY_SENTINAL) 45 | $(FLAKE8) $(PY_DIRS) --max-complexity=$(MAX_COMPLEXITY) 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Actions Status](https://github.com/ccnmtl/django-lti-provider/workflows/build-and-test/badge.svg)](https://github.com/ccnmtl/django-lti-provider/actions) 2 | 3 | django-lti-provider **only supports LTI 1.1**, which is now deprecated. For 4 | [LTI 1.3](https://www.imsglobal.org/spec/lti/v1p3) support, see: 5 | * [PyLTI1p3](https://pypi.org/project/PyLTI1p3/) 6 | * [django-lti](https://pypi.org/project/django-lti/) 7 | * [Mediathread's LTI documentation](https://github.com/ccnmtl/mediathread/tree/master/lti_auth#readme) may also be helpful. 8 | 9 | # Documentation 10 | 11 | django-lti-provider provides [Learning Tools Interoperability](https://en.wikipedia.org/wiki/Learning_Tools_Interoperability) 12 | (LTI) functionality for the Django web framework. This 13 | work began as a port of MIT's [LTI Flask Sample](https://github.com/mitodl/mit_lti_flask_sample), 14 | which demonstrates a sample LTI provider for the Flask Framework based on 15 | the [Python LTI library, PyLTI](https://github.com/mitodl/pylti). 16 | 17 | Additional work was completed to provide fuller functionality and support the idiosyncrasies of various LMS systems 18 | such as Canvas, Blackboard, Moodle and EdEx. 19 | 20 | django-lti-provider offers: 21 | 22 | * an authentication backend to complete an oAuth handshake (optional) 23 | * a templated view for config.xml generation 24 | * a templated landing page view for those LMS who do not have a 'launch in new tab' option, i.e. Canvas 25 | * support for Canvas' [embedded tool extensions](https://canvas.instructure.com/doc/api/file.editor_button_tools.html) 26 | * routing for multiple external assignment end points. 27 | 28 | The library is used at Columbia University's [Center for Teaching And Learning](http://ctl.columbia.edu). 29 | 30 | See an example Django app using the library at [Django LTI Provider Example](https://github.com/ccnmtl/django-lti-provider-example). 31 | 32 | ## Installation 33 | 34 | You can install ```django-lti-provider``` through ```pip```: 35 | 36 | ```python 37 | $ pip install django-lti-provider 38 | ``` 39 | Or, if you're using virtualenv, add ```django-lti-provider``` to your ```requirements.txt```. 40 | 41 | Add to ```INSTALLED_APPS``` in your ```settings.py```:: 42 | 43 | ```python 44 | 'lti_provider', 45 | ``` 46 | 47 | ## Configuration 48 | 49 | ### Basic setup steps 50 | 51 | Add the URL route: 52 | 53 | ```python 54 | path('lti/', include('lti_provider.urls')) 55 | 56 | ``` 57 | 58 | Add the LTIBackend to your AUTHENTICATION_BACKENDS: 59 | 60 | ```python 61 | AUTHENTICATION_BACKENDS = [ 62 | 'django.contrib.auth.backends.ModelBackend', 63 | 'lti_provider.auth.LTIBackend', 64 | ] 65 | ``` 66 | 67 | Complete a migration 68 | 69 | ```python 70 | ./manage.py migrate 71 | ``` 72 | 73 | ### Primary LTI config 74 | 75 | The ``LTI_TOOL_CONFIGURATION`` variable in your ``settings.py`` allows you to 76 | configure your application's config.xml and set other options for the library. ([Edu Apps](https://www.edu-apps.org/code.html) has good documentation 77 | on configuring an lti provider through xml.) 78 | 79 | ```python 80 | LTI_TOOL_CONFIGURATION = { 81 | 'title': '', 82 | 'description': '', 83 | 'launch_url': 'lti/', 84 | 'embed_url': '' or '', 85 | 'embed_icon_url': '' or '', 86 | 'embed_tool_id': '' or '', 87 | 'landing_url': '', 88 | 'course_aware': , 89 | 'course_navigation': , 90 | 'new_tab': , 91 | 'frame_width': , 92 | 'frame_height': , 93 | 'custom_fields': , 94 | 'allow_ta_access': , 95 | 'assignments': { 96 | '': '', 97 | '': '', 98 | '': '', 99 | }, 100 | } 101 | ``` 102 | 103 | To stash custom properties in your session, populate the `LTI_PROPERTY_LIST_EX` variable in your `settings.py`. This is useful for LMS specific `custom_x` parameters that will be needed later. The default value for `LTI_PROPERTY_LIST_EX` is: `['custom_canvas_user_login_id', 'context_title', 'lis_course_offering_sourcedid', 'custom_canvas_api_domain']`. 104 | 105 | ```python 106 | LTI_PROPERTY_LIST_EX = ['custom_parameter1', 'custom_parameter2'] 107 | ``` 108 | 109 | ### Using a cookie based session 110 | 111 | For simplest scenarios you can store data for the LTI request in a session cookie. 112 | This is the quickest way to get up and running, and due to Django's tamper 113 | proof cookie session (assuming a secure secret key) it is a safe option. 114 | Please note that you will need to add the following settings in your 115 | applications `settings.py` to make use of cookies: 116 | 117 | ```python 118 | SESSION_ENGINE = "django.contrib.sessions.backends.signed_cookies" 119 | SESSION_COOKIE_SAMESITE = 'None' 120 | SESSION_COOKIE_SECURE = True 121 | ``` 122 | 123 | Because Canvas sends the information that we are storing in a `POST` 124 | request on the LTI launch, we need to relax the restriction of cookies 125 | only being allowed to be set from the same site. For more information on 126 | `SESSION_COOKIE_SAMESITE` [read here](https://docs.djangoproject.com/en/3.0/ref/settings/#session-cookie-samesite). 127 | 128 | For more information on why `SESSION_COOKIE_SAMESITE` and `SESSION_COOKIE_SECURE` 129 | are needed, if you are choosing to make use of cookies, please read 130 | [here.](https://community.canvaslms.com/t5/Developers-Group/SameSite-Cookies-and-Canvas/ba-p/257967) 131 | 132 | ### Extra LTI Configuration values 133 | 134 | To specify a custom username property, add the `LTI_PROPERTY_USER_USERNAME` variable to your `settings.py`. By default, `LTI_PROPERTY_USER_USERNAME` is `custom_canvas_user_login_id`. This value can vary depending on your LMS. 135 | 136 | To pass through extra LTI parameters to your provider, populate the `LTI_EXTRA_PARAMETERS` variable in your `settings.py`. 137 | This is useful for custom parameters you may specify at installation time. 138 | 139 | ```python 140 | LTI_EXTRA_PARAMETERS = ['lti_version'] # example 141 | ``` 142 | 143 | The ``PYLTI_CONFIG`` variable in your ``settings.py`` configures the 144 | application consumers and secrets. 145 | 146 | ```python 147 | PYLTI_CONFIG = { 148 | 'consumers': { 149 | '': { 150 | 'secret': '' 151 | } 152 | } 153 | } 154 | ``` 155 | 156 | ### Canvas and LTI iframes 157 | 158 | Since LTI tools live within an iframe on Canvas, you **might** need 159 | adjust your `X_FRAME_OPTIONS` setting to allow for the LTI tool to be 160 | opened within the iframe. To the best of our knowledge you probably 161 | don't have to adjust this setting, as Canvas has built a workaround. 162 | For more info [read here](https://github.com/ccnmtl/django-lti-provider/issues/280) 163 | 164 | This ensures that the Django application will allow requests from your 165 | orgs Canvas instance. For more on `X_FRAME_OPTIONS` please 166 | [consult here](https://docs.djangoproject.com/en/3.0/ref/clickjacking/#module-django.middleware.clickjacking). 167 | 168 | ### If you are using a load balancer 169 | 170 | If you happen to have a deployment scenario where you have load balancer 171 | listening on https and routing traffic to nodes that are listening to HTTP, 172 | you will need to add the following line of configuration in `settings.py`: 173 | 174 | ```python 175 | SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') 176 | ``` 177 | 178 | This ensures the correct `launch_url` is generated for the LTI tool. 179 | For more on this setting, [read here](https://docs.djangoproject.com/en/3.1/ref/settings/#secure-proxy-ssl-header). 180 | 181 | ## Assignments 182 | 183 | To support multiple assignments: 184 | 185 | * Create multiple endpoint views 186 | * Add the assignment urls to the `LTI_TOOL_CONFIGURATION['assignments']` map 187 | * Add an assignment, using the External Tool option. 188 | * Canvas: https://community.canvaslms.com/docs/DOC-10384-4152501360 189 | * Update the URL to be `https:///lti/assignment/` 190 | * The `assignment_name` variable should match a landing_url in the LTI_TOOL_CONFIGURATION dict. 191 | * Full example here: [Django LTI Provider Example](https://github.com/ccnmtl/django-lti-provider-example). 192 | 193 | OR 194 | 195 | * Create a single named endpoint that accepts an id 196 | * On Post, django-lti-provider will attempt to reverse the assignment_name/id and then redirect to that view. 197 | -------------------------------------------------------------------------------- /lti_provider/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccnmtl/django-lti-provider/98ddbfae677166051ca5141c1d1d93a053b5feba/lti_provider/__init__.py -------------------------------------------------------------------------------- /lti_provider/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from lti_provider.models import LTICourseContext 3 | 4 | 5 | @admin.register(LTICourseContext) 6 | class AssetAdmin(admin.ModelAdmin): 7 | class Meta: 8 | model = LTICourseContext 9 | 10 | search_fields = ('group', 'faculty_group') 11 | list_display = ('group', 'faculty_group') 12 | -------------------------------------------------------------------------------- /lti_provider/auth.py: -------------------------------------------------------------------------------- 1 | from hashlib import sha1 2 | 3 | from django.utils.encoding import force_bytes 4 | from nameparser import HumanName 5 | from pylti.common import LTIException 6 | from django.contrib.auth import get_user_model 7 | 8 | username_field = get_user_model().USERNAME_FIELD 9 | 10 | 11 | class LTIBackend(object): 12 | 13 | def create_user(self, request, lti, username): 14 | # create the user if necessary 15 | kwargs = {username_field: username, 'password': 'LTI user'} 16 | user = get_user_model()(**kwargs) 17 | user.set_unusable_password() 18 | if username_field != 'email': 19 | user.email = lti.user_email(request) or '' 20 | 21 | name = HumanName(lti.user_fullname(request)) 22 | user.first_name = name.first[:30] 23 | user.last_name = name.last[:30] 24 | 25 | user.save() 26 | return user 27 | 28 | def get_hashed_username(self, request, lti): 29 | # (http://developers.imsglobal.org/userid.html) 30 | # generate a username to avoid overlap with existing system usernames 31 | # sha1 hash result + trunc to 30 chars should result in a valid 32 | # username with low-ish-chance of collisions 33 | uid = force_bytes(lti.consumer_user_id(request)) 34 | return sha1(uid).hexdigest()[:30] 35 | 36 | def get_username(self, request, lti): 37 | username = lti.user_identifier(request) 38 | if not username: 39 | username = self.get_hashed_username(request, lti) 40 | return username 41 | 42 | def find_user(self, request, lti): 43 | # find the user via lms identifier first 44 | kwargs = {username_field: lti.user_identifier(request)} 45 | user_model = get_user_model() 46 | user = user_model.objects.filter(**kwargs).first() 47 | 48 | # find the user via email address, if it exists 49 | email = lti.user_email(request) 50 | if user is None and email: 51 | user = user_model.objects.filter(email=email).first() 52 | 53 | if user is None: 54 | # find the user via hashed username 55 | username = self.get_hashed_username(request, lti) 56 | user = user_model.objects.filter(username=username).first() 57 | 58 | return user 59 | 60 | def find_or_create_user(self, request, lti): 61 | user = self.find_user(request, lti) 62 | if user is None: 63 | username = self.get_username(request, lti) 64 | user = self.create_user(request, lti, username) 65 | 66 | return user 67 | 68 | def authenticate(self, request, lti): 69 | try: 70 | lti.verify(request) 71 | return self.find_or_create_user(request, lti) 72 | except LTIException as e: 73 | lti.clear_session(request) 74 | print(e) 75 | return None 76 | 77 | def get_user(self, user_id): 78 | user_model = get_user_model() 79 | 80 | try: 81 | return user_model.objects.get(pk=user_id) 82 | except user_model.DoesNotExist: 83 | return None 84 | -------------------------------------------------------------------------------- /lti_provider/lti.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | from lti_provider.models import LTICourseContext 4 | from pylti.common import ( 5 | LTIException, LTINotInSessionException, LTI_SESSION_KEY, 6 | verify_request_common, LTIRoleException, LTI_ROLES, LTI_PROPERTY_LIST) 7 | from xml.etree import ElementTree as etree 8 | 9 | 10 | LTI_PROPERTY_LIST_EX = getattr(settings, 11 | 'LTI_PROPERTY_LIST_EX', 12 | [ 13 | 'custom_canvas_user_login_id', 14 | 'context_title', 15 | 'lis_course_offering_sourcedid', 16 | 'custom_canvas_api_domain' 17 | ]) 18 | 19 | LTI_PROPERTY_USER_USERNAME = getattr(settings, 20 | 'LTI_PROPERTY_USER_USERNAME', 21 | 'custom_canvas_user_login_id') 22 | 23 | 24 | class LTI(object): 25 | """ 26 | The LTI Object represents an abstraction of the current LTI 27 | session. It provides callback methods and methods that allow the 28 | developer to inspect LTI basic-launch-request. 29 | 30 | This object is instantiated by the LTIMixin. 31 | """ 32 | 33 | def __init__(self, request_type, role_type): 34 | self.request_type = request_type 35 | self.role_type = role_type 36 | 37 | def clear_session(self, request): 38 | """ 39 | Invalidate the session 40 | """ 41 | request.session.flush() 42 | 43 | def initialize_session(self, request, params): 44 | # All good to go, store all of the LTI params into a 45 | # session dict for use in views 46 | for prop in LTI_PROPERTY_LIST: 47 | if params.get(prop, None): 48 | request.session[prop] = params[prop] 49 | 50 | for prop in LTI_PROPERTY_LIST_EX: 51 | if params.get(prop, None): 52 | request.session[prop] = params[prop] 53 | 54 | def verify(self, request): 55 | """ 56 | Verify if LTI request is valid, validation 57 | depends on arguments 58 | 59 | :raises: LTIException 60 | """ 61 | if self.request_type == 'session': 62 | self._verify_session(request) 63 | elif self.request_type == 'initial': 64 | self._verify_request(request) 65 | elif self.request_type == 'any': 66 | self._verify_any(request) 67 | else: 68 | raise LTIException('Unknown request type') 69 | 70 | return True 71 | 72 | def _params(self, request): 73 | if request.method == 'POST': 74 | return dict(request.POST.items()) 75 | else: 76 | return dict(request.GET.items()) 77 | 78 | def _verify_any(self, request): 79 | """ 80 | Verify that request is in session or initial request. 81 | Guess what type of request is being sent over based on 82 | the request params. 83 | 84 | :raises: LTIException 85 | """ 86 | 87 | # if an oauth_consumer_key is present, assume this is an initial 88 | # launch request and complete a full verification 89 | # otherwise, just check the session for the LTI_SESSION_KEY 90 | params = self._params(request) 91 | if 'oauth_consumer_key' in params: 92 | self._verify_request(request) 93 | else: 94 | self._verify_session(request) 95 | 96 | @staticmethod 97 | def _verify_session(request): 98 | """ 99 | Verify that session was already created 100 | 101 | :raises: LTIException 102 | """ 103 | if not request.session.get(LTI_SESSION_KEY, False): 104 | raise LTINotInSessionException('Session expired or unavailable') 105 | 106 | def _verify_request(self, request): 107 | """ 108 | Verify LTI request 109 | 110 | :raises: LTIException is request validation failed 111 | """ 112 | try: 113 | params = self._params(request) 114 | verify_request_common(self.consumers(), 115 | request.build_absolute_uri(), 116 | request.method, request.META, 117 | params) 118 | 119 | self._validate_role() 120 | 121 | self.clear_session(request) 122 | self.initialize_session(request, params) 123 | request.session[LTI_SESSION_KEY] = True 124 | return True 125 | except LTIException: 126 | self.clear_session(request) 127 | request.session[LTI_SESSION_KEY] = False 128 | raise 129 | 130 | def consumers(self): 131 | """ 132 | Gets consumer's map from config 133 | :return: consumers map 134 | """ 135 | config = getattr(settings, 'PYLTI_CONFIG', dict()) 136 | consumers = config.get('consumers', dict()) 137 | return consumers 138 | 139 | def _validate_role(self): 140 | """ 141 | Check that user is in accepted/specified role 142 | 143 | :exception: LTIException if user is not in roles 144 | """ 145 | if self.role_type != u'any': 146 | if self.role_type in LTI_ROLES: 147 | role_list = LTI_ROLES[self.role_type] 148 | 149 | # find the intersection of the roles 150 | roles = set(role_list) & set(self.user_roles()) 151 | if len(roles) < 1: 152 | raise LTIRoleException('Not authorized.') 153 | else: 154 | raise LTIException("Unknown role {}.".format(self.role_type)) 155 | 156 | return True 157 | 158 | def custom_course_context(self, request): 159 | """ 160 | Returns the custom LTICourseContext id as provided by LTI 161 | 162 | throws: KeyError or ValueError or LTICourseContext.DoesNotExist 163 | :return: context -- the LTICourseContext instance or None 164 | """ 165 | return LTICourseContext.objects.get( 166 | enable=True, 167 | uuid=self.custom_course_context(request)) 168 | 169 | def canvas_domain(self, request): 170 | return request.session.get('custom_canvas_api_domain', None) 171 | 172 | def consumer_user_id(self, request): 173 | return "%s-%s" % \ 174 | (self.oauth_consumer_key(request), self.user_id(request)) 175 | 176 | def course_context(self, request): 177 | return request.session.get('context_id', None) 178 | 179 | def course_title(self, request): 180 | return request.session.get('context_title', None) 181 | 182 | def is_administrator(self, request): 183 | return 'administrator' in request.session.get('roles', '').lower() 184 | 185 | def is_instructor(self, request): 186 | roles = request.session.get('roles', '').lower() 187 | return 'instructor' in roles or 'staff' in roles 188 | 189 | def lis_outcome_service_url(self, request): 190 | return request.session.get('lis_outcome_service_url', None) 191 | 192 | def lis_result_sourcedid(self, request): 193 | return request.session.get('lis_result_sourcedid', None) 194 | 195 | def oauth_consumer_key(self, request): 196 | return request.session.get('oauth_consumer_key', None) 197 | 198 | def user_email(self, request): 199 | return request.session.get('lis_person_contact_email_primary', None) 200 | 201 | def user_fullname(self, request): 202 | name = request.session.get('lis_person_name_full', None) 203 | if not name or len(name) < 1: 204 | name = self.user_id(request) 205 | 206 | return name or '' 207 | 208 | def user_id(self, request): 209 | return request.session.get('user_id', None) 210 | 211 | def user_identifier(self, request): 212 | return request.session.get(LTI_PROPERTY_USER_USERNAME, None) 213 | 214 | def user_roles(self, request): # pylint: disable=no-self-use 215 | """ 216 | LTI roles of the authenticated user 217 | 218 | :return: roles 219 | """ 220 | roles = request.session.get('roles', None) 221 | if not roles: 222 | return [] 223 | return roles.lower().split(',') 224 | 225 | def sis_course_id(self, request): 226 | return request.session.get('lis_course_offering_sourcedid', None) 227 | 228 | def generate_request_xml(self, message_identifier_id, operation, 229 | lis_result_sourcedid, score, launch_url): 230 | # pylint: disable=too-many-locals 231 | """ 232 | Generates LTI 1.1 XML for posting result to LTI consumer. 233 | 234 | :param message_identifier_id: 235 | :param operation: 236 | :param lis_result_sourcedid: 237 | :param score: 238 | :return: XML string 239 | """ 240 | root = etree.Element(u'imsx_POXEnvelopeRequest', 241 | xmlns=u'http://www.imsglobal.org/services/' 242 | u'ltiv1p1/xsd/imsoms_v1p0') 243 | 244 | header = etree.SubElement(root, 'imsx_POXHeader') 245 | header_info = etree.SubElement(header, 'imsx_POXRequestHeaderInfo') 246 | version = etree.SubElement(header_info, 'imsx_version') 247 | version.text = 'V1.0' 248 | message_identifier = etree.SubElement(header_info, 249 | 'imsx_messageIdentifier') 250 | message_identifier.text = message_identifier_id 251 | body = etree.SubElement(root, 'imsx_POXBody') 252 | xml_request = etree.SubElement( 253 | body, '%s%s' % (operation, 'Request')) 254 | record = etree.SubElement(xml_request, 'resultRecord') 255 | 256 | guid = etree.SubElement(record, 'sourcedGUID') 257 | 258 | sourcedid = etree.SubElement(guid, 'sourcedId') 259 | sourcedid.text = lis_result_sourcedid 260 | if score is not None: 261 | result = etree.SubElement(record, 'result') 262 | result_score = etree.SubElement(result, 'resultScore') 263 | language = etree.SubElement(result_score, 'language') 264 | language.text = 'en' 265 | text_string = etree.SubElement(result_score, 'textString') 266 | text_string.text = score.__str__() 267 | if launch_url: 268 | result_data = etree.SubElement(result, 'resultData') 269 | lti_launch_url = etree.SubElement( 270 | result_data, 'ltiLaunchUrl') 271 | lti_launch_url.text = launch_url 272 | ret = "\n{}".format( 273 | etree.tostring(root, encoding='utf-8').decode('utf-8')) 274 | 275 | return ret 276 | -------------------------------------------------------------------------------- /lti_provider/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('auth', '0006_require_contenttypes_0002'), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='LTICourseContext', 16 | fields=[ 17 | ('id', models.AutoField(verbose_name='ID', serialize=False, 18 | auto_created=True, primary_key=True)), 19 | ('lms_context_id', models.TextField()), 20 | ('faculty_group', models.ForeignKey( 21 | on_delete=models.CASCADE, 22 | related_name='course_faculty_group', to='auth.Group')), 23 | ('group', models.ForeignKey( 24 | on_delete=models.CASCADE, 25 | related_name='course_group', 26 | to='auth.Group')), 27 | ], 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /lti_provider/migrations/0002_auto_20151231_1107.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import migrations, models 5 | import uuid 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('lti_provider', '0001_initial'), 12 | ] 13 | 14 | operations = [ 15 | migrations.RemoveField( 16 | model_name='lticoursecontext', 17 | name='lms_context_id', 18 | ), 19 | migrations.AddField( 20 | model_name='lticoursecontext', 21 | name='uuid', 22 | field=models.UUIDField(default=uuid.uuid4, editable=False), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /lti_provider/migrations/0003_auto_20151231_1109.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('lti_provider', '0002_auto_20151231_1107'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterUniqueTogether( 15 | name='lticoursecontext', 16 | unique_together=set([('group', 'faculty_group')]), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /lti_provider/migrations/0004_lticoursecontext_enable.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('lti_provider', '0003_auto_20151231_1109'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='lticoursecontext', 16 | name='enable', 17 | field=models.BooleanField(default=False), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /lti_provider/migrations/0005_auto_20171009_1234.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.5 on 2017-10-09 12:34 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class MySQLAddLMSCourseContext(migrations.AddField): 9 | 10 | def database_forwards( 11 | self, app_label, schema_editor, from_state, to_state): 12 | 13 | if schema_editor.connection.vendor.startswith("mysql"): 14 | super(MySQLAddLMSCourseContext, self).database_forwards( 15 | app_label, schema_editor, from_state, to_state) 16 | 17 | 18 | class PostgresAddLMSCourseContext(migrations.AddField): 19 | 20 | def database_forwards( 21 | self, app_label, schema_editor, from_state, to_state): 22 | 23 | if schema_editor.connection.vendor.startswith("postgres"): 24 | super(PostgresAddLMSCourseContext, self).database_forwards( 25 | app_label, schema_editor, from_state, to_state) 26 | 27 | 28 | class Migration(migrations.Migration): 29 | 30 | dependencies = [ 31 | ('lti_provider', '0004_lticoursecontext_enable'), 32 | ] 33 | 34 | operations = [ 35 | migrations.RemoveField( 36 | model_name='lticoursecontext', 37 | name='enable', 38 | ), 39 | migrations.RemoveField( 40 | model_name='lticoursecontext', 41 | name='uuid', 42 | ), 43 | PostgresAddLMSCourseContext( 44 | model_name='lticoursecontext', 45 | name='lms_course_context', 46 | field=models.TextField(null=True, unique=True) 47 | ), 48 | MySQLAddLMSCourseContext( 49 | model_name='lticoursecontext', 50 | name='lms_course_context', 51 | field=models.CharField(max_length=255, null=True, unique=True) 52 | ) 53 | ] 54 | -------------------------------------------------------------------------------- /lti_provider/migrations/0006_auto_20180205_1636.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('lti_provider', '0005_auto_20171009_1234'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='lticoursecontext', 16 | name='lms_course_context', 17 | field=models.CharField(max_length=255, unique=True, null=True), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /lti_provider/migrations/0007_alter_lticoursecontext_lms_course_context.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.12 on 2022-04-05 23:13 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('lti_provider', '0006_auto_20180205_1636'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='lticoursecontext', 15 | name='lms_course_context', 16 | field=models.CharField(max_length=255, unique=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /lti_provider/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccnmtl/django-lti-provider/98ddbfae677166051ca5141c1d1d93a053b5feba/lti_provider/migrations/__init__.py -------------------------------------------------------------------------------- /lti_provider/mixins.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib.auth import authenticate, login 3 | try: 4 | from django.urls import reverse 5 | except ImportError: 6 | from django.core.urlresolvers import reverse 7 | from django.http.response import HttpResponseRedirect 8 | from lti_provider.lti import LTI 9 | from lti_provider.models import LTICourseContext 10 | 11 | 12 | class LTIAuthMixin(object): 13 | role_type = 'any' 14 | request_type = 'any' 15 | 16 | def join_groups(self, request, lti, ctx): 17 | # add the user to the requested groups 18 | request.user.groups.add(ctx.group) 19 | for role in lti.user_roles(request): 20 | role = role.lower() 21 | if ('staff' in role or 22 | 'instructor' in role or 23 | 'administrator' in role): 24 | request.user.groups.add(ctx.faculty_group) 25 | break 26 | 27 | if settings.LTI_TOOL_CONFIGURATION.get('allow_ta_access', False): 28 | if 'teachingassistant' in role: 29 | request.user.groups.add(ctx.faculty_group) 30 | break 31 | 32 | def course_configuration(self, request, lti): 33 | # check if course is configured 34 | if settings.LTI_TOOL_CONFIGURATION['course_aware']: 35 | ctx = LTICourseContext.objects.get( 36 | lms_course_context=lti.course_context(request)) 37 | 38 | # add user to the course 39 | self.join_groups(request, lti, ctx) 40 | 41 | def dispatch(self, request, *args, **kwargs): 42 | lti = LTI(self.request_type, self.role_type) 43 | 44 | # validate the user via oauth 45 | user = authenticate(request=request, lti=lti) 46 | if user is None: 47 | lti.clear_session(request) 48 | return HttpResponseRedirect(reverse('lti-fail-auth')) 49 | 50 | # login 51 | login(request, user) 52 | 53 | # configure course groups if requested 54 | try: 55 | self.course_configuration(request, lti) 56 | except (KeyError, ValueError, LTICourseContext.DoesNotExist): 57 | return HttpResponseRedirect(reverse('lti-course-config')) 58 | 59 | self.lti = lti 60 | return super(LTIAuthMixin, self).dispatch(request, *args, **kwargs) 61 | 62 | 63 | class LTILoggedInMixin(object): 64 | role_type = 'any' 65 | request_type = 'any' 66 | 67 | def dispatch(self, request, *args, **kwargs): 68 | lti = LTI(self.request_type, self.role_type) 69 | 70 | # validate the user via oauth 71 | user = authenticate(request=request, lti=lti) 72 | if user is None: 73 | lti.clear_session(request) 74 | return HttpResponseRedirect(reverse('lti-fail-auth')) 75 | 76 | # login 77 | login(request, user) 78 | 79 | self.lti = lti 80 | return super(LTILoggedInMixin, self).dispatch(request, *args, **kwargs) 81 | -------------------------------------------------------------------------------- /lti_provider/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import Group 2 | from django.db import models 3 | 4 | 5 | class LTICourseContext(models.Model): 6 | group = models.ForeignKey(Group, 7 | related_name='course_group', 8 | on_delete=models.CASCADE) 9 | faculty_group = models.ForeignKey(Group, 10 | related_name='course_faculty_group', 11 | on_delete=models.CASCADE) 12 | lms_course_context = models.CharField(unique=True, 13 | max_length=255) 14 | 15 | class Meta: 16 | unique_together = (('group', 'faculty_group'),) 17 | -------------------------------------------------------------------------------- /lti_provider/templates/lti_provider/config.xml: -------------------------------------------------------------------------------- 1 | 2 | 11 | {{launch_url}} 12 | {% if debug %}Dev {% endif %}{{title}} 13 | {{description}} 14 | 15 | public 16 | {{domain}} 17 | {% if debug %}Dev{% endif %} {{title}} 18 | {{frame_width}} 19 | {{frame_height}} 20 | 21 | {% if course_navigation %} 22 | {% if course_navigation == True %} 23 | 24 | disabled 25 | true 26 | 27 | {% elif course_navigation.items %} 28 | 29 | {% for key, value in course_navigation.items %} 30 | {{value}} 31 | {% endfor %} 32 | 33 | {% endif %} 34 | {% endif %} 35 | {% if embed_tool_id %} 36 | 37 | disabled 38 | true 39 | {{STATIC_URL}}{{embed_icon_url}} 40 | 665 41 | 600 42 | {{embed_tool_id}} 43 | 44 | {% endif %} 45 | {% if custom_fields %} 46 | 47 | {% for key, value in custom_fields.items %} 48 | {{value}} 49 | {% endfor %} 50 | 51 | {% endif %} 52 | 53 | 54 | -------------------------------------------------------------------------------- /lti_provider/templates/lti_provider/fail_auth.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 9 | 12 | 13 | 14 |
15 |
16 |
17 |
18 |

Authentication Failed

19 |

20 | Your {{title}} course was not able to authenticate 21 | with this external component. 22 |

23 | 24 |

25 | 26 | Please report this issue to your administrator 27 | 28 |

29 |
30 |
31 |
32 |
33 | 34 | 35 | -------------------------------------------------------------------------------- /lti_provider/templates/lti_provider/fail_course_configuration.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Course Configuration 4 | 9 | 10 | 11 | 12 |
13 |
14 |
15 |
16 |

Course Configuration

17 |

18 | Your {{title}} course has not been configured to use this component.

19 |

20 | 21 | Please contact your help desk to enable the component.
22 | 23 | {% if is_instructor or is_administrator %} 24 |

Administrators & Instructors

25 |

@todo - tell them how to enable

26 | {% endif %} 27 |
28 |
29 |
30 |
31 |
32 | 33 | 34 | -------------------------------------------------------------------------------- /lti_provider/templates/lti_provider/landing_page.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 9 | 10 |
11 |
12 |
13 |
14 |

Custom Landing Page

15 |

16 | Launch Now 17 |

18 |
19 |
20 |
21 |
22 | 23 | 24 | -------------------------------------------------------------------------------- /lti_provider/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccnmtl/django-lti-provider/98ddbfae677166051ca5141c1d1d93a053b5feba/lti_provider/templatetags/__init__.py -------------------------------------------------------------------------------- /lti_provider/templatetags/lti_utils.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | from lti_provider.lti import LTI 3 | from pylti.common import LTINotInSessionException 4 | 5 | register = template.Library() 6 | 7 | 8 | @register.simple_tag 9 | def lti_session(request): 10 | try: 11 | lti = LTI('session', 'any') 12 | if lti.verify(request): 13 | return lti 14 | except LTINotInSessionException: 15 | return None 16 | -------------------------------------------------------------------------------- /lti_provider/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccnmtl/django-lti-provider/98ddbfae677166051ca5141c1d1d93a053b5feba/lti_provider/tests/__init__.py -------------------------------------------------------------------------------- /lti_provider/tests/factories.py: -------------------------------------------------------------------------------- 1 | try: 2 | from urllib.parse import parse_qs, urlparse, urlencode 3 | except ImportError: 4 | from urllib import urlencode 5 | from urlparse import parse_qs, urlparse 6 | 7 | from django.contrib.auth.models import User, Group, AnonymousUser 8 | from django.contrib.sessions.middleware import SessionMiddleware 9 | from django.test.client import RequestFactory 10 | import factory 11 | import oauthlib.oauth1 12 | from oauthlib.oauth1.rfc5849 import CONTENT_TYPE_FORM_URLENCODED 13 | 14 | from lti_provider.models import LTICourseContext 15 | 16 | 17 | BASE_LTI_PARAMS = { 18 | u'launch_presentation_return_url': u'/asset/', 19 | u'lis_person_contact_email_primary': u'foo@bar.com', 20 | u'lis_person_name_full': u'Foo Bar Baz', 21 | u'lis_result_sourcedid': u'course-v1%3AedX%2BDemoX%2BDemo_Course' 22 | u':-724d6c2b5fcc4a17a26b9120a1d463aa:student', 23 | u'lti_message_type': u'basic-lti-launch-request', 24 | u'lti_version': u'LTI-1p0', 25 | u'roles': 26 | u'urn:lti:instrole:ims/lis/Instructor,urn:lti:instrole:ims/lis/Staff', 27 | u'resource_link_id': u'-724d6c2b5fcc4a17a26b9120a1d463aa', 28 | u'user_id': u'student', 29 | } 30 | 31 | CONSUMERS = { 32 | '__consumer_key__': {'secret': '__lti_secret__'} 33 | } 34 | 35 | 36 | def generate_lti_request(course_context=None, provider=None, use=None): 37 | """ 38 | This code generated valid LTI 1.0 basic-lti-launch-request request 39 | """ 40 | client = oauthlib.oauth1.Client('__consumer_key__', 41 | client_secret='__lti_secret__', 42 | signature_method=oauthlib.oauth1. 43 | SIGNATURE_HMAC, 44 | signature_type=oauthlib.oauth1. 45 | SIGNATURE_TYPE_QUERY) 46 | 47 | params = BASE_LTI_PARAMS.copy() 48 | if course_context: 49 | params.update({'context_id': course_context.lms_course_context}) 50 | if provider: 51 | params.update({'tool_consumer_info_product_family_code': provider}) 52 | if use: 53 | params.update({'ext_content_intended_use': use}) 54 | 55 | signature = client.sign( 56 | 'http://testserver/lti/', 57 | http_method='POST', body=urlencode(params), 58 | headers={'Content-Type': CONTENT_TYPE_FORM_URLENCODED}) 59 | 60 | url_parts = urlparse(signature[0]) 61 | query_string = parse_qs(url_parts.query, keep_blank_values=True) 62 | verify_params = dict() 63 | for key, value in query_string.items(): 64 | verify_params[key] = value[0] 65 | 66 | params.update(verify_params) 67 | 68 | request = RequestFactory().post('/lti/', params) 69 | 70 | middleware = SessionMiddleware() 71 | middleware.process_request(request) 72 | request.session.save() 73 | 74 | request.user = AnonymousUser() 75 | return request 76 | 77 | 78 | class UserFactory(factory.django.DjangoModelFactory): 79 | class Meta: 80 | model = User 81 | username = factory.Sequence(lambda n: 'user%d' % n) 82 | password = factory.PostGenerationMethodCall('set_password', 'test') 83 | 84 | 85 | class GroupFactory(factory.django.DjangoModelFactory): 86 | class Meta: 87 | model = Group 88 | name = factory.Sequence(lambda n: 'group %s' % n) 89 | 90 | 91 | class LTICourseContextFactory(factory.django.DjangoModelFactory): 92 | class Meta: 93 | model = LTICourseContext 94 | 95 | group = factory.SubFactory(GroupFactory) 96 | faculty_group = factory.SubFactory(GroupFactory) 97 | lms_course_context = factory.Sequence(lambda n: 'lti%d' % n) 98 | -------------------------------------------------------------------------------- /lti_provider/tests/test_auth.py: -------------------------------------------------------------------------------- 1 | from django.contrib.sessions.middleware import SessionMiddleware 2 | from django.test.client import RequestFactory 3 | from django.test.testcases import TestCase 4 | 5 | from lti_provider.auth import LTIBackend 6 | from lti_provider.lti import LTI 7 | from lti_provider.tests.factories import BASE_LTI_PARAMS, UserFactory 8 | 9 | 10 | class LTIBackendTest(TestCase): 11 | 12 | def setUp(self): 13 | self.backend = LTIBackend() 14 | 15 | self.request = RequestFactory() 16 | self.request.COOKIES = {} 17 | middleware = SessionMiddleware() 18 | middleware.process_request(self.request) 19 | self.request.session.save() 20 | 21 | for prop, value in BASE_LTI_PARAMS.items(): 22 | self.request.session[prop] = value 23 | 24 | self.lti = LTI('initial', 'any') 25 | 26 | def test_create_user(self): 27 | user = self.backend.create_user(self.request, self.lti, '12345') 28 | self.assertFalse(user.has_usable_password()) 29 | self.assertEqual(user.email, 'foo@bar.com') 30 | self.assertEqual(user.get_full_name(), 'Foo Baz') 31 | 32 | def test_create_user_no_full_name(self): 33 | self.request.session.pop('lis_person_name_full') 34 | user = self.backend.create_user(self.request, self.lti, '12345') 35 | self.assertEqual(user.get_full_name(), 'student') 36 | 37 | def test_create_user_empty_full_name(self): 38 | self.request.session['lis_person_name_full'] = '' 39 | user = self.backend.create_user(self.request, self.lti, '12345') 40 | self.assertEqual(user.get_full_name(), 'student') 41 | 42 | def test_create_user_long_name(self): 43 | self.request.session['lis_person_name_full'] = ( 44 | 'Pneumonoultramicroscopicsilicovolcanoconiosis ' 45 | 'Supercalifragilisticexpialidocious') 46 | user = self.backend.create_user(self.request, self.lti, '12345') 47 | self.assertEqual( 48 | user.get_full_name(), 49 | 'Pneumonoultramicroscopicsilico Supercalifragilisticexpialidoc') 50 | 51 | def test_find_or_create_user1(self): 52 | # via email 53 | user = UserFactory(email='foo@bar.com') 54 | self.assertEqual( 55 | self.backend.find_or_create_user(self.request, self.lti), user) 56 | 57 | def test_find_or_create_user2(self): 58 | # via lms username 59 | username = 'uni123' 60 | self.request.session['custom_canvas_user_login_id'] = username 61 | user = UserFactory(username=username) 62 | self.assertEqual( 63 | self.backend.find_or_create_user(self.request, self.lti), user) 64 | 65 | def test_find_or_create_user3(self): 66 | # via hashed username 67 | self.request.session['oauth_consumer_key'] = '1234567890' 68 | username = self.backend.get_hashed_username(self.request, self.lti) 69 | user = UserFactory(username=username) 70 | self.assertEqual( 71 | self.backend.find_or_create_user(self.request, self.lti), user) 72 | 73 | def test_find_or_create_user4(self): 74 | # new user 75 | self.request.session['oauth_consumer_key'] = '1234567890' 76 | user = self.backend.find_or_create_user(self.request, self.lti) 77 | self.assertFalse(user.has_usable_password()) 78 | self.assertEqual(user.email, 'foo@bar.com') 79 | self.assertEqual(user.get_full_name(), 'Foo Baz') 80 | 81 | username = self.backend.get_hashed_username(self.request, self.lti) 82 | self.assertEqual(user.username, username) 83 | 84 | def test_get_user(self): 85 | user = UserFactory() 86 | self.assertIsNone(self.backend.get_user(1234)) 87 | self.assertEqual(self.backend.get_user(user.id), user) 88 | -------------------------------------------------------------------------------- /lti_provider/tests/test_lti.py: -------------------------------------------------------------------------------- 1 | from django.contrib.sessions.middleware import SessionMiddleware 2 | from django.test.client import RequestFactory 3 | from django.test.testcases import TestCase 4 | from lti_provider.lti import LTI 5 | from lti_provider.tests.factories import BASE_LTI_PARAMS, CONSUMERS, \ 6 | generate_lti_request 7 | from pylti.common import LTI_SESSION_KEY, LTINotInSessionException 8 | 9 | 10 | class LTITest(TestCase): 11 | 12 | def setUp(self): 13 | self.request = RequestFactory() 14 | self.request.COOKIES = {} 15 | middleware = SessionMiddleware() 16 | middleware.process_request(self.request) 17 | self.request.session.save() 18 | 19 | for prop, value in BASE_LTI_PARAMS.items(): 20 | self.request.session[prop] = value 21 | 22 | self.lti = LTI('initial', 'any') 23 | 24 | self.emptyRequest = RequestFactory() 25 | self.emptyRequest.COOKIES = {} 26 | middleware = SessionMiddleware() 27 | middleware.process_request(self.emptyRequest) 28 | self.emptyRequest.session.save() 29 | 30 | def test_init(self): 31 | self.assertEqual(self.lti.request_type, 'initial') 32 | self.assertEqual(self.lti.role_type, 'any') 33 | 34 | def test_consumer_user_id(self): 35 | self.request.session['oauth_consumer_key'] = '1234567890' 36 | self.assertEqual( 37 | self.lti.consumer_user_id(self.request), '1234567890-student') 38 | 39 | def test_user_email(self): 40 | self.assertIsNone(self.lti.user_email(self.emptyRequest)) 41 | self.assertEqual(self.lti.user_email(self.request), 'foo@bar.com') 42 | 43 | def test_user_fullname(self): 44 | self.assertEqual(self.lti.user_fullname(self.emptyRequest), '') 45 | 46 | self.assertEqual(self.lti.user_fullname(self.request), 'Foo Bar Baz') 47 | 48 | def test_user_roles(self): 49 | self.assertEqual(self.lti.user_roles(self.emptyRequest), []) 50 | 51 | self.assertEqual(self.lti.user_roles(self.request), [ 52 | u'urn:lti:instrole:ims/lis/instructor', 53 | u'urn:lti:instrole:ims/lis/staff']) 54 | 55 | self.assertTrue(self.lti.is_instructor(self.request)) 56 | self.assertFalse(self.lti.is_administrator(self.request)) 57 | 58 | def test_consumers(self): 59 | with self.settings(PYLTI_CONFIG={'consumers': CONSUMERS}): 60 | self.assertEqual(self.lti.consumers(), CONSUMERS) 61 | 62 | def test_params(self): 63 | factory = RequestFactory() 64 | request = factory.post('/', {'post': 'data'}) 65 | params = self.lti._params(request) 66 | self.assertTrue('post' in params) 67 | 68 | request = factory.post('/', {'get': 'data'}) 69 | params = self.lti._params(request) 70 | self.assertTrue('get' in params) 71 | 72 | def test_verify_any(self): 73 | lti = LTI('any', 'any') 74 | request = generate_lti_request() 75 | 76 | with self.settings(PYLTI_CONFIG={'consumers': CONSUMERS}): 77 | # test_verify_request 78 | lti.verify(request) 79 | self.assertTrue(request.session[LTI_SESSION_KEY]) 80 | 81 | # test_verify_session 82 | self.assertTrue(lti.verify(request)) 83 | 84 | def test_verify_session(self): 85 | lti = LTI('session', 'any') 86 | request = RequestFactory().post('/lti/') 87 | 88 | with self.assertRaises(LTINotInSessionException): 89 | request.session = {} 90 | lti.verify(request) 91 | 92 | request.session = {LTI_SESSION_KEY: True} 93 | self.assertTrue(lti.verify(request)) 94 | 95 | def test_verify_request(self): 96 | with self.settings(PYLTI_CONFIG={'consumers': CONSUMERS}): 97 | request = generate_lti_request() 98 | lti = LTI('initial', 'any') 99 | lti.verify(request) 100 | self.assertTrue(request.session[LTI_SESSION_KEY]) 101 | -------------------------------------------------------------------------------- /lti_provider/tests/test_templatetags.py: -------------------------------------------------------------------------------- 1 | from django.test.testcases import TestCase 2 | from lti_provider.lti import LTI 3 | from lti_provider.templatetags.lti_utils import lti_session 4 | from lti_provider.tests.factories import CONSUMERS, generate_lti_request 5 | 6 | 7 | class LTITemplateTags(TestCase): 8 | 9 | def test_lti_session(self): 10 | with self.settings(PYLTI_CONFIG={'consumers': CONSUMERS}): 11 | request = generate_lti_request() 12 | self.assertIsNone(lti_session(request)) 13 | 14 | # initialize the session 15 | lti = LTI('initial', 'any') 16 | self.assertTrue(lti.verify(request)) 17 | 18 | lti = lti_session(request) 19 | self.assertEqual(lti.user_id(request), 'student') 20 | -------------------------------------------------------------------------------- /lti_provider/tests/test_views.py: -------------------------------------------------------------------------------- 1 | from django.contrib.sessions.middleware import SessionMiddleware 2 | try: 3 | from django.urls import reverse 4 | except ImportError: 5 | from django.core.urlresolvers import reverse 6 | from django.test import TestCase, RequestFactory, Client 7 | from lti_provider.lti import LTI 8 | from lti_provider.tests.factories import LTICourseContextFactory, \ 9 | UserFactory, CONSUMERS, generate_lti_request, BASE_LTI_PARAMS 10 | from lti_provider.views import LTIAuthMixin, LTIRoutingView 11 | from pylti.common import LTI_SESSION_KEY 12 | 13 | import xml.etree.ElementTree as ET 14 | 15 | TEST_LTI_TOOL_CONFIGURATION = { 16 | 'title': 'Test Application', 17 | 'description': 'Test Description.', 18 | 'launch_url': 'lti/', 19 | 'embed_url': 'asset/embed/', 20 | 'embed_icon_url': '', 21 | 'embed_tool_id': '', 22 | 'landing_url': '{}://{}/', 23 | 'course_aware': True, 24 | 'course_navigation': True, 25 | 'new_tab': False, 26 | 'allow_ta_access': False 27 | } 28 | 29 | 30 | class LTIViewTest(TestCase): 31 | 32 | def setUp(self): 33 | self.request = RequestFactory() 34 | self.request.COOKIES = {} 35 | middleware = SessionMiddleware() 36 | middleware.process_request(self.request) 37 | self.request.session.save() 38 | 39 | for prop, value in BASE_LTI_PARAMS.items(): 40 | self.request.session[prop] = value 41 | 42 | self.lti = LTI('initial', 'any') 43 | 44 | def test_join_groups(self): 45 | mixin = LTIAuthMixin() 46 | ctx = LTICourseContextFactory() 47 | user = UserFactory() 48 | self.request.user = user 49 | 50 | mixin.join_groups(self.request, self.lti, ctx) 51 | self.assertTrue(user in ctx.group.user_set.all()) 52 | self.assertTrue(user in ctx.faculty_group.user_set.all()) 53 | 54 | def test_join_groups_student(self): 55 | mixin = LTIAuthMixin() 56 | ctx = LTICourseContextFactory() 57 | user = UserFactory() 58 | self.request.user = user 59 | 60 | with self.settings( 61 | LTI_TOOL_CONFIGURATION=TEST_LTI_TOOL_CONFIGURATION): 62 | self.request.session['roles'] = u'Learner' 63 | mixin.join_groups(self.request, self.lti, ctx) 64 | self.assertTrue(user in ctx.group.user_set.all()) 65 | self.assertFalse(user in ctx.faculty_group.user_set.all()) 66 | 67 | def test_join_groups_teachingassistant_false(self): 68 | mixin = LTIAuthMixin() 69 | ctx = LTICourseContextFactory() 70 | user = UserFactory() 71 | self.request.user = user 72 | lti_tool_config1 = TEST_LTI_TOOL_CONFIGURATION.copy() 73 | 74 | with self.settings( 75 | LTI_TOOL_CONFIGURATION=lti_tool_config1): 76 | self.request.session['roles'] = \ 77 | u'urn:lti:role:ims/liss/TeachingAssistant' 78 | mixin.join_groups(self.request, self.lti, ctx) 79 | self.assertTrue(user in ctx.group.user_set.all()) 80 | self.assertFalse(user in ctx.faculty_group.user_set.all()) 81 | 82 | def test_join_groups_teachingassistant_true(self): 83 | mixin = LTIAuthMixin() 84 | ctx = LTICourseContextFactory() 85 | user = UserFactory() 86 | self.request.user = user 87 | lti_tool_config2 = TEST_LTI_TOOL_CONFIGURATION.copy() 88 | lti_tool_config2['allow_ta_access'] = True 89 | 90 | with self.settings( 91 | LTI_TOOL_CONFIGURATION=lti_tool_config2): 92 | self.request.session['roles'] = \ 93 | u'urn:lti:role:ims/lis/TeachingAssistant' 94 | mixin.join_groups(self.request, self.lti, ctx) 95 | self.assertTrue(user in ctx.group.user_set.all()) 96 | self.assertTrue(user in ctx.faculty_group.user_set.all()) 97 | 98 | def test_missing_configuration(self): 99 | mixin = LTIAuthMixin() 100 | ctx = LTICourseContextFactory() 101 | user = UserFactory() 102 | self.request.user = user 103 | lti_tool_config2 = TEST_LTI_TOOL_CONFIGURATION.copy() 104 | del lti_tool_config2['allow_ta_access'] 105 | 106 | with self.settings( 107 | LTI_TOOL_CONFIGURATION=lti_tool_config2): 108 | self.request.session['roles'] = \ 109 | u'urn:lti:role:ims/lis/TeachingAssistant' 110 | mixin.join_groups(self.request, self.lti, ctx) 111 | self.assertTrue(user in ctx.group.user_set.all()) 112 | self.assertFalse(user in ctx.faculty_group.user_set.all()) 113 | 114 | def test_launch_invalid_user(self): 115 | request = generate_lti_request() 116 | 117 | response = LTIRoutingView().dispatch(request) 118 | self.assertEqual(response.status_code, 302) 119 | 120 | self.assertEqual(response.url, reverse('lti-fail-auth')) 121 | self.assertFalse(request.session.get(LTI_SESSION_KEY, False)) 122 | 123 | def test_launch_invalid_course(self): 124 | with self.settings( 125 | LTI_TOOL_CONFIGURATION=TEST_LTI_TOOL_CONFIGURATION, 126 | PYLTI_CONFIG={'consumers': CONSUMERS}): 127 | request = generate_lti_request() 128 | 129 | response = LTIRoutingView().dispatch(request) 130 | self.assertEqual(response.status_code, 302) 131 | self.assertEqual(response.url, reverse('lti-course-config')) 132 | self.assertTrue(request.session.get(LTI_SESSION_KEY, False)) 133 | 134 | def test_launch(self): 135 | with self.settings( 136 | LTI_TOOL_CONFIGURATION=TEST_LTI_TOOL_CONFIGURATION, 137 | PYLTI_CONFIG={'consumers': CONSUMERS}, 138 | LTI_EXTRA_PARAMETERS=['lti_version']): 139 | ctx = LTICourseContextFactory() 140 | request = generate_lti_request(ctx) 141 | 142 | view = LTIRoutingView() 143 | view.request = request 144 | 145 | response = view.dispatch(request) 146 | self.assertEqual(response.status_code, 302) 147 | 148 | landing = 'http://testserver/?lti_version=LTI-1p0&' 149 | self.assertEqual( 150 | response.url, landing.format(ctx.lms_course_context)) 151 | 152 | self.assertIsNotNone(request.session[LTI_SESSION_KEY]) 153 | user = request.user 154 | self.assertFalse(user.has_usable_password()) 155 | self.assertEqual(user.email, 'foo@bar.com') 156 | self.assertEqual(user.get_full_name(), 'Foo Baz') 157 | self.assertTrue(user in ctx.group.user_set.all()) 158 | self.assertTrue(user in ctx.faculty_group.user_set.all()) 159 | 160 | def test_launch_custom_landing_page(self): 161 | with self.settings( 162 | LTI_TOOL_CONFIGURATION=TEST_LTI_TOOL_CONFIGURATION, 163 | PYLTI_CONFIG={'consumers': CONSUMERS}, 164 | LTI_EXTRA_PARAMETERS=['lti_version']): 165 | ctx = LTICourseContextFactory() 166 | request = generate_lti_request(ctx, 'canvas') 167 | 168 | view = LTIRoutingView() 169 | view.request = request 170 | 171 | response = view.dispatch(request) 172 | landing = 'http://testserver/lti/landing/{}/?lti_version=LTI-1p0&' 173 | self.assertEqual(response.status_code, 302) 174 | self.assertTrue( 175 | response.url, 176 | landing.format(ctx.lms_course_context)) 177 | 178 | self.assertIsNotNone(request.session[LTI_SESSION_KEY]) 179 | user = request.user 180 | self.assertFalse(user.has_usable_password()) 181 | self.assertEqual(user.email, 'foo@bar.com') 182 | self.assertEqual(user.get_full_name(), 'Foo Baz') 183 | self.assertTrue(user in ctx.group.user_set.all()) 184 | self.assertTrue(user in ctx.faculty_group.user_set.all()) 185 | 186 | def test_embed(self): 187 | with self.settings(PYLTI_CONFIG={'consumers': CONSUMERS}, 188 | LTI_EXTRA_PARAMETERS=['lti_version'], 189 | LTI_TOOL_CONFIGURATION=TEST_LTI_TOOL_CONFIGURATION): 190 | ctx = LTICourseContextFactory() 191 | request = generate_lti_request(ctx, 'canvas', 'embed') 192 | 193 | view = LTIRoutingView() 194 | view.request = request 195 | 196 | response = view.dispatch(request) 197 | self.assertEqual(response.status_code, 302) 198 | self.assertEqual( 199 | response.url, 200 | 'http://testserver/asset/embed/?return_url=/asset/' 201 | '<i_version=LTI-1p0&') 202 | 203 | self.assertIsNotNone(request.session[LTI_SESSION_KEY]) 204 | user = request.user 205 | self.assertFalse(user.has_usable_password()) 206 | self.assertEqual(user.email, 'foo@bar.com') 207 | self.assertEqual(user.get_full_name(), 'Foo Baz') 208 | self.assertTrue(user in ctx.group.user_set.all()) 209 | self.assertTrue(user in ctx.faculty_group.user_set.all()) 210 | 211 | def test_course_navigation(self): 212 | with self.settings( 213 | LTI_TOOL_CONFIGURATION=TEST_LTI_TOOL_CONFIGURATION): 214 | 215 | client = Client() 216 | response = client.get('/lti/config.xml') 217 | config_xml = response.content.decode() 218 | 219 | root = ET.fromstring(config_xml) 220 | course_navigation_property = root.find( 221 | ".//*[@name='course_navigation']") 222 | enabled = course_navigation_property.find( 223 | "./*[@name='enabled']").text 224 | 225 | self.assertEqual(enabled, 'true') 226 | 227 | def test_course_navigation_as_dict(self): 228 | lti_tool_config = TEST_LTI_TOOL_CONFIGURATION 229 | lti_tool_config['course_navigation'] = { 230 | "default": "disabled", 231 | "enabled": "true", 232 | "windowTarget": "_blank" 233 | } 234 | with self.settings( 235 | LTI_TOOL_CONFIGURATION=lti_tool_config): 236 | 237 | client = Client() 238 | response = client.get('/lti/config.xml') 239 | config_xml = response.content.decode() 240 | 241 | root = ET.fromstring(config_xml) 242 | course_navigation_property = root.find( 243 | ".//*[@name='course_navigation']") 244 | window_target = course_navigation_property.find( 245 | "./*[@name='windowTarget']").text 246 | 247 | self.assertEqual(window_target, '_blank') 248 | 249 | def test_lookup_assignment_name(self): 250 | lti_tool_config = TEST_LTI_TOOL_CONFIGURATION 251 | lti_tool_config['assignments'] = { 252 | '1': '/assignment/1/' 253 | } 254 | with self.settings(PYLTI_CONFIG={'consumers': CONSUMERS}, 255 | LTI_TOOL_CONFIGURATION=lti_tool_config): 256 | 257 | view = LTIRoutingView() 258 | assignment = view.lookup_assignment_name('1', '1') 259 | self.assertEqual(assignment, '/assignment/1/') 260 | -------------------------------------------------------------------------------- /lti_provider/tests/urls.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django.conf.urls import include, url 4 | 5 | 6 | urlpatterns = [ 7 | url(r'^lti/', include('lti_provider.urls')) 8 | ] 9 | -------------------------------------------------------------------------------- /lti_provider/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path, re_path 2 | 3 | from lti_provider.views import ( 4 | LTIConfigView, LTILandingPage, LTIRoutingView, 5 | LTICourseEnableView, LTIPostGrade, LTIFailAuthorization, 6 | LTICourseConfigure, 7 | login, launch, get_jwks, configure 8 | ) 9 | 10 | 11 | urlpatterns = [ 12 | path('config.xml', LTIConfigView.as_view(), {}, 'lti-config'), 13 | path('auth', LTIFailAuthorization.as_view(), {}, 'lti-fail-auth'), 14 | path('course/config', 15 | LTICourseConfigure.as_view(), {}, 'lti-course-config'), 16 | path('course/enable/', 17 | LTICourseEnableView.as_view(), {}, 'lti-course-enable'), 18 | path('landing/', LTILandingPage.as_view(), {}, 'lti-landing-page'), 19 | path('grade/', LTIPostGrade.as_view(), {}, 'lti-post-grade'), 20 | path('', LTIRoutingView.as_view(), {}, 'lti-login'), 21 | re_path(r'^assignment/(?P.*)/(?P\d+)/$', 22 | LTIRoutingView.as_view(), {}, 'lti-assignment-view'), 23 | re_path(r'^assignment/(?P.*)/$', 24 | LTIRoutingView.as_view(), {}, 'lti-assignment-view'), 25 | 26 | # New pylti1.3 routes 27 | path('login/', login, name='lti-login'), 28 | path('launch/', launch, name='lti-launch'), 29 | path('jwks/', get_jwks, name='jwks'), 30 | re_path(r'^configure/(?P[\w-]+)/$', configure, 31 | name='lti-configure') 32 | ] 33 | -------------------------------------------------------------------------------- /lti_provider/views.py: -------------------------------------------------------------------------------- 1 | import time 2 | import os 3 | import pprint 4 | 5 | from django.conf import settings 6 | from django.contrib import messages 7 | from django.contrib.auth.decorators import login_required 8 | from django.contrib.auth.models import Group 9 | from django.http.response import HttpResponseRedirect 10 | from django.shortcuts import get_object_or_404 11 | from django.urls.exceptions import NoReverseMatch 12 | from django.utils.decorators import method_decorator 13 | from django.views.decorators.clickjacking import xframe_options_exempt 14 | from django.views.decorators.csrf import csrf_exempt 15 | from django.views.generic.base import View, TemplateView 16 | from lti_provider.mixins import LTIAuthMixin, LTILoggedInMixin 17 | from lti_provider.models import LTICourseContext 18 | from pylti.common import LTIPostMessageException, post_message 19 | 20 | from django.http import HttpResponse, HttpResponseForbidden, JsonResponse 21 | from django.shortcuts import render 22 | from django.views.decorators.http import require_POST 23 | from django.urls import reverse 24 | from pylti1p3.contrib.django import ( 25 | DjangoOIDCLogin, DjangoMessageLaunch, DjangoCacheDataStorage 26 | ) 27 | from pylti1p3.deep_link_resource import DeepLinkResource 28 | from pylti1p3.tool_config import ToolConfJsonFile 29 | from pylti1p3.registration import Registration 30 | 31 | 32 | class LTIConfigView(TemplateView): 33 | template_name = 'lti_provider/config.xml' 34 | content_type = 'text/xml; charset=utf-8' 35 | 36 | def get_context_data(self, **kwargs): 37 | domain = self.request.get_host() 38 | launch_url = '%s://%s/%s' % ( 39 | self.request.scheme, domain, 40 | settings.LTI_TOOL_CONFIGURATION.get('launch_url')) 41 | 42 | ctx = { 43 | 'domain': domain, 44 | 'launch_url': launch_url, 45 | 'title': settings.LTI_TOOL_CONFIGURATION.get('title'), 46 | 'description': settings.LTI_TOOL_CONFIGURATION.get('description'), 47 | 'embed_icon_url': 48 | settings.LTI_TOOL_CONFIGURATION.get('embed_icon_url'), 49 | 'embed_tool_id': settings.LTI_TOOL_CONFIGURATION.get( 50 | 'embed_tool_id'), 51 | 'frame_width': settings.LTI_TOOL_CONFIGURATION.get('frame_width'), 52 | 'frame_height': settings.LTI_TOOL_CONFIGURATION.get( 53 | 'frame_height'), 54 | 'course_navigation': settings.LTI_TOOL_CONFIGURATION.get( 55 | 'course_navigation'), 56 | 'custom_fields': settings.LTI_TOOL_CONFIGURATION.get( 57 | 'custom_fields') 58 | } 59 | return ctx 60 | 61 | 62 | @method_decorator(xframe_options_exempt, name='dispatch') 63 | class LTIRoutingView(LTIAuthMixin, View): 64 | request_type = 'initial' 65 | role_type = 'any' 66 | 67 | @method_decorator(csrf_exempt) 68 | def dispatch(self, *args, **kwargs): 69 | return super(LTIRoutingView, self).dispatch(*args, **kwargs) 70 | 71 | def add_custom_parameters(self, url): 72 | if not hasattr(settings, 'LTI_EXTRA_PARAMETERS'): 73 | return url 74 | 75 | if '?' not in url: 76 | url += '?' 77 | else: 78 | url += '&' 79 | 80 | for key in settings.LTI_EXTRA_PARAMETERS: 81 | url += '{}={}&'.format(key, self.request.POST.get(key, '')) 82 | 83 | return url 84 | 85 | def lookup_assignment_name(self, assignment_name, pk): 86 | try: 87 | # first see if there is a matching named view 88 | url = reverse(assignment_name, kwargs={'pk': pk}) 89 | except NoReverseMatch: 90 | # otherwise look it up. 91 | assignments = settings.LTI_TOOL_CONFIGURATION['assignments'] 92 | url = assignments[assignment_name] 93 | 94 | return url 95 | 96 | def post(self, request, assignment_name=None, pk=None): 97 | if request.POST.get('ext_content_intended_use', '') == 'embed': 98 | domain = self.request.get_host() 99 | url = '%s://%s/%s?return_url=%s' % ( 100 | self.request.scheme, domain, 101 | settings.LTI_TOOL_CONFIGURATION.get('embed_url'), 102 | request.POST.get('launch_presentation_return_url')) 103 | elif assignment_name: 104 | url = self.lookup_assignment_name(assignment_name, pk) 105 | elif request.GET.get('assignment', None) is not None: 106 | assignment_name = request.GET.get('assignment') 107 | pk = request.GET.get('pk') 108 | url = self.lookup_assignment_name(assignment_name, pk) 109 | elif settings.LTI_TOOL_CONFIGURATION.get('new_tab'): 110 | url = reverse('lti-landing-page') 111 | else: 112 | url = settings.LTI_TOOL_CONFIGURATION['landing_url'].format( 113 | self.request.scheme, self.request.get_host()) 114 | 115 | # custom parameters can be tacked on here 116 | url = self.add_custom_parameters(url) 117 | 118 | return HttpResponseRedirect(url) 119 | 120 | 121 | @method_decorator(xframe_options_exempt, name='dispatch') 122 | class LTILandingPage(LTIAuthMixin, TemplateView): 123 | template_name = 'lti_provider/landing_page.html' 124 | 125 | def get_context_data(self, **kwargs): 126 | domain = self.request.get_host() 127 | url = settings.LTI_TOOL_CONFIGURATION['landing_url'].format( 128 | self.request.scheme, domain, self.lti.course_context(self.request)) 129 | is_auth_ta = None 130 | if settings.LTI_TOOL_CONFIGURATION.get('allow_ta_access', False): 131 | role = self.request.session.get('roles', '').lower() 132 | is_auth_ta = 'teachingassistant' in role 133 | 134 | return { 135 | 'landing_url': url, 136 | 'title': settings.LTI_TOOL_CONFIGURATION.get('title'), 137 | 'is_instructor': self.lti.is_instructor(self.request), 138 | 'is_administrator': self.lti.is_administrator(self.request), 139 | 'is_auth_ta': is_auth_ta 140 | } 141 | 142 | 143 | @method_decorator(xframe_options_exempt, name='dispatch') 144 | class LTIFailAuthorization(TemplateView): 145 | template_name = 'lti_provider/fail_auth.html' 146 | 147 | 148 | @method_decorator(xframe_options_exempt, name='dispatch') 149 | class LTICourseConfigure(LTILoggedInMixin, TemplateView): 150 | template_name = 'lti_provider/fail_course_configuration.html' 151 | 152 | def get_context_data(self, **kwargs): 153 | return { 154 | 'is_instructor': self.lti.is_instructor(self.request), 155 | 'is_administrator': self.lti.is_administrator(self.request), 156 | 'user': self.request.user, 157 | 'lms_course': self.lti.course_context(self.request), 158 | 'lms_course_title': self.lti.course_title(self.request), 159 | 'sis_course_id': self.lti.sis_course_id(self.request), 160 | 'domain': self.lti.canvas_domain(self.request) 161 | } 162 | 163 | 164 | @method_decorator(xframe_options_exempt, name='dispatch') 165 | class LTICourseEnableView(LTILoggedInMixin, View): 166 | 167 | @method_decorator(login_required) 168 | def dispatch(self, request, *args, **kwargs): 169 | return super(self.__class__, self).dispatch(request, *args, **kwargs) 170 | 171 | def post(self, *args, **kwargs): 172 | group_id = self.request.POST.get('group') 173 | faculty_group_id = self.request.POST.get('faculty_group') 174 | course_context = self.lti.course_context(self.request) 175 | title = self.lti.course_title(self.request) 176 | 177 | (ctx, created) = LTICourseContext.objects.get_or_create( 178 | group=get_object_or_404(Group, id=group_id), 179 | faculty_group=get_object_or_404(Group, id=faculty_group_id), 180 | lms_course_context=course_context) 181 | 182 | messages.add_message( 183 | self.request, messages.INFO, 184 | 'Success! {} is connected to {}.'.format( 185 | title, settings.LTI_TOOL_CONFIGURATION.get('title'))) 186 | 187 | url = reverse('lti-landing-page', args=[course_context]) 188 | return HttpResponseRedirect(url) 189 | 190 | 191 | class LTIPostGrade(LTIAuthMixin, View): 192 | 193 | def message_identifier(self): 194 | return '{:.0f}'.format(time.time()) 195 | 196 | def post(self, request, *args, **kwargs): 197 | """ 198 | Post grade to LTI consumer using XML 199 | 200 | :param: score: 0 <= score <= 1. (Score MUST be between 0 and 1) 201 | :return: True if post successful and score valid 202 | :exception: LTIPostMessageException if call failed 203 | """ 204 | try: 205 | score = float(request.POST.get('score')) 206 | except ValueError: 207 | score = 0 208 | 209 | redirect_url = request.POST.get('next', '/') 210 | launch_url = request.POST.get('launch_url', None) 211 | 212 | xml = self.lti.generate_request_xml( 213 | self.message_identifier(), 'replaceResult', 214 | self.lti.lis_result_sourcedid(request), score, launch_url) 215 | 216 | if not post_message( 217 | self.lti.consumers(), self.lti.oauth_consumer_key(request), 218 | self.lti.lis_outcome_service_url(request), xml): 219 | 220 | msg = ('An error occurred while saving your score. ' 221 | 'Please try again.') 222 | messages.add_message(request, messages.ERROR, msg) 223 | 224 | # Something went wrong, display an error. 225 | # Is 500 the right thing to do here? 226 | raise LTIPostMessageException('Post grade failed') 227 | else: 228 | msg = ('Your score was submitted. Great job!') 229 | messages.add_message(request, messages.INFO, msg) 230 | 231 | return HttpResponseRedirect(redirect_url) 232 | 233 | 234 | # 235 | # New pylti1p3 funtionality below, adapted from pylti1.3-django-example 236 | # 237 | # https://github.com/dmitry-viskov/pylti1.3-django-example 238 | # 239 | class ExtendedDjangoMessageLaunch(DjangoMessageLaunch): 240 | 241 | def validate_nonce(self): 242 | """ 243 | Probably it is bug on "https://lti-ri.imsglobal.org": 244 | site passes invalid "nonce" value during deep links launch. 245 | Because of this in case of iss == http://imsglobal.org just 246 | skip nonce validation. 247 | 248 | """ 249 | iss = self.get_iss() 250 | deep_link_launch = self.is_deep_link_launch() 251 | if iss == "http://imsglobal.org" and deep_link_launch: 252 | return self 253 | return super().validate_nonce() 254 | 255 | 256 | def get_lti_config_path(): 257 | return os.path.join(settings.BASE_DIR, 'configs', 'config.json') 258 | 259 | 260 | def get_tool_conf(): 261 | tool_conf = ToolConfJsonFile(get_lti_config_path()) 262 | return tool_conf 263 | 264 | 265 | def get_jwk_from_public_key(key_name): 266 | key_path = os.path.join(settings.BASE_DIR, 'configs', key_name) 267 | f = open(key_path, 'r') 268 | key_content = f.read() 269 | jwk = Registration.get_jwk(key_content) 270 | f.close() 271 | return jwk 272 | 273 | 274 | def get_launch_data_storage(): 275 | return DjangoCacheDataStorage() 276 | 277 | 278 | def get_launch_url(request) -> str: 279 | target_link_uri = request.POST.get( 280 | 'target_link_uri', request.GET.get('target_link_uri')) 281 | 282 | # If this wasn't in the request, this shouldn't be too hard to 283 | # find. It's just the launch route. 284 | if not target_link_uri: 285 | target_link_uri = reverse('lti-launch') 286 | 287 | # If we really can't find this, then fail. 288 | if not target_link_uri: 289 | raise Exception('Missing "target_link_uri" param') 290 | 291 | return target_link_uri 292 | 293 | 294 | def login(request): 295 | tool_conf = get_tool_conf() 296 | launch_data_storage = get_launch_data_storage() 297 | 298 | oidc_login = DjangoOIDCLogin( 299 | request, tool_conf, launch_data_storage=launch_data_storage) 300 | target_link_uri = get_launch_url(request) 301 | return oidc_login\ 302 | .enable_check_cookies()\ 303 | .redirect(target_link_uri) 304 | 305 | 306 | @require_POST 307 | def launch(request): 308 | # TODO: make this course context aware - migrate functionality 309 | # from LTILandingPage.get_context_data. 310 | landing_url = settings.LTI_TOOL_CONFIGURATION['landing_url'] 311 | 312 | tool_conf = get_tool_conf() 313 | launch_data_storage = get_launch_data_storage() 314 | message_launch = ExtendedDjangoMessageLaunch( 315 | request, tool_conf, launch_data_storage=launch_data_storage) 316 | message_launch_data = message_launch.get_launch_data() 317 | pprint.pprint(message_launch_data) 318 | 319 | return render(request, 'lti_provider/landing_page.html', { 320 | 'landing_url': landing_url, 321 | 'page_title': 'Page Title', 322 | 'is_deep_link_launch': message_launch.is_deep_link_launch(), 323 | 'launch_data': message_launch.get_launch_data(), 324 | 'launch_id': message_launch.get_launch_id(), 325 | 'curr_user_name': message_launch_data.get('name', ''), 326 | }) 327 | 328 | 329 | def get_jwks(request): 330 | tool_conf = get_tool_conf() 331 | return JsonResponse(tool_conf.get_jwks(), safe=False) 332 | 333 | 334 | def configure(request, launch_id): 335 | tool_conf = get_tool_conf() 336 | launch_data_storage = get_launch_data_storage() 337 | message_launch = ExtendedDjangoMessageLaunch.from_cache( 338 | launch_id, request, tool_conf, 339 | launch_data_storage=launch_data_storage) 340 | 341 | if not message_launch.is_deep_link_launch(): 342 | return HttpResponseForbidden('Must be a deep link!') 343 | 344 | launch_url = request.build_absolute_uri(reverse('lti-launch')) 345 | 346 | resource = DeepLinkResource() 347 | resource.set_url(launch_url).set_title('Custom title!') 348 | 349 | html = message_launch.get_deep_link().output_response_form([resource]) 350 | return HttpResponse(html) 351 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | """ run tests for lti_provider 2 | 3 | $ virtualenv ve 4 | $ ./ve/bin/pip install Django 5 | $ ./ve/bin/pip install -r test_reqs.txt 6 | $ ./ve/bin/python runtests.py 7 | """ 8 | 9 | 10 | import django 11 | from django.conf import settings 12 | from django.core.management import call_command 13 | 14 | 15 | def main(): 16 | # Dynamically configure the Django settings with the minimum necessary to 17 | # get Django running tests 18 | settings.configure( 19 | SECRET_KEY="something super secret", 20 | DEFAULT_AUTO_FIELD='django.db.models.AutoField', 21 | MIDDLEWARE=( 22 | 'django.contrib.sessions.middleware.SessionMiddleware', 23 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 24 | 'django.contrib.messages.middleware.MessageMiddleware', 25 | ), 26 | 27 | INSTALLED_APPS=( 28 | 'django.contrib.auth', 29 | 'django.contrib.contenttypes', 30 | 'django.contrib.sessions', 31 | 'lti_provider', 32 | ), 33 | TEST_RUNNER='django.test.runner.DiscoverRunner', 34 | 35 | AUTHENTICATION_BACKENDS=[ 36 | 'django.contrib.auth.backends.ModelBackend', 37 | 'lti_provider.auth.LTIBackend', 38 | ], 39 | TEMPLATES=[ 40 | { 41 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 42 | 'DIRS': [ 43 | # insert your TEMPLATE_DIRS here 44 | ], 45 | 'APP_DIRS': True, 46 | 'OPTIONS': { 47 | 'context_processors': [ 48 | 'django.contrib.auth.context_processors.auth', 49 | 'django.template.context_processors.debug', 50 | 'django.template.context_processors.i18n', 51 | 'django.template.context_processors.media', 52 | 'django.template.context_processors.request', 53 | 'django.template.context_processors.static', 54 | 'django.template.context_processors.tz', 55 | 'django.contrib.messages.context_processors.messages', 56 | ], 57 | }, 58 | }, 59 | ], 60 | COVERAGE_EXCLUDES_FOLDERS=['migrations'], 61 | ROOT_URLCONF='lti_provider.tests.urls', 62 | 63 | PROJECT_APPS=[ 64 | 'lti_provider', 65 | ], 66 | # Django replaces this, but it still wants it. *shrugs* 67 | DATABASES={ 68 | 'default': { 69 | 'ENGINE': 'django.db.backends.sqlite3', 70 | 'NAME': ':memory:', 71 | 'HOST': '', 72 | 'PORT': '', 73 | 'USER': '', 74 | 'PASSWORD': '', 75 | } 76 | }, 77 | ) 78 | 79 | django.setup() 80 | 81 | # Fire off the tests 82 | call_command('test') 83 | 84 | if __name__ == '__main__': 85 | main() 86 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2007-2024, Columbia University Center for Teaching And Learning (CTL) 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or without 5 | # modification, are permitted provided that the following conditions are met: 6 | # * Redistributions of source code must retain the above copyright 7 | # notice, this list of conditions and the following disclaimer. 8 | # * Redistributions in binary form must reproduce the above copyright 9 | # notice, this list of conditions and the following disclaimer in the 10 | # documentation and/or other materials provided with the distribution. 11 | # * Neither the name of the CCNMTL nor the 12 | # names of its contributors may be used to endorse or promote products 13 | # derived from this software without specific prior written permission. 14 | # 15 | # THIS SOFTWARE IS PROVIDED BY CCNMTL ``AS IS'' AND ANY 16 | # EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 17 | # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | # DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY 19 | # DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 20 | # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 21 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 22 | # ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 23 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 24 | # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | 26 | from setuptools import setup 27 | 28 | setup( 29 | name="django-lti-provider", 30 | version="1.0.0", 31 | author="Susan Dreher", 32 | author_email="ctl-dev@columbia.edu", 33 | url="https://github.com/ccnmtl/django-lti-provider", 34 | description="LTI helper", 35 | long_description="LTI Helper", 36 | install_requires=[ 37 | "Django", 38 | "nameparser", 39 | "httplib2", 40 | "oauth2", 41 | "oauthlib", 42 | "pylti", 43 | "pylti1p3", 44 | ], 45 | scripts=[], 46 | license="BSD", 47 | platforms=["any"], 48 | zip_safe=False, 49 | packages=['lti_provider'], 50 | include_package_data=True, 51 | ) 52 | -------------------------------------------------------------------------------- /test_reqs.txt: -------------------------------------------------------------------------------- 1 | six==1.17.0 2 | nameparser==1.1.3 3 | httplib2==0.22.0 4 | oauth2==1.9.0.post1 5 | oauthlib==3.2.2 6 | pylti==0.7.0 7 | ipaddress==1.0.23 8 | python-dateutil==2.9.0.post0 9 | text-unidecode==1.3 # for faker 10 | faker==37.3.0 11 | factory-boy==3.3.3 12 | coverage==7.8.2 13 | mccabe==0.7.0 14 | pycodestyle==2.13.0 15 | flake8==7.2.0 16 | pep8==1.7.1 17 | pyflakes==3.3.2 18 | pytz==2025.2 19 | configparser==7.2.0 20 | zipp==3.23.0 21 | importlib-metadata<8.8 # for flake8 22 | entrypoints==0.4 23 | typing_extensions==4.13.2 24 | pyparsing==3.2.3 25 | 26 | certifi==2025.4.26 # requests 27 | idna==3.10 # requests 28 | charset_normalizer==3.4.2 # requests 29 | urllib3==2.4.0 # requests 30 | requests==2.32.3 # pylti1p3 31 | pyjwt==2.10.1 # pylti1p3 32 | cffi==1.17.1 # cryptography 33 | cryptography==45.0.3 # jwcrypto 34 | jwcrypto==1.5.6 # pylti1p3 35 | pylti1p3==2.0.0 36 | --------------------------------------------------------------------------------