|
4 | 4 | from courseaffils.columbia import CourseStringTemplate, CanvasTemplate |
5 | 5 | from courseaffils.models import Course |
6 | 6 | from courseaffils.views import get_courses_for_user |
7 | | -from django.conf import settings |
| 7 | +from django.conf import settings, LazySettings |
8 | 8 | from django.contrib import messages |
9 | 9 | from django.contrib.auth.mixins import ( |
10 | 10 | LoginRequiredMixin |
11 | 11 | ) |
12 | 12 | from django.contrib.auth.models import Group |
13 | 13 | from django.shortcuts import render, get_object_or_404 |
14 | 14 | from django.http import ( |
15 | | - HttpResponseRedirect |
| 15 | + HttpResponseRedirect, HttpResponse |
16 | 16 | ) |
17 | 17 | from django.urls.base import reverse |
18 | 18 | from django.views.generic.base import TemplateView, View |
|
31 | 31 | import statsmodels.api as sm |
32 | 32 | import json |
33 | 33 | import math |
| 34 | +from lti_tool.views import LtiLaunchBaseView, OIDCLoginInitView |
| 35 | +from lti_dynamic_registration.views import DynamicRegistrationBaseView |
| 36 | +from lti_dynamic_registration.constants import CanvasPrivacyLevel |
| 37 | +from lti_dynamic_registration.types import ( |
| 38 | + CanvasLtiMessage, |
| 39 | + CanvasLtiRegistration, |
| 40 | + CanvasLtiToolConfiguration, |
| 41 | +) |
| 42 | +from urllib.parse import urljoin, urlparse |
| 43 | +from django.templatetags.static import static |
| 44 | +from django.views.decorators.clickjacking import xframe_options_exempt |
| 45 | + |
| 46 | + |
| 47 | +def ensure_https(url: str) -> str: |
| 48 | + if not url.startswith('http://') and not url.startswith('https://'): |
| 49 | + return 'https://' + url |
| 50 | + return url |
| 51 | + |
| 52 | + |
| 53 | +def is_static_file_remote(path: str) -> bool: |
| 54 | + url = static(path) |
| 55 | + parsed = urlparse(url) |
| 56 | + return bool(parsed.scheme and parsed.netloc) |
| 57 | + |
| 58 | + |
| 59 | +def get_icon_url(settings: LazySettings, host: str) -> str: |
| 60 | + icon_url = static(settings.LTI_TOOL_CONFIGURATION['embed_icon_url']) |
| 61 | + |
| 62 | + if not is_static_file_remote(icon_url): |
| 63 | + icon_url = urljoin(f'https://{host}', icon_url) |
| 64 | + |
| 65 | + return icon_url |
34 | 66 |
|
35 | 67 |
|
36 | 68 | class CoursesView(LoginRequiredMixin, TemplateView): |
@@ -130,6 +162,246 @@ def post(self, request, *args, **kwargs): |
130 | 162 | ) |
131 | 163 |
|
132 | 164 |
|
| 165 | +class JSONConfigView(View): |
| 166 | + """ |
| 167 | + JSON configuration endpoint for LTI 1.3. |
| 168 | +
|
| 169 | + In Canvas LMS, an LTI Developer Key can be created via Manual |
| 170 | + Entry, or by URL. This view provides the JSON necessary for URL |
| 171 | + configuration in Canvas. |
| 172 | +
|
| 173 | + https://canvas.instructure.com/doc/api/file.lti_dev_key_config.html |
| 174 | + """ |
| 175 | + def get(self, request, *args, **kwargs): |
| 176 | + domain = request.get_host() |
| 177 | + title = settings.LTI_TOOL_CONFIGURATION['title'] |
| 178 | + icon_url = static(settings.LTI_TOOL_CONFIGURATION['embed_icon_url']) |
| 179 | + target_link_uri = urljoin( |
| 180 | + 'https://{}'.format(domain), reverse('lti-launch')) |
| 181 | + |
| 182 | + uuid_str = kwargs.get('registration_uuid') |
| 183 | + oidc_init_uri = urljoin( |
| 184 | + 'https://{}'.format(domain), |
| 185 | + reverse('init', kwargs={'registration_uuid': uuid_str})) |
| 186 | + |
| 187 | + lti_platform = 'columbiasce.test.instructure.com' |
| 188 | + if hasattr(settings, 'LTI_PLATFORM'): |
| 189 | + lti_platform = settings.LTI_PLATFORM |
| 190 | + |
| 191 | + json_obj = { |
| 192 | + 'title': title, |
| 193 | + 'description': settings.LTI_TOOL_CONFIGURATION['description'], |
| 194 | + 'oidc_initiation_url': oidc_init_uri, |
| 195 | + 'target_link_uri': target_link_uri, |
| 196 | + 'scopes': [ |
| 197 | + 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem', |
| 198 | + 'https://purl.imsglobal.org/spec/lti-ags/lti/claim/lis', |
| 199 | + |
| 200 | + 'https://purl.imsglobal.org/' |
| 201 | + 'spec/lti-ags/scope/result.readonly', |
| 202 | + |
| 203 | + 'https://purl.imsglobal.org/' |
| 204 | + 'spec/lti-nrps/scope/contextmembership.readonly', |
| 205 | + ], |
| 206 | + 'extensions': [ |
| 207 | + { |
| 208 | + 'domain': domain, |
| 209 | + 'tool_id': 'metricsmentor', |
| 210 | + 'platform': lti_platform, |
| 211 | + 'privacy_level': 'public', |
| 212 | + 'settings': { |
| 213 | + 'text': 'Launch ' + title, |
| 214 | + 'labels': { |
| 215 | + 'en': 'Launch ' + title, |
| 216 | + }, |
| 217 | + 'icon_url': icon_url, |
| 218 | + 'selection_height': 800, |
| 219 | + 'selection_width': 800, |
| 220 | + 'placements': [ |
| 221 | + { |
| 222 | + 'text': 'MetricsMentor', |
| 223 | + 'icon_url': icon_url, |
| 224 | + 'placement': 'course_navigation', |
| 225 | + 'message_type': 'LtiResourceLinkRequest', |
| 226 | + 'target_link_uri': target_link_uri, |
| 227 | + 'selection_height': 500, |
| 228 | + 'selection_width': 500 |
| 229 | + } |
| 230 | + ], |
| 231 | + } |
| 232 | + } |
| 233 | + ], |
| 234 | + 'public_jwk_url': urljoin( |
| 235 | + 'https://{}'.format(domain), reverse('jwks')) |
| 236 | + } |
| 237 | + return JsonResponse(json_obj) |
| 238 | + |
| 239 | + |
| 240 | +@method_decorator(xframe_options_exempt, name='dispatch') |
| 241 | +class MyOIDCLoginInitView(OIDCLoginInitView): |
| 242 | + pass |
| 243 | + |
| 244 | + |
| 245 | +@method_decorator(xframe_options_exempt, name='dispatch') |
| 246 | +class LtiLaunchView(LtiLaunchBaseView, TemplateView): |
| 247 | + """ |
| 248 | + https://github.com/academic-innovation/django-lti/blob/main/README.md#handling-an-lti-launch |
| 249 | + """ |
| 250 | + template_name = 'lti_provider/landing_page.html' |
| 251 | + lti_tool_name = None |
| 252 | + course = None |
| 253 | + |
| 254 | + @xframe_options_exempt |
| 255 | + def handle_resource_launch(self, request, lti_launch): |
| 256 | + if settings.DEBUG: |
| 257 | + print('All lti_launch data:', lti_launch.get_launch_data()) |
| 258 | + print('User:', lti_launch.user.__dict__) |
| 259 | + print('NRPS claim:', lti_launch.nrps_claim) |
| 260 | + print('Roles claim:', lti_launch.roles_claim) |
| 261 | + |
| 262 | + self.lti_tool_name = lti_launch.platform_instance_claim.get( |
| 263 | + 'product_family_code') |
| 264 | + if self.lti_tool_name: |
| 265 | + self.lti_tool_name = self.lti_tool_name.capitalize() |
| 266 | + |
| 267 | + self.deployment_id = lti_launch.deployment.deployment_id |
| 268 | + self.course_id = lti_launch.context_claim.get('id') |
| 269 | + self.course_name = lti_launch.context_claim.get('title') |
| 270 | + |
| 271 | + # Search for course by context_id in EconPractice database |
| 272 | + try: |
| 273 | + self.course = Course.objects.get( |
| 274 | + context_id=self.course_id, deployment_id=self.deployment_id) |
| 275 | + except Course.DoesNotExist: |
| 276 | + pass |
| 277 | + |
| 278 | + return self.get(request) |
| 279 | + |
| 280 | + @xframe_options_exempt |
| 281 | + def get_context_data(self, **kwargs): |
| 282 | + domain = self.request.get_host() |
| 283 | + url = settings.LTI_TOOL_CONFIGURATION['landing_url'].format( |
| 284 | + self.request.scheme, domain, kwargs.get('context')) |
| 285 | + |
| 286 | + lti_tool_name = 'LTI' |
| 287 | + if self.lti_tool_name: |
| 288 | + lti_tool_name = self.lti_tool_name |
| 289 | + |
| 290 | + return { |
| 291 | + 'DEBUG': settings.DEBUG, |
| 292 | + 'landing_url': url, |
| 293 | + 'title': settings.LTI_TOOL_CONFIGURATION['title'], |
| 294 | + 'lti_tool_name': lti_tool_name, |
| 295 | + 'course': self.course, |
| 296 | + 'deployment_id': self.deployment_id, |
| 297 | + 'course_id': self.course_id, |
| 298 | + 'course_name': self.course_name, |
| 299 | + } |
| 300 | + |
| 301 | + |
| 302 | +class DynamicRegistrationView(DynamicRegistrationBaseView): |
| 303 | + tool_friendly_name = "MetricsMentor" |
| 304 | + lti_platform = 'columbiasce.test.instructure.com' |
| 305 | + |
| 306 | + def dispatch(self, *args, **kwargs) -> HttpResponse: |
| 307 | + if hasattr(settings, 'LTI_PLATFORM'): |
| 308 | + self.lti_platform = settings.LTI_PLATFORM |
| 309 | + |
| 310 | + return super().dispatch(*args, **kwargs) |
| 311 | + |
| 312 | + def get(self, request, *args, **kwargs) -> HttpResponse: |
| 313 | + context = { |
| 314 | + # Pass in a default lti_platform |
| 315 | + 'lti_platform': ensure_https(self.lti_platform), |
| 316 | + } |
| 317 | + return render(request, 'registration/lti_registration.html', context) |
| 318 | + |
| 319 | + def post(self, request, *args, **kwargs) -> HttpResponse: |
| 320 | + # Perform the registration steps. Typically this would involve: |
| 321 | + # 1. Register the platform in the tool |
| 322 | + issuer = request.POST.get('issuer') |
| 323 | + if issuer: |
| 324 | + issuer = issuer.strip() |
| 325 | + issuer = ensure_https(issuer) |
| 326 | + |
| 327 | + authorization_endpoint = urljoin(issuer, '/api/lti/authorize_redirect') |
| 328 | + |
| 329 | + token_endpoint = urljoin(issuer, '/login/oauth2/token') |
| 330 | + jwks_uri = urljoin(issuer, '/api/lti/security/jwks') |
| 331 | + |
| 332 | + reg = self.register_platform_in_tool( |
| 333 | + issuer, { |
| 334 | + 'issuer': issuer, |
| 335 | + 'authorization_endpoint': authorization_endpoint, |
| 336 | + 'token_endpoint': token_endpoint, |
| 337 | + 'jwks_uri': jwks_uri, |
| 338 | + } |
| 339 | + ) |
| 340 | + |
| 341 | + # 2. Register the tool in the platform |
| 342 | + openid_config = self.get_openid_config() |
| 343 | + privacy_level = CanvasPrivacyLevel('public') |
| 344 | + host = request.get_host() |
| 345 | + target_link_uri = f'https://{host}/lti/launch/' |
| 346 | + icon_url = get_icon_url(settings, host) |
| 347 | + oidc_init_uri = urljoin( |
| 348 | + 'https://{}'.format(host), |
| 349 | + reverse('oidc_init', kwargs={'registration_uuid': reg.uuid})) |
| 350 | + |
| 351 | + lti_tool_config = CanvasLtiToolConfiguration( |
| 352 | + domain=self.lti_platform, |
| 353 | + target_link_uri=target_link_uri, |
| 354 | + claims=[ |
| 355 | + 'sub', |
| 356 | + 'iss', |
| 357 | + 'name', |
| 358 | + 'given_name', |
| 359 | + 'family_name', |
| 360 | + 'nickname', |
| 361 | + 'picture', |
| 362 | + 'email', |
| 363 | + 'locale' |
| 364 | + ], |
| 365 | + messages=[ |
| 366 | + CanvasLtiMessage( |
| 367 | + label='MetricsMentor', |
| 368 | + icon_uri=icon_url, |
| 369 | + type='LtiResourceLinkRequest', |
| 370 | + placements=['course_navigation'], |
| 371 | + target_link_uri=target_link_uri, |
| 372 | + default_enabled=True |
| 373 | + ) |
| 374 | + ], |
| 375 | + description=settings.LTI_TOOL_CONFIGURATION['description'], |
| 376 | + privacy_level=privacy_level, |
| 377 | + tool_id='metricsmentor', |
| 378 | + ) |
| 379 | + tool_platform_registration = CanvasLtiRegistration( |
| 380 | + client_name='MetricsMentor', |
| 381 | + target_link_uri=target_link_uri, |
| 382 | + initiate_login_uri=oidc_init_uri, |
| 383 | + jwks_uri=jwks_uri, |
| 384 | + scopes=[ |
| 385 | + 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem', |
| 386 | + ], |
| 387 | + lti_tool_configuration=lti_tool_config, |
| 388 | + ) |
| 389 | + |
| 390 | + if settings.DEBUG: |
| 391 | + print('registration', tool_platform_registration.to_dict()) |
| 392 | + client_id = self.register_tool_in_platform( |
| 393 | + openid_config, tool_platform_registration) |
| 394 | + |
| 395 | + # 3. Update the platform registration with the client ID returned in |
| 396 | + # step 2 |
| 397 | + reg.client_id = client_id |
| 398 | + reg.save() |
| 399 | + |
| 400 | + # Return a page containing javascript that calls a special |
| 401 | + # platform postMessage endpoint |
| 402 | + return self.success_response() |
| 403 | + |
| 404 | + |
133 | 405 | class LTICourseCreate(LoginRequiredMixin, View): |
134 | 406 |
|
135 | 407 | def notify_staff(self, course): |
|
0 commit comments