Skip to content

Commit ad263a5

Browse files
committed
Add LTI 1.3 module
Migrated old LTI 1.1 routes to `/lti11/`.
1 parent 446a1ce commit ad263a5

File tree

12 files changed

+245
-10
lines changed

12 files changed

+245
-10
lines changed

econplayground/lti/README.md

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
The lti django app contains all of EconPractice's LTI-related
2+
code.
3+
4+
Canvas allows LTI integration in a few different ways. Within a
5+
course, you can select Settings, and then the Apps tab. From here, you
6+
can add an LTI app using a number of different methods, i.e.: Manual
7+
Entry, By URL, Paste XML, By Client ID, or By LTI 2 Registration URL.
8+
Most of these methods actually refer to the deprecated LTI 1.1
9+
protocol (keep in mind LTI 2 is also deprecated, and is actually older
10+
than LTI 1.3). The most reliable method I've found here is to use "By
11+
Client ID".
12+
13+
For more context on all this, I have found
14+
[Canvas's LTI documentation](https://canvas.instructure.com/doc/api/file.tools_intro.html)
15+
to be pretty straightforward and understandable as an intro to LTI,
16+
in comparison to the [LTI 1.3 standard](https://www.imsglobal.org/spec/lti/v1p3/)
17+
itself.
18+
19+
## EconPractice / Canvas integration (LTI 1.3)
20+
21+
To integrate EconPractice with Canvas, first we create an LTI
22+
Registration in EconPractice's admin page:
23+
* `https://<econpractice hostname>/admin/lti_tool/ltiregistration/`
24+
25+
1. Click "Add LTI Registration"
26+
2. Name: Canvas instance hostname, e.g. `canvas.ctl.columbia.edu`
27+
3. UUID: This is filled in automatically
28+
4. Issuer: `https://<canvas instance hostname>`
29+
5. Client ID: Set to 1 for now, this will be updated later
30+
6. Auth URL: `https://<canvas instance hostname>/api/lti/authorize_redirect`
31+
7. Access token URL: `https://<canvas instance hostname>/login/oauth2/auth`
32+
8. Keyset URL: `https://<canvas instance hostname>/api/lti/security/jwks`
33+
34+
Then, in Canvas, navigate to Admin -> CTL -> Developer Keys.
35+
36+
1. Click the blue "+ Developer Key" button and select "LTI Key". ("API
37+
Key" here refers to Canvas's custom API which we don't have special
38+
integration for, so we definitely want to choose "LTI Key" here.)
39+
2. Select Method -> Enter URL
40+
3. Enter the URL: `https://<your econpractice hostname>/lti/<registration uuid>/config.json`
41+
4. Fill in the Key Name as EconPractice, as well as an admin email for
42+
Owner Email.
43+
5. Under Redirect URIs, add: `https://<your econpractice hostname>/lti/launch/`
44+
6. Click Save
45+
7. Under "Details", you will see an ID like "10000000000018". This is the "Client ID".
46+
Go back to the LTI Registration in EconPractice and update the Client ID with this
47+
value. Without doing this, the LTI launch will raise:
48+
`OIDCException: Could not find registration details`
49+
50+
Once the LTI Registration and the Developer Key are in place, you can
51+
add this LTI App to a course.
52+
53+
1. Navigate to a course in Canvas, and go to Settings.
54+
2. Click the Apps tab, and click the "+ App" button to add a new app.
55+
3. Under Configuration Type, select "By Client ID".
56+
4. For the Client ID, input the ID of the Developer Key you created, which
57+
should look something like "10000000000018".
58+
5. Click Submit.
59+
60+
The EconPractice LTI app should now be installed in this course. You can
61+
click on the gear icon, then Placements to see app placements. The
62+
"Course Navigation" placement should be active, and this is adds a
63+
"EconPractice" menu item in the course sidebar. Sometimes the Placements
64+
aren't automatically configured on the first try. If you don't see the
65+
EconPractice menu item on the left, go back to the Developer Key and see
66+
how things are configured, and save this again if necessary. This should
67+
be smoothed over once we migrate to the new "LTI Dynamic Registration",
68+
which Canvas only recently added support for. See:
69+
https://github.com/academic-innovation/django-lti/pull/135#issuecomment-2644062255
70+
71+
Now, there is still one more thing to do: the LTI Deployment must be made
72+
"active". To do this:
73+
74+
1. Select the EconPractice menu item in the course. You should receive the
75+
error message "This deployment is not active". This is an expected error
76+
message that comes from django-lti.
77+
2. Navigate to your LTI Registration management admin screen in EconPractice,
78+
and look under LTI Deployments. You should see one populated now, and
79+
simply select the "Is Active" checkbox, and Save.
80+
81+
Now, *finally*, the tool should successfully launch within Canvas.
82+
83+
## Notes about LTI libraries
84+
85+
Python support for LTI 1.3 is not in the most cohesive state right now, as
86+
different people have contributed their efforts at different times and in
87+
different places. That said, it has been possible to get things working
88+
building off what open source libraries have been built.
89+
90+
[pylti1.3](https://github.com/dmitry-viskov/pylti1.3) is a good base to build
91+
off of. And although it is not actively maintained, it is entirely possible to
92+
override anything we need to, when necessary.
93+
94+
A lot of the functionality in pylti1.3 is pretty low-level though, and a lot
95+
of the LTI transactions and setup can be pre-configured when working with
96+
Django. Fortunately a lot of this work has been done by the
97+
[django-lti](https://github.com/academic-innovation/django-lti) library, which
98+
builds off of pylti1.3. There is some overlap in functionality here, as pylti1.3
99+
also contains some Django-specific code. But the django-lti library fills in the
100+
gaps, and is under active development and open to suggestions that we might find
101+
helpful as we work deeper with LTI 1.3 integration.

econplayground/lti/__init__.py

Whitespace-only changes.

econplayground/lti/admin.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# from django.contrib import admin
2+
3+
# Register your models here.

econplayground/lti/apps.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from django.apps import AppConfig
2+
3+
4+
class LtiConfig(AppConfig):
5+
default_auto_field = 'django.db.models.BigAutoField'
6+
name = 'lti'

econplayground/lti/migrations/__init__.py

Whitespace-only changes.

econplayground/lti/models.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# from django.db import models
2+
3+
# Create your models here.

econplayground/lti/tests.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# from django.test import TestCase
2+
3+
# Create your tests here.

econplayground/lti/urls.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from django.urls import path
2+
3+
from econplayground.lti.views import (
4+
JSONConfigView, LTI1p3LaunchView, MyOIDCLoginInitView
5+
)
6+
from lti_tool.views import jwks
7+
8+
9+
urlpatterns = [
10+
# django-lti
11+
path('.well-known/jwks.json', jwks, name='jwks'),
12+
path('init/<uuid:registration_uuid>/',
13+
MyOIDCLoginInitView.as_view(), name='oidc_init'),
14+
path('<uuid:registration_uuid>/config.json',
15+
JSONConfigView.as_view()),
16+
path('launch/', LTI1p3LaunchView.as_view(), name='lti-launch'),
17+
]

econplayground/lti/views.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
from django.conf import settings
2+
from django.http import JsonResponse
3+
from django.urls import reverse
4+
from django.utils.decorators import method_decorator
5+
from django.views.decorators.clickjacking import xframe_options_exempt
6+
from django.views.generic.base import View, TemplateView
7+
from urllib.parse import urljoin
8+
9+
from lti_tool.views import LtiLaunchBaseView, OIDCLoginInitView
10+
11+
12+
class JSONConfigView(View):
13+
"""
14+
JSON configuration endpoint for LTI 1.3.
15+
16+
In Canvas LMS, an LTI Developer Key can be created via Manual
17+
Entry, or by URL. This view provides the JSON necessary for URL
18+
configuration in Canvas.
19+
20+
https://canvas.instructure.com/doc/api/file.lti_dev_key_config.html
21+
"""
22+
def get(self, request, *args, **kwargs):
23+
domain = request.get_host()
24+
title = settings.LTI_TOOL_CONFIGURATION['title']
25+
icon_url = urljoin(
26+
settings.STATIC_URL,
27+
settings.LTI_TOOL_CONFIGURATION['embed_icon_url'])
28+
target_link_uri = urljoin(
29+
'https://{}'.format(domain), reverse('lti-launch'))
30+
31+
uuid = kwargs.get('registration_uuid')
32+
oidc_init_uri = urljoin(
33+
'https://{}'.format(domain),
34+
reverse('oidc_init', kwargs={'registration_uuid': uuid}))
35+
36+
json_obj = {
37+
'title': title,
38+
'description': settings.LTI_TOOL_CONFIGURATION['description'],
39+
'oidc_initiation_url': oidc_init_uri,
40+
'target_link_uri': target_link_uri,
41+
'scopes': [
42+
'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem',
43+
'https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly'
44+
],
45+
'extensions': [
46+
{
47+
'domain': domain,
48+
'tool_id': 'econpractice',
49+
'platform': 'canvas.ctl.columbia.edu',
50+
'privacy_level': 'public',
51+
'settings': {
52+
'text': 'Launch ' + title,
53+
'labels': {
54+
'en': 'Launch ' + title,
55+
},
56+
'icon_url': icon_url,
57+
'selection_height': 800,
58+
'selection_width': 800,
59+
'placements': [
60+
{
61+
'text': 'EconPractice',
62+
'icon_url': icon_url,
63+
'placement': 'course_navigation',
64+
'message_type': 'LtiResourceLinkRequest',
65+
'target_link_uri': target_link_uri,
66+
'required_permissions': 'manage_calendar',
67+
'selection_height': 500,
68+
'selection_width': 500
69+
}
70+
]
71+
}
72+
}
73+
],
74+
'public_jwk_url': urljoin(
75+
'https://{}'.format(domain), reverse('jwks'))
76+
}
77+
return JsonResponse(json_obj)
78+
79+
80+
@method_decorator(xframe_options_exempt, name='dispatch')
81+
class MyOIDCLoginInitView(OIDCLoginInitView):
82+
pass
83+
84+
85+
@method_decorator(xframe_options_exempt, name='dispatch')
86+
class LTI1p3LaunchView(LtiLaunchBaseView, TemplateView):
87+
"""
88+
https://github.com/academic-innovation/django-lti/blob/main/README.md#handling-an-lti-launch
89+
"""
90+
template_name = 'lti_auth/landing_page.html'
91+
92+
def get_context_data(self, **kwargs):
93+
domain = self.request.get_host()
94+
url = settings.LTI_TOOL_CONFIGURATION['landing_url'].format(
95+
self.request.scheme, domain, kwargs.get('context'))
96+
97+
return {
98+
'landing_url': url,
99+
'title': settings.LTI_TOOL_CONFIGURATION['title']
100+
}
101+
102+
def handle_resource_launch(self, request, lti_launch):
103+
return self.get(request)

econplayground/main/tests/test_views.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -906,7 +906,7 @@ def setUp(self):
906906
self.submission = SubmissionFactory(graph=self.g, user=self.u)
907907

908908
def test_get(self):
909-
request = self.factory.get('/lti/landing/')
909+
request = self.factory.get('/lti11/landing/')
910910
request.user = self.u
911911
view = MyLTILandingPage()
912912
view.lti = MockLTI()

econplayground/settings_shared.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@
122122
LTI_TOOL_CONFIGURATION = {
123123
'title': 'EconPractice',
124124
'description': 'Interactive economics graphs',
125-
'launch_url': 'lti/',
125+
'launch_url': 'lti11/',
126126
'embed_url': '',
127127
'embed_icon_url': 'img/icons/icon-16.png',
128128
'embed_tool_id': 'econpractice',

econplayground/urls.py

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
from econplayground.main import views
1010
from econplayground.assignment import views as assignment_views
1111
from django_cas_ng import views as cas_views
12-
from lti_tool.views import jwks, OIDCLoginInitView
1312

1413

1514
site_media_root = os.path.join(os.path.dirname(__file__), "../media")
@@ -151,8 +150,13 @@ def trigger_error(request):
151150
path('smoketest/', include('smoketest.urls')),
152151
path('uploads/<path>',
153152
serve, {'document_root': settings.MEDIA_ROOT}),
154-
path('lti/landing/', views.MyLTILandingPage.as_view()),
155-
path('lti/', include('lti_provider.urls')),
153+
154+
# django-lti-provider (LTI 1.1)
155+
path('lti11/landing/', views.MyLTILandingPage.as_view()),
156+
path('lti11/', include('lti_provider.urls')),
157+
158+
# LTI 1.3
159+
path('lti/', include('econplayground.lti.urls')),
156160

157161
path('sign_s3/', views.S3SignView.as_view()),
158162

@@ -161,11 +165,6 @@ def trigger_error(request):
161165
path('sentry-debug/', trigger_error),
162166

163167
path('pages/', include('django.contrib.flatpages.urls')),
164-
165-
# django-lti
166-
path('.well-known/jwks.json', jwks, name='jwks'),
167-
path('init/<uuid:registration_uuid>/',
168-
OIDCLoginInitView.as_view(), name='oidc_init'),
169168
]
170169

171170
if settings.DEBUG:

0 commit comments

Comments
 (0)