From c0dfbf09767f39cbe8c6cb0572eadb982aeb3464 Mon Sep 17 00:00:00 2001 From: Nikolas Nyby Date: Fri, 8 Mar 2024 14:42:43 -0500 Subject: [PATCH] WIP - #636 --- lti_provider/urls.py | 17 ++- lti_provider/views.py | 259 +++++++++++++++++++++++++++++++++++++++++- setup.py | 1 + test_reqs.txt | 9 ++ 4 files changed, 278 insertions(+), 8 deletions(-) diff --git a/lti_provider/urls.py b/lti_provider/urls.py index 65b9ebc..31e2721 100644 --- a/lti_provider/urls.py +++ b/lti_provider/urls.py @@ -1,7 +1,11 @@ from django.urls import re_path -from lti_provider.views import LTIConfigView, LTILandingPage, LTIRoutingView, \ - LTICourseEnableView, LTIPostGrade, LTIFailAuthorization, LTICourseConfigure +from lti_provider.views import ( + LTIConfigView, LTILandingPage, LTIRoutingView, + LTICourseEnableView, LTIPostGrade, LTIFailAuthorization, + LTICourseConfigure, + login, launch, get_jwks, configure +) urlpatterns = [ @@ -17,5 +21,12 @@ re_path(r'^assignment/(?P.*)/(?P\d+)/$', LTIRoutingView.as_view(), {}, 'lti-assignment-view'), re_path(r'^assignment/(?P.*)/$', - LTIRoutingView.as_view(), {}, 'lti-assignment-view') + LTIRoutingView.as_view(), {}, 'lti-assignment-view'), + + # New pylti1.3 routes + re_path(r'^login/$', login, name='game-login'), + re_path(r'^launch/$', launch, name='game-launch'), + re_path(r'^jwks/$', get_jwks, name='jwks'), + re_path(r'^configure/(?P[\w-]+)/$', configure, + name='lti-configure') ] diff --git a/lti_provider/views.py b/lti_provider/views.py index 5d11366..25f9dcb 100644 --- a/lti_provider/views.py +++ b/lti_provider/views.py @@ -1,4 +1,7 @@ import time +import datetime +import os +import pprint from django.conf import settings from django.contrib import messages @@ -15,11 +18,18 @@ from lti_provider.models import LTICourseContext from pylti.common import LTIPostMessageException, post_message - -try: - from django.urls import reverse -except ImportError: - from django.core.urlresolvers import reverse +from django.http import HttpResponse, HttpResponseForbidden, JsonResponse +from django.shortcuts import render +from django.views.decorators.http import require_POST +from django.urls import reverse +from pylti1p3.contrib.django import ( + DjangoOIDCLogin, DjangoMessageLaunch, DjangoCacheDataStorage +) +from pylti1p3.deep_link_resource import DeepLinkResource +from pylti1p3.grade import Grade +from pylti1p3.lineitem import LineItem +from pylti1p3.tool_config import ToolConfJsonFile +from pylti1p3.registration import Registration class LTIConfigView(TemplateView): @@ -222,3 +232,242 @@ def post(self, request, *args, **kwargs): messages.add_message(request, messages.INFO, msg) return HttpResponseRedirect(redirect_url) + + +# +# New pylti1p3 funtionality below, adapted from pylti1.3-django-example +# +# https://github.com/dmitry-viskov/pylti1.3-django-example +# +class ExtendedDjangoMessageLaunch(DjangoMessageLaunch): + + def validate_nonce(self): + """ + Probably it is bug on "https://lti-ri.imsglobal.org": + site passes invalid "nonce" value during deep links launch. + Because of this in case of iss == http://imsglobal.org just + skip nonce validation. + + """ + iss = self.get_iss() + deep_link_launch = self.is_deep_link_launch() + if iss == "http://imsglobal.org" and deep_link_launch: + return self + return super().validate_nonce() + + +def get_lti_config_path(): + return os.path.join(settings.BASE_DIR, 'configs', 'config.json') + + +def get_tool_conf(): + tool_conf = ToolConfJsonFile(get_lti_config_path()) + return tool_conf + + +def get_jwk_from_public_key(key_name): + key_path = os.path.join(settings.BASE_DIR, 'configs', key_name) + f = open(key_path, 'r') + key_content = f.read() + jwk = Registration.get_jwk(key_content) + f.close() + return jwk + + +def get_launch_data_storage(): + return DjangoCacheDataStorage() + + +def get_launch_url(request): + target_link_uri = request.POST.get( + 'target_link_uri', request.GET.get('target_link_uri')) + if not target_link_uri: + raise Exception('Missing "target_link_uri" param') + return target_link_uri + + +def login(request): + tool_conf = get_tool_conf() + launch_data_storage = get_launch_data_storage() + + oidc_login = DjangoOIDCLogin( + request, tool_conf, launch_data_storage=launch_data_storage) + target_link_uri = get_launch_url(request) + return oidc_login\ + .enable_check_cookies()\ + .redirect(target_link_uri) + + +@require_POST +def launch(request): + tool_conf = get_tool_conf() + launch_data_storage = get_launch_data_storage() + message_launch = ExtendedDjangoMessageLaunch( + request, tool_conf, launch_data_storage=launch_data_storage) + message_launch_data = message_launch.get_launch_data() + pprint.pprint(message_launch_data) + + return render(request, 'game.html', { + 'page_title': 'Page Title', + 'is_deep_link_launch': message_launch.is_deep_link_launch(), + 'launch_data': message_launch.get_launch_data(), + 'launch_id': message_launch.get_launch_id(), + 'curr_user_name': message_launch_data.get('name', ''), + }) + + +def get_jwks(request): + tool_conf = get_tool_conf() + return JsonResponse(tool_conf.get_jwks(), safe=False) + + +def configure(request, launch_id): + tool_conf = get_tool_conf() + launch_data_storage = get_launch_data_storage() + message_launch = ExtendedDjangoMessageLaunch.from_cache( + launch_id, request, tool_conf, + launch_data_storage=launch_data_storage) + + if not message_launch.is_deep_link_launch(): + return HttpResponseForbidden('Must be a deep link!') + + launch_url = request.build_absolute_uri(reverse('game-launch')) + + resource = DeepLinkResource() + resource.set_url(launch_url).set_title('Custom title!') + + html = message_launch.get_deep_link().output_response_form([resource]) + return HttpResponse(html) + + +@require_POST +def score(request, launch_id, earned_score, time_spent): + tool_conf = get_tool_conf() + launch_data_storage = get_launch_data_storage() + message_launch = ExtendedDjangoMessageLaunch.from_cache( + launch_id, request, tool_conf, + launch_data_storage=launch_data_storage) + resource_link_id = message_launch.get_launch_data() \ + .get( + 'https://purl.imsglobal.org/spec/lti/claim/resource_link', {} + ).get('id') + + if not message_launch.has_ags(): + return HttpResponseForbidden("Don't have grades!") + + sub = message_launch.get_launch_data().get('sub') + timestamp = datetime.datetime.utcnow().isoformat() + 'Z' + earned_score = int(earned_score) + time_spent = int(time_spent) + + ags = message_launch.get_ags() + + if ags.can_create_lineitem(): + sc = Grade() + sc.set_score_given(earned_score)\ + .set_score_maximum(100)\ + .set_timestamp(timestamp)\ + .set_activity_progress('Completed')\ + .set_grading_progress('FullyGraded')\ + .set_user_id(sub) + + sc_line_item = LineItem() + sc_line_item.set_tag('score')\ + .set_score_maximum(100)\ + .set_label('Score') + if resource_link_id: + sc_line_item.set_resource_id(resource_link_id) + + ags.put_grade(sc, sc_line_item) + + tm = Grade() + tm.set_score_given(time_spent)\ + .set_score_maximum(999)\ + .set_timestamp(timestamp)\ + .set_activity_progress('Completed')\ + .set_grading_progress('FullyGraded')\ + .set_user_id(sub) + + tm_line_item = LineItem() + tm_line_item.set_tag('time')\ + .set_score_maximum(999)\ + .set_label('Time Taken') + if resource_link_id: + tm_line_item.set_resource_id(resource_link_id) + + result = ags.put_grade(tm, tm_line_item) + else: + sc = Grade() + sc.set_score_given(earned_score) \ + .set_score_maximum(100) \ + .set_timestamp(timestamp) \ + .set_activity_progress('Completed') \ + .set_grading_progress('FullyGraded') \ + .set_user_id(sub) + result = ags.put_grade(sc) + + return JsonResponse({'success': True, 'result': result.get('body')}) + + +def scoreboard(request, launch_id): + tool_conf = get_tool_conf() + launch_data_storage = get_launch_data_storage() + message_launch = ExtendedDjangoMessageLaunch.from_cache( + launch_id, request, tool_conf, + launch_data_storage=launch_data_storage) + resource_link_id = message_launch.get_launch_data() \ + .get( + 'https://purl.imsglobal.org/spec/lti/claim/resource_link', {} + ).get('id') + + if not message_launch.has_nrps(): + return HttpResponseForbidden("Don't have names and roles!") + + if not message_launch.has_ags(): + return HttpResponseForbidden("Don't have grades!") + + ags = message_launch.get_ags() + + if ags.can_create_lineitem(): + score_line_item = LineItem() + score_line_item.set_tag('score') \ + .set_score_maximum(100) \ + .set_label('Score') + if resource_link_id: + score_line_item.set_resource_id(resource_link_id) + + score_line_item = ags.find_or_create_lineitem(score_line_item) + scores = ags.get_grades(score_line_item) + + time_line_item = LineItem() + time_line_item.set_tag('time') \ + .set_score_maximum(999) \ + .set_label('Time Taken') + if resource_link_id: + time_line_item.set_resource_id(resource_link_id) + + time_line_item = ags.find_or_create_lineitem(time_line_item) + times = ags.get_grades(time_line_item) + else: + scores = ags.get_grades() + times = None + + members = message_launch.get_nrps().get_members() + scoreboard_result = [] + + for sc in scores: + result = {'score': sc['resultScore']} + if times is None: + result['time'] = 'Not set' + else: + for tm in times: + if tm['userId'] == sc['userId']: + result['time'] = tm['resultScore'] + break + for member in members: + if member['user_id'] == sc['userId']: + result['name'] = member.get('name', 'Unknown') + break + scoreboard_result.append(result) + + return JsonResponse(scoreboard_result, safe=False) diff --git a/setup.py b/setup.py index d3bfb56..0e18ae2 100644 --- a/setup.py +++ b/setup.py @@ -40,6 +40,7 @@ "oauth2", "oauthlib", "pylti", + "pylti1p3", ], scripts=[], license="BSD", diff --git a/test_reqs.txt b/test_reqs.txt index 9388d21..14271ba 100644 --- a/test_reqs.txt +++ b/test_reqs.txt @@ -22,3 +22,12 @@ importlib-metadata<7.1 # for flake8 entrypoints==0.4 typing_extensions==4.10.0 pyparsing==3.1.2 + +certifi==2024.2.2 # requests +idna==3.6 # requests +charset_normalizer==3.3.2 # requests +urllib3==2.2.1 # requests +requests==2.31.0 # pylti1p3 +pyjwt==2.8.0 # pylti1p3 +jwcrypto==1.5.6 # pylti1p3 +pylti1p3==2.0.0