feat: Make content libraries an LTI 1.3 tool
Offers blockstore-based content through content libraries acting as an LTI 1.3 tool: - Content Library support LTI 1.3 launches offering blockstore-based content through resource links. - Content Library support LTI 1.3. AGS, allowing gradebook updates from graded assignments.
This commit is contained in:
committed by
Braden MacDonald
parent
bc5e6118a9
commit
14e2f29516
@@ -258,6 +258,16 @@ FEATURES = {
|
||||
# only supported in courses using split mongo.
|
||||
'ENABLE_CONTENT_LIBRARIES': True,
|
||||
|
||||
# .. toggle_name: FEATURES['ENABLE_CONTENT_LIBRARIES_LTI_TOOL']
|
||||
# .. toggle_implementation: DjangoSetting
|
||||
# .. toggle_default: False
|
||||
# .. toggle_description: When set to True, Content Libraries in
|
||||
# Studio can be used as an LTI 1.3 tool by external LTI platforms.
|
||||
# .. toggle_use_cases: open_edx
|
||||
# .. toggle_creation_date: 2021-08-17
|
||||
# .. toggle_tickets: https://github.com/edx/edx-platform/pull/27411
|
||||
'ENABLE_CONTENT_LIBRARIES_LTI_TOOL': False,
|
||||
|
||||
# Milestones application flag
|
||||
'MILESTONES_APP': False,
|
||||
|
||||
@@ -617,6 +627,7 @@ EDX_ROOT_URL = ''
|
||||
AUTHENTICATION_BACKENDS = [
|
||||
'auth_backends.backends.EdXOAuth2',
|
||||
'rules.permissions.ObjectPermissionBackend',
|
||||
'openedx.core.djangoapps.content_libraries.auth.LtiAuthenticationBackend',
|
||||
'openedx.core.djangoapps.oauth_dispatch.dot_overrides.backends.EdxRateLimitedAllowAllUsersModelBackend',
|
||||
'bridgekeeper.backends.RulePermissionBackend',
|
||||
]
|
||||
@@ -1629,6 +1640,9 @@ INSTALLED_APPS = [
|
||||
|
||||
# Allow Studio to use LMS for SSO
|
||||
'social_django',
|
||||
|
||||
# Content Library LTI 1.3 Support.
|
||||
'pylti1p3.contrib.django.lti1p3_tool_config',
|
||||
]
|
||||
|
||||
|
||||
|
||||
295
cms/templates/content_libraries/xblock_iframe.html
Normal file
295
cms/templates/content_libraries/xblock_iframe.html
Normal file
@@ -0,0 +1,295 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<html>
|
||||
<head>
|
||||
<!-- Open links in a new tab, not this iframe -->
|
||||
<base target="_blank">
|
||||
<meta charset="UTF-8">
|
||||
<!-- gettext & XBlock JS i18n code -->
|
||||
<script type="text/javascript" src="{{ lms_root_url }}/static/js/i18n/en/djangojs.js"></script>
|
||||
<!-- Most XBlocks require jQuery: -->
|
||||
<script src="https://code.jquery.com/jquery-2.2.4.min.js"></script>
|
||||
<!-- The Video XBlock requires "ajaxWithPrefix" -->
|
||||
<script type="text/javascript">
|
||||
$.postWithPrefix = $.post;
|
||||
$.getWithPrefix = $.get;
|
||||
$.ajaxWithPrefix = $.ajax;
|
||||
</script>
|
||||
<!-- The Video XBlock requires "Slider" from jQuery-UI: -->
|
||||
<script src="https://code.jquery.com/ui/1.12.1/jquery-ui.min.js"></script>
|
||||
<!-- The video XBlock depends on Underscore.JS -->
|
||||
<script type="text/javascript" src="{{ lms_root_url }}/static/common/js/vendor/underscore.js"></script>
|
||||
<!-- The video XBlock depends on jquery-cookie -->
|
||||
<script type="text/javascript" src="{{ lms_root_url }}/static/js/vendor/jquery.cookie.js"></script>
|
||||
<!--The Video XBlock has an undeclared dependency on 'Logger' -->
|
||||
<script>
|
||||
window.Logger = { log: function() { } };
|
||||
</script>
|
||||
<!-- Builtin XBlock types depend on RequireJS -->
|
||||
<script type="text/javascript" src="{{ lms_root_url }}/static/common/js/vendor/require.js"></script>
|
||||
<script type="text/javascript" src="{{ lms_root_url }}/static/js/RequireJS-namespace-undefine.js"></script>
|
||||
<script>
|
||||
// The minimal RequireJS configuration required for common LMS building XBlock types to work:
|
||||
(function (require, define) {
|
||||
require.config({
|
||||
baseUrl: "{{ lms_root_url }}/static/",
|
||||
paths: {
|
||||
accessibility: 'js/src/accessibility_tools',
|
||||
draggabilly: 'js/vendor/draggabilly',
|
||||
hls: 'common/js/vendor/hls',
|
||||
moment: 'common/js/vendor/moment-with-locales',
|
||||
HtmlUtils: 'edx-ui-toolkit/js/utils/html-utils',
|
||||
},
|
||||
});
|
||||
define('gettext', [], function() { return window.gettext; });
|
||||
define('jquery', [], function() { return window.jQuery; });
|
||||
define('jquery-migrate', [], function() { return window.jQuery; });
|
||||
define('underscore', [], function() { return window._; });
|
||||
}).call(this, require || RequireJS.require, define || RequireJS.define);
|
||||
</script>
|
||||
<!-- edX HTML Utils requires GlobalLoader -->
|
||||
<script type="text/javascript" src="{{ lms_root_url }}/static/edx-ui-toolkit/js/utils/global-loader.js"></script>
|
||||
<script>
|
||||
// The video XBlock has an undeclared dependency on edX HTML Utils
|
||||
RequireJS.require(['HtmlUtils'], function (HtmlUtils) {
|
||||
window.edx.HtmlUtils = HtmlUtils;
|
||||
// The problem XBlock depends on window.SR, though 'accessibility_tools' has an undeclared dependency on HtmlUtils:
|
||||
RequireJS.require(['accessibility']);
|
||||
});
|
||||
RequireJS.require(['edx-ui-toolkit/js/utils/string-utils'], function (StringUtils) {
|
||||
window.edx.StringUtils = StringUtils;
|
||||
});
|
||||
</script>
|
||||
<!--
|
||||
commons.js: this file produced by webpack contains many shared chunks of code.
|
||||
By including this, you have only to also import any of the smaller entrypoint
|
||||
files (defined in webpack.common.config.js) to get that entry point and all
|
||||
of its dependencies.
|
||||
-->
|
||||
<script type="text/javascript" src="{{ lms_root_url }}/static/xmodule_js/common_static/bundles/commons.js"></script>
|
||||
<!-- The video XBlock (and perhaps others?) expect this global: -->
|
||||
<script>
|
||||
window.onTouchBasedDevice = function() { return navigator.userAgent.match(/iPhone|iPod|iPad|Android/i); };
|
||||
</script>
|
||||
<!-- At least one XBlock (drag and drop v2) expects Font Awesome -->
|
||||
<link rel="stylesheet"
|
||||
href="https://stackpath.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css">
|
||||
<!-- Capa Problem Editing requires CodeMirror -->
|
||||
<link rel="stylesheet" href="{{ lms_root_url }}/static/js/vendor/CodeMirror/codemirror.css">
|
||||
<!-- Built-in XBlocks (and some plugins) depends on LMS CSS -->
|
||||
<link rel="stylesheet" href="{{ lms_root_url }}/static/css/lms-course.css">
|
||||
<!-- Configure and load MathJax -->
|
||||
<script type="text/x-mathjax-config">
|
||||
MathJax.Hub.Config({
|
||||
tex2jax: {
|
||||
inlineMath: [
|
||||
["\\(","\\)"],
|
||||
['[mathjaxinline]','[/mathjaxinline]']
|
||||
],
|
||||
displayMath: [
|
||||
["\\[","\\]"],
|
||||
['[mathjax]','[/mathjax]']
|
||||
]
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<script type="text/x-mathjax-config">
|
||||
MathJax.Hub.signal.Interest(function(message) {
|
||||
if(message[0] === "End Math") {
|
||||
set_mathjax_display_div_settings();
|
||||
}
|
||||
});
|
||||
function set_mathjax_display_div_settings() {
|
||||
$('.MathJax_Display').each(function( index ) {
|
||||
this.setAttribute('tabindex', '0');
|
||||
this.setAttribute('aria-live', 'off');
|
||||
this.removeAttribute('role');
|
||||
this.removeAttribute('aria-readonly');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<script type="text/javascript">
|
||||
// Activating Mathjax accessibility files
|
||||
window.MathJax = {
|
||||
menuSettings: {
|
||||
collapsible: true,
|
||||
autocollapse: false,
|
||||
explorer: true
|
||||
}
|
||||
};
|
||||
</script>
|
||||
<!-- This must appear after all mathjax-config blocks, so it is after the imports from the other templates.
|
||||
It can't be run through static.url because MathJax uses crazy url introspection to do lazy loading of
|
||||
MathJax extension libraries -->
|
||||
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/mathjax@2.7.5/MathJax.js?config=TeX-MML-AM_SVG"></script>
|
||||
<!-- fragment head -->
|
||||
{{ fragment.head_html | safe }}
|
||||
</head>
|
||||
<body>
|
||||
<!-- fragment body -->
|
||||
{{ fragment.body_html | safe }}
|
||||
<!-- fragment foot -->
|
||||
{{ fragment.foot_html | safe }}
|
||||
<script>
|
||||
/**
|
||||
* Map of all URL handlers for this block and its children, keyed by usage
|
||||
* key.
|
||||
*/
|
||||
{% comment %}
|
||||
This variable is expected to be a valid JSON, which will be translated
|
||||
directly into a javascript object.
|
||||
{% endcomment %}
|
||||
|
||||
HANDLER_URL_MAP = {{ handler_urls_json | safe }};
|
||||
|
||||
/**
|
||||
* The JavaScript code which runs inside our IFrame and is responsible
|
||||
* for communicating with the parent window.
|
||||
*
|
||||
* This cannot use any imported functions because it runs in the IFrame,
|
||||
* not in our app webpack bundle.
|
||||
*/
|
||||
function blockFrameJS() {
|
||||
const CHILDREN_KEY = '_jsrt_xb_children'; // JavaScript RunTime XBlock children
|
||||
const USAGE_ID_KEY = '_jsrt_xb_usage_id';
|
||||
const HANDLER_URL = '_jsrt_xb_handler_url';
|
||||
|
||||
const uniqueKeyPrefix = `k${+Date.now()}-${Math.floor(Math.random() * 1e10)}-`;
|
||||
let messageCount = 0;
|
||||
|
||||
/**
|
||||
* The JavaScript runtime for any XBlock in the IFrame
|
||||
*/
|
||||
const runtime = {
|
||||
/**
|
||||
* An obscure and little-used API that retrieves a particular
|
||||
* XBlock child using its 'data-name' attribute
|
||||
* @param block The root DIV element of the XBlock calling this method
|
||||
* @param childName The value of the 'data-name' attribute of the root
|
||||
* DIV element of the XBlock child in question.
|
||||
*/
|
||||
childMap: (block, childName) => runtime.children(block).find((child) => child.element.getAttribute('data-name') === childName),
|
||||
children: (block) => block[CHILDREN_KEY],
|
||||
/**
|
||||
* Get the URL for the specified handler. This method must be synchronous, so
|
||||
* cannot make HTTP requests.
|
||||
*/
|
||||
handlerUrl: (block, handlerName, suffix, query) => {
|
||||
let url = block[HANDLER_URL].replace('handler_name', handlerName);
|
||||
if (suffix) {
|
||||
url += `${suffix}/`;
|
||||
}
|
||||
if (query) {
|
||||
url += `?${query}`;
|
||||
}
|
||||
return url;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize an XBlock. This function should only be called by initializeXBlockAndChildren
|
||||
* because it assumes that function has already run.
|
||||
*/
|
||||
function initializeXBlock(element, callback) {
|
||||
const usageId = element[USAGE_ID_KEY];
|
||||
// Check if the XBlock has an initialization function:
|
||||
const initFunctionName = element.getAttribute('data-init');
|
||||
if (initFunctionName !== null) {
|
||||
// Since this block has an init function, it may need to call handlers:
|
||||
element[HANDLER_URL] = HANDLER_URL_MAP[usageId];
|
||||
// Now proceed with initializing the block's JavaScript:
|
||||
const InitFunction = (window)[initFunctionName];
|
||||
// Does the XBlock HTML contain arguments to pass to the InitFunction?
|
||||
let data = {};
|
||||
[].forEach.call(element.children, (childNode) => {
|
||||
// The newer/pure/Blockstore runtime uses 'xblock_json_init_args'
|
||||
// while the LMS runtime uses 'xblock-json-init-args'.
|
||||
if (
|
||||
childNode.matches('script.xblock_json_init_args')
|
||||
|| childNode.matches('script.xblock-json-init-args')
|
||||
) {
|
||||
data = JSON.parse(childNode.textContent);
|
||||
}
|
||||
});
|
||||
// An unfortunate inconsistency is that the old Studio runtime used
|
||||
// to pass 'element' as a jQuery-wrapped DOM element, whereas the LMS
|
||||
// runtime used to pass 'element' as the pure DOM node. In order not to
|
||||
// break backwards compatibility, we would need to maintain that.
|
||||
// However, this is currently disabled as it causes issues (need to
|
||||
// modify the runtime methods like handlerUrl too), and we decided not
|
||||
// to maintain support for legacy studio_view in this runtime.
|
||||
// const isStudioView = element.className.indexOf('studio_view') !== -1;
|
||||
// const passElement = isStudioView && (window as any).$ ? (window as any).$(element) : element;
|
||||
const blockJS = new InitFunction(runtime, element, data) || {};
|
||||
blockJS.element = element;
|
||||
callback(blockJS);
|
||||
} else {
|
||||
const blockJS = { element };
|
||||
callback(blockJS);
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively initialize the JavaScript code of each XBlock:
|
||||
function initializeXBlockAndChildren(element, callback) {
|
||||
// The newer/pure/Blockstore runtime uses the 'data-usage' attribute, while the LMS uses 'data-usage-id'
|
||||
const usageId = element.getAttribute('data-usage') || element.getAttribute('data-usage-id');
|
||||
if (usageId !== null) {
|
||||
element[USAGE_ID_KEY] = usageId;
|
||||
} else {
|
||||
throw new Error('XBlock is missing a usage ID attribute on its root HTML node.');
|
||||
}
|
||||
|
||||
const version = element.getAttribute('data-runtime-version');
|
||||
if (version != null && version !== '1') {
|
||||
throw new Error('Unsupported XBlock runtime version requirement.');
|
||||
}
|
||||
|
||||
// Recursively initialize any children first:
|
||||
// We need to find all div.xblock-v1 children, unless they're grandchilden
|
||||
// So we build a list of all div.xblock-v1 descendants that aren't descendants
|
||||
// of an already-found descendant:
|
||||
const childNodesFound = [];
|
||||
[].forEach.call(element.querySelectorAll('.xblock, .xblock-v1'), (childNode) => {
|
||||
if (!childNodesFound.find((el) => el.contains(childNode))) {
|
||||
childNodesFound.push(childNode);
|
||||
}
|
||||
});
|
||||
|
||||
// This code is awkward because we can't use promises (IE11 etc.)
|
||||
let childrenInitialized = -1;
|
||||
function initNextChild() {
|
||||
childrenInitialized += 1;
|
||||
if (childrenInitialized < childNodesFound.length) {
|
||||
const childNode = childNodesFound[childrenInitialized];
|
||||
initializeXBlockAndChildren(childNode, initNextChild);
|
||||
} else {
|
||||
// All children are initialized:
|
||||
initializeXBlock(element, callback);
|
||||
}
|
||||
}
|
||||
initNextChild();
|
||||
}
|
||||
|
||||
// Find the root XBlock node.
|
||||
// The newer/pure/Blockstore runtime uses '.xblock-v1' while the LMS runtime uses '.xblock'.
|
||||
const rootNode = document.querySelector('.xblock, .xblock-v1'); // will always return the first matching element
|
||||
initializeXBlockAndChildren(rootNode, () => {
|
||||
});
|
||||
|
||||
let lastHeight = -1;
|
||||
function checkFrameHeight() {
|
||||
const newHeight = document.documentElement.scrollHeight;
|
||||
if (newHeight !== lastHeight) {
|
||||
lastHeight = newHeight;
|
||||
}
|
||||
}
|
||||
// Check the size whenever the DOM changes:
|
||||
new MutationObserver(checkFrameHeight).observe(document.body, { attributes: true, childList: true, subtree: true });
|
||||
// And whenever the IFrame is resized
|
||||
window.addEventListener('resize', checkFrameHeight);
|
||||
}
|
||||
|
||||
window.addEventListener('load', blockFrameJS);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -3150,7 +3150,10 @@ INSTALLED_APPS = [
|
||||
'openedx.core.djangoapps.agreements',
|
||||
|
||||
# User and group management via edx-django-utils
|
||||
'edx_django_utils.user'
|
||||
'edx_django_utils.user',
|
||||
|
||||
# Content Library LTI 1.3 Support.
|
||||
'pylti1p3.contrib.django.lti1p3_tool_config',
|
||||
]
|
||||
|
||||
######################### CSRF #########################################
|
||||
|
||||
@@ -19,7 +19,16 @@ class ContentLibraryAdmin(admin.ModelAdmin):
|
||||
"""
|
||||
Definition of django admin UI for Content Libraries
|
||||
"""
|
||||
fields = ("library_key", "org", "slug", "bundle_uuid", "allow_public_learning", "allow_public_read")
|
||||
|
||||
fields = (
|
||||
"library_key",
|
||||
"org",
|
||||
"slug",
|
||||
"bundle_uuid",
|
||||
"allow_public_learning",
|
||||
"allow_public_read",
|
||||
"authorized_lti_configs",
|
||||
)
|
||||
list_display = ("slug", "org", "bundle_uuid")
|
||||
inlines = (ContentLibraryPermissionInline, )
|
||||
|
||||
|
||||
@@ -176,6 +176,7 @@ class ContentLibraryMetadata:
|
||||
# has_unpublished_deletes will be true when the draft version of the library's bundle
|
||||
# contains deletes of any XBlocks that were in the most recently published version
|
||||
has_unpublished_deletes = attr.ib(False)
|
||||
allow_lti = attr.ib(False)
|
||||
# Allow any user (even unregistered users) to view and interact directly
|
||||
# with this library's content in the LMS
|
||||
allow_public_learning = attr.ib(False)
|
||||
@@ -392,6 +393,7 @@ def get_library(library_key):
|
||||
num_blocks=num_blocks,
|
||||
version=bundle_metadata.latest_version,
|
||||
last_published=last_published,
|
||||
allow_lti=ref.allow_lti,
|
||||
allow_public_learning=ref.allow_public_learning,
|
||||
allow_public_read=ref.allow_public_read,
|
||||
has_unpublished_changes=has_unpublished_changes,
|
||||
|
||||
@@ -31,3 +31,9 @@ class ContentLibrariesConfig(AppConfig):
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
def ready(self):
|
||||
"""
|
||||
Import signal handler's module to ensure they are registered.
|
||||
"""
|
||||
from . import signal_handlers # pylint: disable=unused-import
|
||||
|
||||
43
openedx/core/djangoapps/content_libraries/auth.py
Normal file
43
openedx/core/djangoapps/content_libraries/auth.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""
|
||||
Content Library LTI authentication.
|
||||
|
||||
This module offers an authentication backend to support LTI launches within
|
||||
content libraries.
|
||||
"""
|
||||
|
||||
|
||||
import logging
|
||||
|
||||
from django.contrib.auth.backends import ModelBackend
|
||||
|
||||
from .models import LtiProfile
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LtiAuthenticationBackend(ModelBackend):
|
||||
"""
|
||||
Authenticate based on content library LTI profile.
|
||||
|
||||
The backend assumes the profile was previously created and its presence is
|
||||
enough to assume the launch claims are valid.
|
||||
"""
|
||||
|
||||
# pylint: disable=arguments-differ
|
||||
def authenticate(self, request, iss=None, aud=None, sub=None, **kwargs):
|
||||
"""
|
||||
Authenticate if the user in the request has an LTI profile.
|
||||
"""
|
||||
log.info('LTI 1.3 authentication: iss=%s, sub=%s', iss, sub)
|
||||
try:
|
||||
lti_profile = LtiProfile.objects.get_from_claims(
|
||||
iss=iss, aud=aud, sub=sub)
|
||||
except LtiProfile.DoesNotExist:
|
||||
return None
|
||||
user = lti_profile.user
|
||||
log.info('LTI 1.3 authentication profile: profile=%s user=%s',
|
||||
lti_profile, user)
|
||||
if user and self.user_can_authenticate(user):
|
||||
return user
|
||||
return None
|
||||
@@ -0,0 +1,44 @@
|
||||
# Generated by Django 2.2.20 on 2021-05-11 15:43
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('content_libraries', '0004_contentlibrary_license'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='LtiProfile',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('platform_id', models.CharField(help_text='The LTI platform identifier to which this profile belongs to.', max_length=255, verbose_name='platform identifier')),
|
||||
('client_id', models.CharField(help_text='The LTI client identifier generated by the platform.', max_length=255, verbose_name='client identifier')),
|
||||
('subject_id', models.CharField(help_text='Identifies the entity that initiated the deep linking request, commonly a user. If set to ``None`` the profile belongs to the Anonymous entity.', max_length=255, verbose_name='subject identifier')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('user', models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='contentlibraries_lti_profile', to=settings.AUTH_USER_MODEL, verbose_name='open edx user')),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('platform_id', 'client_id', 'subject_id')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='LtiGradedResource',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('usage_key', models.CharField(help_text='The usage key string of the blockstore resource serving the content of this launch.', max_length=255)),
|
||||
('resource_id', models.CharField(help_text='The platform unique identifier of this resource in the platform, also known as "resource link id".', max_length=255)),
|
||||
('resource_title', models.CharField(help_text='The platform descriptive title for this resource placed in the platform.', max_length=255, null=True)),
|
||||
('ags_lineitem', models.CharField(help_text='If AGS was enabled during launch, this should hold the lineitem ID.', max_length=255)),
|
||||
('profile', models.ForeignKey(help_text='The authorized LTI profile that launched the resource.', on_delete=django.db.models.deletion.CASCADE, related_name='lti_resources', to='content_libraries.LtiProfile')),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('usage_key', 'profile')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
# Generated by Django 2.2.20 on 2021-06-15 19:16
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('lti1p3_tool_config', '0001_initial'),
|
||||
('content_libraries', '0005_ltigradedresource_ltiprofile'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='contentlibrary',
|
||||
name='lti_tool',
|
||||
field=models.ForeignKey(default=None, help_text="Authorize the LTI tool selected to expose this library's content through LTI launches, leave unselected to disable LTI launches.", null=True, on_delete=django.db.models.deletion.SET_NULL, to='lti1p3_tool_config.LtiTool'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='ltiprofile',
|
||||
name='subject_id',
|
||||
field=models.CharField(help_text='Identifies the entity that initiated the launch request, commonly a user.', max_length=255, verbose_name='subject identifier'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,14 @@
|
||||
# Generated by Django 2.2.24 on 2021-08-18 06:14
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('content_libraries', '0005_contentlibraryblockimporttask'),
|
||||
('content_libraries', '0006_auto_20210615_1916'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
]
|
||||
@@ -0,0 +1,55 @@
|
||||
# Generated by Django 2.2.24 on 2021-08-18 21:48
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import opaque_keys.edx.django.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('lti1p3_tool_config', '0001_initial'),
|
||||
('content_libraries', '0007_merge_20210818_0614'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='contentlibrary',
|
||||
name='lti_tool',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='contentlibrary',
|
||||
name='authorized_lti_configs',
|
||||
field=models.ManyToManyField(help_text="List of authorized LTI tool configurations that can access this library's content through LTI launches, if empty no LTI launch is allowed.", related_name='content_libraries', to='lti1p3_tool_config.LtiTool'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='ltigradedresource',
|
||||
name='profile',
|
||||
field=models.ForeignKey(help_text='The authorized LTI profile that launched the resource (identifies the user).', on_delete=django.db.models.deletion.CASCADE, related_name='lti_resources', to='content_libraries.LtiProfile'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='ltigradedresource',
|
||||
name='resource_id',
|
||||
field=models.CharField(help_text='The LTI platform unique identifier of this resource, also known as the "resource link id".', max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='ltigradedresource',
|
||||
name='resource_title',
|
||||
field=models.CharField(help_text='The LTI platform descriptive title for this resource.', max_length=255, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='ltigradedresource',
|
||||
name='usage_key',
|
||||
field=opaque_keys.edx.django.models.UsageKeyField(help_text='The usage key string of the blockstore resource serving the content of this launch.', max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='ltiprofile',
|
||||
name='client_id',
|
||||
field=models.CharField(help_text='The LTI client identifier generated by the LTI platform.', max_length=255, verbose_name='client identifier'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='ltiprofile',
|
||||
name='platform_id',
|
||||
field=models.CharField(help_text='The LTI platform identifier to which this profile belongs to.', max_length=255, verbose_name='lti platform identifier'),
|
||||
),
|
||||
]
|
||||
@@ -1,23 +1,68 @@
|
||||
"""
|
||||
Models for new Content Libraries.
|
||||
========================
|
||||
Content Libraries Models
|
||||
========================
|
||||
|
||||
This module contains the models for new Content Libraries.
|
||||
|
||||
LTI 1.3 Models
|
||||
==============
|
||||
|
||||
Content Libraries serves blockstore-based content through LTI 1.3 launches.
|
||||
The interface supports resource link launches and grading services. Two use
|
||||
cases justify the current data model to support LTI launches. They are:
|
||||
|
||||
1. Authentication and authorization. This use case demands management of user
|
||||
lifecycle to authorize access to content and grade submission, and it
|
||||
introduces a model to own the authentication business logic related to LTI.
|
||||
|
||||
2. Grade and assignments. When AGS is supported, content libraries store
|
||||
additional information concerning the launched resource so that, once the
|
||||
grading sub-system submits the score, it can retrieve them to propagate the
|
||||
score update into the LTI platform's grade book.
|
||||
|
||||
Relationship with LMS's ``lti_provider``` models
|
||||
------------------------------------------------
|
||||
|
||||
The data model above is similar to the one provided by the current LTI 1.1
|
||||
implementation for modulestore and courseware content. But, Content Libraries
|
||||
is orthogonal. Its use-case is to offer standalone, embedded content from a
|
||||
specific backend (blockstore). As such, it decouples from LTI 1.1. and the
|
||||
logic assume no relationship or impact across the two applications. The same
|
||||
reasoning applies to steps beyond the data model, such as at the XBlock
|
||||
runtime, authentication, and score handling, etc.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Group
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.db import transaction
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from opaque_keys.edx.django.models import CourseKeyField
|
||||
from opaque_keys.edx.locator import LibraryLocatorV2
|
||||
from pylti1p3.contrib.django import DjangoDbToolConf
|
||||
from pylti1p3.contrib.django import DjangoMessageLaunch
|
||||
from pylti1p3.contrib.django.lti1p3_tool_config.models import LtiTool
|
||||
from pylti1p3.grade import Grade
|
||||
|
||||
from opaque_keys.edx.django.models import UsageKeyField
|
||||
from openedx.core.djangoapps.content_libraries.constants import (
|
||||
LIBRARY_TYPES, COMPLEX, LICENSE_OPTIONS,
|
||||
ALL_RIGHTS_RESERVED,
|
||||
)
|
||||
from organizations.models import Organization # lint-amnesty, pylint: disable=wrong-import-order
|
||||
|
||||
from .apps import ContentLibrariesConfig
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
@@ -40,7 +85,7 @@ class ContentLibrary(models.Model):
|
||||
All actual content is stored in Blockstore, and any data that we'd want to
|
||||
transfer to another instance if this library were exported and then
|
||||
re-imported on another Open edX instance should be kept in Blockstore. This
|
||||
model in the LMS should only be used to track settings specific to this Open
|
||||
model in Studio should only be used to track settings specific to this Open
|
||||
edX instance, like who has permission to edit this content library.
|
||||
"""
|
||||
objects = ContentLibraryManager()
|
||||
@@ -76,6 +121,14 @@ class ContentLibrary(models.Model):
|
||||
"""),
|
||||
)
|
||||
|
||||
authorized_lti_configs = models.ManyToManyField(
|
||||
LtiTool,
|
||||
related_name='content_libraries',
|
||||
help_text=("List of authorized LTI tool configurations that can access "
|
||||
"this library's content through LTI launches, if empty no LTI "
|
||||
"launch is allowed.")
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name_plural = "Content Libraries"
|
||||
unique_together = ("org", "slug")
|
||||
@@ -87,6 +140,29 @@ class ContentLibrary(models.Model):
|
||||
"""
|
||||
return LibraryLocatorV2(org=self.org.short_name, slug=self.slug)
|
||||
|
||||
@property
|
||||
def allow_lti(self):
|
||||
"""
|
||||
True if there is at least one LTI tool configuration associated if this
|
||||
library.
|
||||
"""
|
||||
return self.authorized_lti_configs.exists()
|
||||
|
||||
@classmethod
|
||||
def authorize_lti_launch(cls, library_key, *, issuer, client_id=None):
|
||||
"""
|
||||
Check if the given Issuer and Client ID are authorized to launch content
|
||||
from this library.
|
||||
"""
|
||||
return (ContentLibrary
|
||||
.objects
|
||||
.filter(authorized_lti_configs__issuer=issuer,
|
||||
authorized_lti_configs__client_id=client_id,
|
||||
authorized_lti_configs__is_active=True,
|
||||
org__short_name=library_key.org,
|
||||
slug=library_key.slug)
|
||||
.exists())
|
||||
|
||||
def __str__(self):
|
||||
return f"ContentLibrary ({str(self.library_key)})"
|
||||
|
||||
@@ -211,3 +287,241 @@ class ContentLibraryBlockImportTask(models.Model):
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.course_id} to {self.library} #{self.pk}'
|
||||
|
||||
|
||||
class LtiProfileManager(models.Manager):
|
||||
"""
|
||||
Custom manager of LtiProfile mode.
|
||||
"""
|
||||
|
||||
def get_from_claims(self, *, iss, aud, sub):
|
||||
"""
|
||||
Get the an instance from a LTI launch claims.
|
||||
"""
|
||||
return self.get(platform_id=iss, client_id=aud, subject_id=sub)
|
||||
|
||||
def get_or_create_from_claims(self, *, iss, aud, sub):
|
||||
"""
|
||||
Get or create an instance from a LTI launch claims.
|
||||
"""
|
||||
try:
|
||||
return self.get_from_claims(iss=iss, aud=aud, sub=sub)
|
||||
except self.model.DoesNotExist:
|
||||
# User will be created on ``save()``.
|
||||
return self.create(platform_id=iss, client_id=aud, subject_id=sub)
|
||||
|
||||
|
||||
class LtiProfile(models.Model):
|
||||
"""
|
||||
Content Libraries LTI's profile for Open edX users.
|
||||
|
||||
Unless Anonymous, this should be a unique representation of the LTI subject
|
||||
(as per the client token ``sub`` identify claim) that initiated an LTI
|
||||
launch through Content Libraries.
|
||||
"""
|
||||
|
||||
objects = LtiProfileManager()
|
||||
|
||||
user = models.OneToOneField(
|
||||
get_user_model(),
|
||||
null=True,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='contentlibraries_lti_profile',
|
||||
verbose_name=_('open edx user'),
|
||||
)
|
||||
|
||||
platform_id = models.CharField(
|
||||
max_length=255,
|
||||
verbose_name=_('lti platform identifier'),
|
||||
help_text=_("The LTI platform identifier to which this profile belongs "
|
||||
"to.")
|
||||
)
|
||||
|
||||
client_id = models.CharField(
|
||||
max_length=255,
|
||||
verbose_name=_('client identifier'),
|
||||
help_text=_("The LTI client identifier generated by the LTI platform.")
|
||||
)
|
||||
|
||||
subject_id = models.CharField(
|
||||
max_length=255,
|
||||
verbose_name=_('subject identifier'),
|
||||
help_text=_('Identifies the entity that initiated the launch request, '
|
||||
'commonly a user.')
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(
|
||||
auto_now_add=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = ['platform_id', 'client_id', 'subject_id']
|
||||
|
||||
@property
|
||||
def subject_url(self):
|
||||
"""
|
||||
An local URL that is known to uniquely identify this profile.
|
||||
|
||||
We take advantage of the fact that platform id is required to be an URL
|
||||
and append paths with the reamaining keys to it.
|
||||
"""
|
||||
return '/'.join([
|
||||
self.platform_id.rstrip('/'),
|
||||
self.client_id,
|
||||
self.subject_id
|
||||
])
|
||||
|
||||
def save(self, *args, **kwds):
|
||||
"""
|
||||
Get or create an edx user on save.
|
||||
"""
|
||||
if not self.user:
|
||||
uid = uuid.uuid5(uuid.NAMESPACE_URL, self.subject_url)
|
||||
username = f'urn:openedx:content_libraries:username:{uid}'
|
||||
email = f'{uid}@{ContentLibrariesConfig.name}'
|
||||
with transaction.atomic():
|
||||
if self.user is None:
|
||||
self.user, created = User.objects.get_or_create(
|
||||
username=username,
|
||||
defaults={'email': email})
|
||||
if created:
|
||||
# LTI users can only auth throught LTI launches.
|
||||
self.user.set_unusable_password()
|
||||
self.user.save()
|
||||
super().save(*args, **kwds)
|
||||
|
||||
def __str__(self):
|
||||
return self.subject_id
|
||||
|
||||
|
||||
class LtiGradedResourceManager(models.Manager):
|
||||
"""
|
||||
A custom manager for the graded resources model.
|
||||
"""
|
||||
|
||||
def upsert_from_ags_launch(self, user, block, resource_endpoint, resource_link):
|
||||
"""
|
||||
Update or create a graded resource at AGS launch.
|
||||
"""
|
||||
resource_id = resource_link['id']
|
||||
resource_title = resource_link.get('title') or None
|
||||
lineitem = resource_endpoint['lineitem']
|
||||
lti_profile = user.contentlibraries_lti_profile
|
||||
resource, _ = self.update_or_create(
|
||||
profile=lti_profile,
|
||||
usage_key=block.scope_ids.usage_id,
|
||||
defaults={'resource_title': resource_title,
|
||||
'resource_id': resource_id,
|
||||
'ags_lineitem': lineitem}
|
||||
)
|
||||
return resource
|
||||
|
||||
def get_from_user_id(self, user_id, **kwds):
|
||||
"""
|
||||
Retrieve a resource for a given user id holding an lti profile.
|
||||
"""
|
||||
try:
|
||||
user = get_user_model().objects.get(pk=user_id)
|
||||
except get_user_model().DoesNotExist as exc:
|
||||
raise self.model.DoesNotExist('User specified was not found.') from exc
|
||||
profile = getattr(user, 'contentlibraries_lti_profile', None)
|
||||
if not profile:
|
||||
raise self.model.DoesNotExist('User does not have a LTI profile.')
|
||||
kwds['profile'] = profile
|
||||
return self.get(**kwds)
|
||||
|
||||
|
||||
class LtiGradedResource(models.Model):
|
||||
"""
|
||||
A content libraries resource launched through LTI with AGS enabled.
|
||||
|
||||
Essentially, an instance of this model represents a successful LTI AGS
|
||||
launch. This model links the profile that launched the resource with the
|
||||
resource itself, allowing identifcation of the link through its usage key
|
||||
string and user id.
|
||||
"""
|
||||
|
||||
objects = LtiGradedResourceManager()
|
||||
|
||||
profile = models.ForeignKey(
|
||||
LtiProfile,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='lti_resources',
|
||||
help_text=_('The authorized LTI profile that launched the resource '
|
||||
'(identifies the user).'))
|
||||
|
||||
usage_key = UsageKeyField(
|
||||
max_length=255,
|
||||
help_text=_('The usage key string of the blockstore resource serving the '
|
||||
'content of this launch.'),
|
||||
)
|
||||
|
||||
resource_id = models.CharField(
|
||||
max_length=255,
|
||||
help_text=_('The LTI platform unique identifier of this resource, also '
|
||||
'known as the "resource link id".'),
|
||||
)
|
||||
|
||||
resource_title = models.CharField(
|
||||
max_length=255,
|
||||
null=True,
|
||||
help_text=_('The LTI platform descriptive title for this resource.'),
|
||||
)
|
||||
|
||||
ags_lineitem = models.CharField(
|
||||
max_length=255,
|
||||
null=False,
|
||||
help_text=_('If AGS was enabled during launch, this should hold the '
|
||||
'lineitem ID.'))
|
||||
|
||||
class Meta:
|
||||
unique_together = (['usage_key', 'profile'])
|
||||
|
||||
def update_score(self, weighted_earned, weighted_possible, timestamp):
|
||||
"""
|
||||
Use LTI's score service to update the LTI platform's gradebook.
|
||||
|
||||
This method synchronously send a request to the LTI platform to update
|
||||
the assignment score.
|
||||
"""
|
||||
|
||||
launch_data = {
|
||||
'iss': self.profile.platform_id,
|
||||
'aud': self.profile.client_id,
|
||||
'https://purl.imsglobal.org/spec/lti-ags/claim/endpoint': {
|
||||
'lineitem': self.ags_lineitem,
|
||||
'scope': {
|
||||
'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem',
|
||||
'https://purl.imsglobal.org/spec/lti-ags/scope/score',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tool_config = DjangoDbToolConf()
|
||||
|
||||
ags = (
|
||||
DjangoMessageLaunch(request=None, tool_config=tool_config)
|
||||
.set_auto_validation(enable=False)
|
||||
.set_jwt({'body': launch_data})
|
||||
.set_restored()
|
||||
.validate_registration()
|
||||
.get_ags()
|
||||
)
|
||||
|
||||
if weighted_possible == 0:
|
||||
weighted_score = 0
|
||||
else:
|
||||
weighted_score = float(weighted_earned) / float(weighted_possible)
|
||||
|
||||
ags.put_grade(
|
||||
Grade()
|
||||
.set_score_given(weighted_score)
|
||||
.set_score_maximum(1)
|
||||
.set_timestamp(timestamp.isoformat())
|
||||
.set_activity_progress('Submitted')
|
||||
.set_grading_progress('FullyGraded')
|
||||
.set_user_id(self.profile.subject_id)
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return str(self.usage_key)
|
||||
|
||||
@@ -41,6 +41,7 @@ class ContentLibraryMetadataSerializer(serializers.Serializer):
|
||||
num_blocks = serializers.IntegerField(read_only=True)
|
||||
version = serializers.IntegerField(read_only=True)
|
||||
last_published = serializers.DateTimeField(format=DATETIME_FORMAT, read_only=True)
|
||||
allow_lti = serializers.BooleanField(default=False, read_only=True)
|
||||
allow_public_learning = serializers.BooleanField(default=False)
|
||||
allow_public_read = serializers.BooleanField(default=False)
|
||||
has_unpublished_changes = serializers.BooleanField(read_only=True)
|
||||
|
||||
57
openedx/core/djangoapps/content_libraries/signal_handlers.py
Normal file
57
openedx/core/djangoapps/content_libraries/signal_handlers.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""
|
||||
Content library signal handlers.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.dispatch import receiver
|
||||
|
||||
from lms.djangoapps.grades.api import signals as grades_signals
|
||||
from opaque_keys import InvalidKeyError
|
||||
from opaque_keys.edx.locator import LibraryUsageLocatorV2
|
||||
|
||||
from .models import LtiGradedResource
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@receiver(grades_signals.PROBLEM_WEIGHTED_SCORE_CHANGED)
|
||||
def score_changed_handler(sender, **kwargs): # pylint: disable=unused-argument
|
||||
"""
|
||||
Match the score event to an LTI resource and update.
|
||||
"""
|
||||
|
||||
lti_enabled = (settings.FEATURES.get('ENABLE_CONTENT_LIBRARIES')
|
||||
and settings.FEATURES.get('ENABLE_CONTENT_LIBRARIES_LTI_TOOL'))
|
||||
if not lti_enabled:
|
||||
return
|
||||
|
||||
modified = kwargs.get('modified')
|
||||
usage_id = kwargs.get('usage_id')
|
||||
user_id = kwargs.get('user_id')
|
||||
weighted_earned = kwargs.get('weighted_earned')
|
||||
weighted_possible = kwargs.get('weighted_possible')
|
||||
|
||||
if None in (modified, usage_id, user_id, weighted_earned, weighted_possible):
|
||||
log.debug("LTI 1.3: Score Signal: Missing a required parameters, "
|
||||
"ignoring: kwargs=%s", kwargs)
|
||||
return
|
||||
try:
|
||||
usage_key = LibraryUsageLocatorV2.from_string(usage_id)
|
||||
except InvalidKeyError:
|
||||
log.debug("LTI 1.3: Score Signal: Not a content libraries v2 usage key, "
|
||||
"ignoring: usage_id=%s", usage_id)
|
||||
return
|
||||
try:
|
||||
resource = LtiGradedResource.objects.get_from_user_id(
|
||||
user_id, usage_key=usage_key
|
||||
)
|
||||
except LtiGradedResource.DoesNotExist:
|
||||
log.debug("LTI 1.3: Score Signal: Unknown resource, ignoring: kwargs=%s",
|
||||
kwargs)
|
||||
else:
|
||||
resource.update_score(weighted_earned, weighted_possible, modified)
|
||||
log.info("LTI 1.3: Score Signal: Grade upgraded: resource; %s",
|
||||
resource)
|
||||
@@ -37,6 +37,10 @@ URL_LIB_BLOCK_OLX = URL_LIB_BLOCK + 'olx/' # Get or set the OLX of the specifie
|
||||
URL_LIB_BLOCK_ASSETS = URL_LIB_BLOCK + 'assets/' # List the static asset files of the specified XBlock
|
||||
URL_LIB_BLOCK_ASSET_FILE = URL_LIB_BLOCK + 'assets/{file_name}' # Get, delete, or upload a specific static asset file
|
||||
|
||||
URL_LIB_LTI_PREFIX = URL_PREFIX + 'lti/1.3/'
|
||||
URL_LIB_LTI_JWKS = URL_LIB_LTI_PREFIX + 'pub/jwks/'
|
||||
URL_LIB_LTI_LAUNCH = URL_LIB_LTI_PREFIX + 'launch/'
|
||||
|
||||
URL_BLOCK_RENDER_VIEW = '/api/xblock/v2/xblocks/{block_key}/view/{view_name}/'
|
||||
URL_BLOCK_GET_HANDLER_URL = '/api/xblock/v2/xblocks/{block_key}/handler_url/{handler_name}/'
|
||||
URL_BLOCK_METADATA_URL = '/api/xblock/v2/xblocks/{block_key}/'
|
||||
|
||||
35
openedx/core/djangoapps/content_libraries/tests/test_auth.py
Normal file
35
openedx/core/djangoapps/content_libraries/tests/test_auth.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""
|
||||
Unit tests for Content Libraries authentication module.
|
||||
"""
|
||||
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
|
||||
from ..models import LtiProfile
|
||||
from ..models import get_user_model
|
||||
from ..auth import LtiAuthenticationBackend
|
||||
|
||||
|
||||
class LtiAuthenticationBackendTest(TestCase):
|
||||
"""
|
||||
AuthenticationBackend tests.
|
||||
"""
|
||||
|
||||
iss = 'http://foo.bar'
|
||||
aud = 'a-random-test-aud'
|
||||
sub = 'a-random-test-sub'
|
||||
|
||||
def test_without_profile(self):
|
||||
get_user_model().objects.create(username='foobar')
|
||||
backend = LtiAuthenticationBackend()
|
||||
user = backend.authenticate(None, iss=self.iss, aud=self.aud, sub=self.sub)
|
||||
self.assertIsNone(user)
|
||||
|
||||
def test_with_profile(self):
|
||||
profile = LtiProfile.objects.create(
|
||||
platform_id=self.iss, client_id=self.aud, subject_id=self.sub)
|
||||
backend = LtiAuthenticationBackend()
|
||||
user = backend.authenticate(None, iss=self.iss, aud=self.aud, sub=self.sub)
|
||||
self.assertIsNotNone(user)
|
||||
self.assertEqual(user.contentlibraries_lti_profile, profile)
|
||||
306
openedx/core/djangoapps/content_libraries/tests/test_models.py
Normal file
306
openedx/core/djangoapps/content_libraries/tests/test_models.py
Normal file
@@ -0,0 +1,306 @@
|
||||
"""
|
||||
Unit tests for Content Libraries models.
|
||||
"""
|
||||
|
||||
|
||||
from unittest import mock
|
||||
import uuid
|
||||
|
||||
from django.test import TestCase
|
||||
from django.test import RequestFactory
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
from pylti1p3.contrib.django.lti1p3_tool_config.models import LtiToolKey
|
||||
|
||||
from organizations.models import Organization
|
||||
from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2
|
||||
|
||||
from ..models import ALL_RIGHTS_RESERVED
|
||||
from ..models import COMPLEX
|
||||
from ..models import ContentLibrary
|
||||
from ..models import LtiGradedResource
|
||||
from ..models import LtiProfile
|
||||
from ..models import LtiTool
|
||||
|
||||
|
||||
class ContentLibraryTest(TestCase):
|
||||
"""
|
||||
Tests for ContentLibrary model.
|
||||
"""
|
||||
|
||||
def _create_library(self, **kwds):
|
||||
"""
|
||||
Create a library model, without a blockstore bundle attached to it.
|
||||
"""
|
||||
org = Organization.objects.create(name='foo', short_name='foo')
|
||||
return ContentLibrary.objects.create(
|
||||
org=org,
|
||||
slug='foobar',
|
||||
type=COMPLEX,
|
||||
bundle_uuid=uuid.uuid4(),
|
||||
allow_public_learning=False,
|
||||
allow_public_read=False,
|
||||
license=ALL_RIGHTS_RESERVED,
|
||||
**kwds,
|
||||
)
|
||||
|
||||
def test_authorize_lti_launch_when_no_library(self):
|
||||
"""
|
||||
Given no library
|
||||
When authorize_lti_launch is called
|
||||
Then return False
|
||||
"""
|
||||
self.assertFalse(ContentLibrary.objects.exists())
|
||||
authorized = ContentLibrary.authorize_lti_launch(
|
||||
LibraryLocatorV2(org='foo', slug='foobar'),
|
||||
issuer='http://a-fake-issuer',
|
||||
client_id='a-fake-client-id')
|
||||
self.assertFalse(authorized)
|
||||
|
||||
def test_authorize_lti_launch_when_null(self):
|
||||
"""
|
||||
Given a library WITHOUT an lti tool set
|
||||
When authorize_lti_launch is called
|
||||
Then return False
|
||||
"""
|
||||
library = self._create_library()
|
||||
authorized = ContentLibrary.authorize_lti_launch(
|
||||
library.library_key,
|
||||
issuer='http://a-fake-issuer',
|
||||
client_id='a-fake-client-id')
|
||||
self.assertFalse(authorized)
|
||||
|
||||
def test_authorize_lti_launch_when_not_null(self):
|
||||
"""
|
||||
Given a library WITH an lti tool set
|
||||
When authorize_lti_launch is called with different issuers
|
||||
Then return False
|
||||
"""
|
||||
issuer = 'http://a-fake-issuer'
|
||||
client_id = 'a-fake-client-id'
|
||||
library = self._create_library()
|
||||
library.authorized_lti_configs.add(LtiTool.objects.create(
|
||||
issuer=issuer,
|
||||
client_id=client_id,
|
||||
tool_key=LtiToolKey.objects.create()
|
||||
))
|
||||
authorized = ContentLibrary.authorize_lti_launch(
|
||||
library.library_key,
|
||||
issuer='http://another-fake-issuer',
|
||||
client_id='another-fake-client-id')
|
||||
self.assertFalse(authorized)
|
||||
|
||||
def test_authorize_lti_launch_when_not_null_and_inactive(self):
|
||||
"""
|
||||
Given a library WITH an lti tool set
|
||||
When authorize_lti_launch is called with the same issuers
|
||||
And lti tool is inactive
|
||||
Then return False
|
||||
"""
|
||||
issuer = 'http://a-fake-issuer'
|
||||
client_id = 'a-fake-client-id'
|
||||
library = self._create_library()
|
||||
library.authorized_lti_configs.add(LtiTool.objects.create(
|
||||
issuer=issuer,
|
||||
client_id=client_id,
|
||||
is_active=False,
|
||||
tool_key=LtiToolKey.objects.create()
|
||||
))
|
||||
authorized = ContentLibrary.authorize_lti_launch(
|
||||
library.library_key,
|
||||
issuer='http://another-fake-issuer',
|
||||
client_id='another-fake-client-id')
|
||||
self.assertFalse(authorized)
|
||||
|
||||
def test_authorize_lti_launch_when_not_null_and_active(self):
|
||||
"""
|
||||
Given a library WITH an lti tool set
|
||||
When authorize_lti_launch is called with the same issuers
|
||||
And lti tool is active
|
||||
Then return True
|
||||
"""
|
||||
issuer = 'http://a-fake-issuer'
|
||||
client_id = 'a-fake-client-id'
|
||||
library = self._create_library()
|
||||
library.authorized_lti_configs.add(LtiTool.objects.create(
|
||||
issuer=issuer,
|
||||
client_id=client_id,
|
||||
is_active=True, # redudant since it defaults to True
|
||||
tool_key=LtiToolKey.objects.create()
|
||||
))
|
||||
authorized = ContentLibrary.authorize_lti_launch(
|
||||
library.library_key,
|
||||
issuer=issuer,
|
||||
client_id=client_id)
|
||||
self.assertTrue(authorized)
|
||||
|
||||
|
||||
class LtiProfileTest(TestCase):
|
||||
"""
|
||||
LtiProfile model tests.
|
||||
"""
|
||||
|
||||
def test_get_from_claims_doesnotexists(self):
|
||||
with self.assertRaises(LtiProfile.DoesNotExist):
|
||||
LtiProfile.objects.get_from_claims(iss='iss', aud='aud', sub='sub')
|
||||
|
||||
def test_get_from_claims_exists(self):
|
||||
"""
|
||||
Given a LtiProfile with iss and sub,
|
||||
When get_from_claims()
|
||||
Then return the same object.
|
||||
"""
|
||||
|
||||
iss = 'http://foo.example.com/'
|
||||
sub = 'randomly-selected-sub-for-testing'
|
||||
aud = 'randomly-selected-aud-for-testing'
|
||||
profile = LtiProfile.objects.create(
|
||||
platform_id=iss,
|
||||
client_id=aud,
|
||||
subject_id=sub)
|
||||
|
||||
queried_profile = LtiProfile.objects.get_from_claims(
|
||||
iss=iss, aud=aud, sub=sub)
|
||||
|
||||
self.assertEqual(
|
||||
queried_profile,
|
||||
profile,
|
||||
'The queried profile is equal to the profile created.')
|
||||
|
||||
def test_subject_url(self):
|
||||
"""
|
||||
Given a profile
|
||||
Then has a valid subject_url.
|
||||
"""
|
||||
iss = 'http://foo.example.com'
|
||||
sub = 'randomly-selected-sub-for-testing'
|
||||
aud = 'randomly-selected-aud-for-testing'
|
||||
expected_url = 'http://foo.example.com/randomly-selected-aud-for-testing/randomly-selected-sub-for-testing'
|
||||
profile = LtiProfile.objects.create(
|
||||
platform_id=iss,
|
||||
client_id=aud,
|
||||
subject_id=sub)
|
||||
self.assertEqual(expected_url, profile.subject_url)
|
||||
|
||||
def test_create_with_user(self):
|
||||
"""
|
||||
Given a profile without a user
|
||||
When save is called
|
||||
Then a user is created.
|
||||
"""
|
||||
|
||||
iss = 'http://foo.example.com/'
|
||||
sub = 'randomly-selected-sub-for-testing'
|
||||
aud = 'randomly-selected-aud-for-testing'
|
||||
profile = LtiProfile.objects.create(
|
||||
platform_id=iss,
|
||||
client_id=aud,
|
||||
subject_id=sub)
|
||||
self.assertIsNotNone(profile.user)
|
||||
self.assertTrue(
|
||||
profile.user.username.startswith('urn:openedx:content_libraries:username:'))
|
||||
|
||||
def test_get_or_create_from_claims(self):
|
||||
"""
|
||||
Given a profile does not exist
|
||||
When get or create
|
||||
And get or create again
|
||||
Then the same profile is returned.
|
||||
"""
|
||||
iss = 'http://foo.example.com/'
|
||||
sub = 'randomly-selected-sub-for-testing'
|
||||
aud = 'randomly-selected-aud-for-testing'
|
||||
self.assertFalse(LtiProfile.objects.exists())
|
||||
profile = LtiProfile.objects.get_or_create_from_claims(iss=iss, aud=aud, sub=sub)
|
||||
self.assertIsNotNone(profile.user)
|
||||
self.assertEqual(iss, profile.platform_id)
|
||||
self.assertEqual(sub, profile.subject_id)
|
||||
|
||||
profile_two = LtiProfile.objects.get_or_create_from_claims(iss=iss, aud=aud, sub=sub)
|
||||
self.assertEqual(profile_two, profile)
|
||||
|
||||
def test_get_or_create_from_claims_twice(self):
|
||||
"""
|
||||
Given a profile
|
||||
When another profile is created
|
||||
Then success
|
||||
"""
|
||||
iss = 'http://foo.example.com/'
|
||||
aud = 'randomly-selected-aud-for-testing'
|
||||
sub_one = 'randomly-selected-sub-for-testing'
|
||||
sub_two = 'another-randomly-sub-for-testing'
|
||||
self.assertFalse(LtiProfile.objects.exists())
|
||||
LtiProfile.objects.get_or_create_from_claims(iss=iss, aud=aud, sub=sub_one)
|
||||
LtiProfile.objects.get_or_create_from_claims(iss=iss, aud=aud, sub=sub_two)
|
||||
|
||||
|
||||
class LtiResourceTest(TestCase):
|
||||
"""
|
||||
LtiGradedResource model tests.
|
||||
"""
|
||||
|
||||
iss = 'fake-iss-for-test'
|
||||
|
||||
sub = 'fake-sub-for-test'
|
||||
|
||||
aud = 'fake-aud-for-test'
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.request_factory = RequestFactory()
|
||||
|
||||
def test_get_from_user_id_when_no_user_then_not_found(self):
|
||||
user_id = 0
|
||||
with self.assertRaises(LtiGradedResource.DoesNotExist):
|
||||
LtiGradedResource.objects.get_from_user_id(user_id)
|
||||
|
||||
def test_get_from_user_id_when_no_profile_then_not_found(self):
|
||||
user = get_user_model().objects.create(username='foobar')
|
||||
with self.assertRaises(LtiGradedResource.DoesNotExist):
|
||||
LtiGradedResource.objects.get_from_user_id(user.pk)
|
||||
|
||||
def test_get_from_user_id_when_profile_then_found(self):
|
||||
profile = LtiProfile.objects.get_or_create_from_claims(
|
||||
iss=self.iss, aud=self.aud, sub=self.sub)
|
||||
LtiGradedResource.objects.create(profile=profile)
|
||||
resource = LtiGradedResource.objects.get_from_user_id(profile.user.pk)
|
||||
self.assertEqual(profile, resource.profile)
|
||||
|
||||
def test_upsert_from_ags_launch(self):
|
||||
"""
|
||||
Give no graded resource
|
||||
When get_or_create_from_launch twice
|
||||
Then created at first, retrieved at second.
|
||||
"""
|
||||
|
||||
resource_id = 'resource-foobar'
|
||||
usage_key = 'lb:foo:bar:fooz:barz'
|
||||
lineitem = 'http://canvas.docker/api/lti/courses/1/line_items/7'
|
||||
resource_endpoint = {
|
||||
"lineitem": lineitem,
|
||||
"scope": [
|
||||
"https://purl.imsglobal.org/spec/lti-ags/scope/lineitem",
|
||||
"https://purl.imsglobal.org/spec/lti-ags/scope/score"
|
||||
],
|
||||
}
|
||||
resource_link = {
|
||||
"id": resource_id,
|
||||
"title": "A custom title",
|
||||
}
|
||||
|
||||
profile = LtiProfile.objects.get_or_create_from_claims(
|
||||
iss=self.iss, aud=self.aud, sub=self.sub)
|
||||
block_mock = mock.Mock()
|
||||
block_mock.scope_ids.usage_id = LibraryUsageLocatorV2.from_string(usage_key)
|
||||
res = LtiGradedResource.objects.upsert_from_ags_launch(
|
||||
profile.user, block_mock, resource_endpoint, resource_link)
|
||||
|
||||
self.assertEqual(resource_id, res.resource_id)
|
||||
self.assertEqual(lineitem, res.ags_lineitem)
|
||||
self.assertEqual(usage_key, str(res.usage_key))
|
||||
self.assertEqual(profile, res.profile)
|
||||
|
||||
res2 = LtiGradedResource.objects.upsert_from_ags_launch(
|
||||
profile.user, block_mock, resource_endpoint, resource_link)
|
||||
|
||||
self.assertEqual(res, res2)
|
||||
@@ -0,0 +1,87 @@
|
||||
"""
|
||||
Tests for LTI views.
|
||||
"""
|
||||
|
||||
from django.conf import settings
|
||||
from django.test import TestCase, override_settings
|
||||
|
||||
from openedx.core.djangoapps.content_libraries.constants import PROBLEM
|
||||
|
||||
from .base import (
|
||||
ContentLibrariesRestApiTest,
|
||||
URL_LIB_LTI_JWKS,
|
||||
skip_unless_cms,
|
||||
)
|
||||
|
||||
|
||||
def override_features(**kwargs):
|
||||
"""
|
||||
Wrapps ``override_settings`` to override ``settings.FEATURES``.
|
||||
"""
|
||||
return override_settings(FEATURES={**settings.FEATURES, **kwargs})
|
||||
|
||||
|
||||
@skip_unless_cms
|
||||
class LtiToolJwksViewTest(TestCase):
|
||||
"""
|
||||
Test JWKS view.
|
||||
"""
|
||||
|
||||
def test_when_lti_disabled_return_404(self):
|
||||
"""
|
||||
Given LTI toggle is disabled
|
||||
When JWKS requested
|
||||
Then return 404
|
||||
"""
|
||||
response = self.client.get(URL_LIB_LTI_JWKS)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
@override_features(ENABLE_CONTENT_LIBRARIES=True,
|
||||
ENABLE_CONTENT_LIBRARIES_LTI_TOOL=True)
|
||||
def test_when_no_keys_then_return_empty(self):
|
||||
"""
|
||||
Given no LTI tool in the database.
|
||||
When JWKS requested.
|
||||
Then return empty
|
||||
"""
|
||||
response = self.client.get(URL_LIB_LTI_JWKS)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertJSONEqual(response.content, '{"keys": []}')
|
||||
|
||||
|
||||
@override_features(ENABLE_CONTENT_LIBRARIES=True,
|
||||
ENABLE_CONTENT_LIBRARIES_LTI_TOOL=True)
|
||||
class LibraryBlockLtiUrlViewTest(ContentLibrariesRestApiTest):
|
||||
"""
|
||||
Test generating LTI URL for a block in a library.
|
||||
"""
|
||||
|
||||
def test_lti_url_generation(self):
|
||||
"""
|
||||
Test the LTI URL generated from the block ID.
|
||||
"""
|
||||
|
||||
library = self._create_library(
|
||||
slug="libgg", title="A Test Library", description="Testing library", library_type=PROBLEM,
|
||||
)
|
||||
|
||||
block = self._add_block_to_library(library['id'], PROBLEM, PROBLEM)
|
||||
usage_key = str(block.usage_key)
|
||||
|
||||
url = f'/api/libraries/v2/blocks/{usage_key}/lti/'
|
||||
expected_lti_url = f"/api/libraries/v2/lti/1.3/launch/?id={usage_key}"
|
||||
|
||||
response = self._api("GET", url, None, expect_response=200)
|
||||
|
||||
self.assertDictEqual(response, {"lti_url": expected_lti_url})
|
||||
|
||||
def test_block_not_found(self):
|
||||
"""
|
||||
Test the LTI URL cannot be generated as the block not found.
|
||||
"""
|
||||
|
||||
self._create_library(
|
||||
slug="libgg", title="A Test Library", description="Testing library", library_type=PROBLEM,
|
||||
)
|
||||
|
||||
self._api("GET", '/api/libraries/v2/blocks/not-existing-key/lti/', None, expect_response=404)
|
||||
@@ -51,6 +51,8 @@ urlpatterns = [
|
||||
url(r'^blocks/(?P<usage_key_str>[^/]+)/', include([
|
||||
# Get metadata about a specific XBlock in this library, or delete the block:
|
||||
url(r'^$', views.LibraryBlockView.as_view()),
|
||||
# Get the LTI URL of a specific XBlock
|
||||
url(r'^lti/$', views.LibraryBlockLtiUrlView.as_view(), name='lti-url'),
|
||||
# Get the OLX source code of the specified block:
|
||||
url(r'^olx/$', views.LibraryBlockOlxView.as_view()),
|
||||
# CRUD for static asset files associated with a block in the library:
|
||||
@@ -59,5 +61,10 @@ urlpatterns = [
|
||||
# Future: publish/discard changes for just this one block
|
||||
# Future: set a block's tags (tags are stored in a Tag bundle and linked in)
|
||||
])),
|
||||
url(r'^lti/1.3/', include([
|
||||
url(r'^login/$', views.LtiToolLoginView.as_view(), name='lti-login'),
|
||||
url(r'^launch/$', views.LtiToolLaunchView.as_view(), name='lti-launch'),
|
||||
url(r'^pub/jwks/$', views.LtiToolJwksView.as_view(), name='lti-pub-jwks'),
|
||||
])),
|
||||
])),
|
||||
]
|
||||
|
||||
@@ -1,13 +1,41 @@
|
||||
"""
|
||||
REST API for Blockstore-based content libraries
|
||||
=======================
|
||||
Content Libraries Views
|
||||
=======================
|
||||
|
||||
This module contains the REST APIs for blockstore-based content libraries, and
|
||||
LTI 1.3 views.
|
||||
"""
|
||||
|
||||
|
||||
from functools import wraps
|
||||
import itertools
|
||||
import json
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import authenticate
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth import login
|
||||
from django.contrib.auth.models import Group
|
||||
from django.http import Http404
|
||||
from django.http import HttpResponseBadRequest
|
||||
from django.http import JsonResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.urls import reverse
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.views.decorators.clickjacking import xframe_options_exempt
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.generic.base import TemplateResponseMixin
|
||||
from django.views.generic.base import View
|
||||
from pylti1p3.contrib.django import DjangoCacheDataStorage
|
||||
from pylti1p3.contrib.django import DjangoDbToolConf
|
||||
from pylti1p3.contrib.django import DjangoMessageLaunch
|
||||
from pylti1p3.contrib.django import DjangoOIDCLogin
|
||||
from pylti1p3.exception import LtiException
|
||||
from pylti1p3.exception import OIDCException
|
||||
|
||||
import edx_api_doc_tools as apidocs
|
||||
from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2
|
||||
from organizations.api import ensure_organization
|
||||
@@ -40,7 +68,13 @@ from openedx.core.djangoapps.content_libraries.serializers import (
|
||||
LibraryXBlockStaticFilesSerializer,
|
||||
ContentLibraryAddPermissionByEmailSerializer,
|
||||
)
|
||||
import openedx.core.djangoapps.site_configuration.helpers as configuration_helpers
|
||||
from openedx.core.lib.api.view_utils import view_auth_classes
|
||||
from openedx.core.djangoapps.xblock import api as xblock_api
|
||||
|
||||
from .models import ContentLibrary
|
||||
from .models import LtiGradedResource
|
||||
from .models import LtiProfile
|
||||
|
||||
|
||||
User = get_user_model()
|
||||
@@ -586,6 +620,27 @@ class LibraryBlockView(APIView):
|
||||
return Response({})
|
||||
|
||||
|
||||
@view_auth_classes()
|
||||
class LibraryBlockLtiUrlView(APIView):
|
||||
"""
|
||||
Views to generate LTI URL for existing XBlocks in a content library.
|
||||
|
||||
Returns 404 in case the block not found by the given key.
|
||||
"""
|
||||
@convert_exceptions
|
||||
def get(self, request, usage_key_str):
|
||||
"""
|
||||
Get the LTI launch URL for the XBlock.
|
||||
"""
|
||||
key = LibraryUsageLocatorV2.from_string(usage_key_str)
|
||||
api.require_permission_for_library_key(key.lib_key, request.user, permissions.CAN_VIEW_THIS_CONTENT_LIBRARY)
|
||||
|
||||
# Get the block to validate its existence
|
||||
api.get_library_block(key)
|
||||
lti_login_url = f"{reverse('content_libraries:lti-launch')}?id={key}"
|
||||
return Response({"lti_url": lti_login_url})
|
||||
|
||||
|
||||
@view_auth_classes()
|
||||
class LibraryBlockOlxView(APIView):
|
||||
"""
|
||||
@@ -754,3 +809,267 @@ class LibraryImportTaskViewSet(ViewSet):
|
||||
|
||||
import_task = api.ContentLibraryBlockImportTask.objects.get(pk=pk)
|
||||
return Response(ContentLibraryBlockImportTaskSerializer(import_task).data)
|
||||
|
||||
|
||||
# LTI 1.3 Views
|
||||
# =============
|
||||
|
||||
|
||||
def requires_lti_enabled(view_func):
|
||||
"""
|
||||
Modify the view function to raise 404 if content librarie LTI tool was not
|
||||
enabled.
|
||||
"""
|
||||
def wrapped_view(*args, **kwargs):
|
||||
lti_enabled = (settings.FEATURES.get('ENABLE_CONTENT_LIBRARIES')
|
||||
and settings.FEATURES.get('ENABLE_CONTENT_LIBRARIES_LTI_TOOL'))
|
||||
if not lti_enabled:
|
||||
raise Http404()
|
||||
return view_func(*args, **kwargs)
|
||||
return wrapped_view
|
||||
|
||||
|
||||
@method_decorator(requires_lti_enabled, name='dispatch')
|
||||
class LtiToolView(View):
|
||||
"""
|
||||
Base LTI View initializing common attributes.
|
||||
"""
|
||||
|
||||
# pylint: disable=attribute-defined-outside-init
|
||||
def setup(self, request, *args, **kwds):
|
||||
"""
|
||||
Initialize attributes shared by all LTI views.
|
||||
"""
|
||||
super().setup(request, *args, **kwds)
|
||||
self.lti_tool_config = DjangoDbToolConf()
|
||||
self.lti_tool_storage = DjangoCacheDataStorage(cache_name='default')
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name='dispatch')
|
||||
class LtiToolLoginView(LtiToolView):
|
||||
"""
|
||||
Third-party Initiated Login view.
|
||||
|
||||
The LTI platform will start the OpenID Connect flow by redirecting the User
|
||||
Agent (UA) to this view. The redirect may be a form POST or a GET. On
|
||||
success the view should redirect the UA to the LTI platform's authentication
|
||||
URL.
|
||||
"""
|
||||
|
||||
LAUNCH_URI_PARAMETER = 'target_link_uri'
|
||||
|
||||
def get(self, request):
|
||||
return self.post(request)
|
||||
|
||||
def post(self, request):
|
||||
"""Initialize 3rd-party login requests to redirect."""
|
||||
oidc_login = DjangoOIDCLogin(
|
||||
self.request,
|
||||
self.lti_tool_config,
|
||||
launch_data_storage=self.lti_tool_storage)
|
||||
launch_url = (self.request.POST.get(self.LAUNCH_URI_PARAMETER)
|
||||
or self.request.GET.get(self.LAUNCH_URI_PARAMETER))
|
||||
try:
|
||||
return oidc_login.redirect(launch_url)
|
||||
except OIDCException as exc:
|
||||
# Relying on downstream error messages, attempt to sanitize it up
|
||||
# for customer facing errors.
|
||||
log.error('LTI OIDC login failed: %s', exc)
|
||||
return HttpResponseBadRequest('Invalid LTI login request.')
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name='dispatch')
|
||||
@method_decorator(xframe_options_exempt, name='dispatch')
|
||||
class LtiToolLaunchView(TemplateResponseMixin, LtiToolView):
|
||||
"""
|
||||
LTI platform tool launch view.
|
||||
|
||||
The launch view supports resource link launches and AGS, when enabled by the
|
||||
LTI platform. Other features and resouces are ignored.
|
||||
"""
|
||||
|
||||
template_name = 'content_libraries/xblock_iframe.html'
|
||||
|
||||
@property
|
||||
def launch_data(self):
|
||||
return self.launch_message.get_launch_data()
|
||||
|
||||
def _authenticate_and_login(self, usage_key):
|
||||
"""
|
||||
Authenticate and authorize the user for this LTI message launch.
|
||||
|
||||
We automatically create LTI profile for every valid launch, and
|
||||
authenticate the LTI user associated with it.
|
||||
"""
|
||||
|
||||
# Check library authorization.
|
||||
|
||||
if not ContentLibrary.authorize_lti_launch(
|
||||
usage_key.lib_key,
|
||||
issuer=self.launch_data['iss'],
|
||||
client_id=self.launch_data['aud']
|
||||
):
|
||||
return None
|
||||
|
||||
# Check LTI profile.
|
||||
|
||||
LtiProfile.objects.get_or_create_from_claims(
|
||||
iss=self.launch_data['iss'],
|
||||
aud=self.launch_data['aud'],
|
||||
sub=self.launch_data['sub'])
|
||||
edx_user = authenticate(
|
||||
self.request,
|
||||
iss=self.launch_data['iss'],
|
||||
aud=self.launch_data['aud'],
|
||||
sub=self.launch_data['sub'])
|
||||
|
||||
if edx_user is not None:
|
||||
|
||||
login(self.request, edx_user)
|
||||
perms = api.get_library_user_permissions(
|
||||
usage_key.lib_key,
|
||||
self.request.user)
|
||||
if not perms:
|
||||
api.set_library_user_permissions(
|
||||
usage_key.lib_key,
|
||||
self.request.user,
|
||||
api.AccessLevel.ADMIN_LEVEL)
|
||||
|
||||
return edx_user
|
||||
|
||||
def _bad_request_response(self):
|
||||
"""
|
||||
A default response for bad requests.
|
||||
"""
|
||||
return HttpResponseBadRequest('Invalid LTI tool launch.')
|
||||
|
||||
def get_context_data(self):
|
||||
"""
|
||||
Setup the template context data.
|
||||
"""
|
||||
|
||||
handler_urls = {
|
||||
str(key): xblock_api.get_handler_url(key, 'handler_name', self.request.user)
|
||||
for key
|
||||
in itertools.chain([self.block.scope_ids.usage_id],
|
||||
getattr(self.block, 'children', []))
|
||||
}
|
||||
|
||||
# We are defaulting to student view due to current use case (resource
|
||||
# link launches). Launches within other views are not currently
|
||||
# supported.
|
||||
fragment = self.block.render('student_view')
|
||||
lms_root_url = configuration_helpers.get_value('LMS_ROOT_URL', settings.LMS_ROOT_URL)
|
||||
return {
|
||||
'fragment': fragment,
|
||||
'handler_urls_json': json.dumps(handler_urls),
|
||||
'lms_root_url': lms_root_url,
|
||||
}
|
||||
|
||||
def get_launch_message(self):
|
||||
"""
|
||||
Return the LTI 1.3 launch message object for the current request.
|
||||
"""
|
||||
launch_message = DjangoMessageLaunch(
|
||||
self.request,
|
||||
self.lti_tool_config,
|
||||
launch_data_storage=self.lti_tool_storage)
|
||||
# This will force the LTI launch validation steps.
|
||||
launch_message.get_launch_data()
|
||||
return launch_message
|
||||
|
||||
# pylint: disable=attribute-defined-outside-init
|
||||
def post(self, request):
|
||||
"""
|
||||
Process LTI platform launch requests.
|
||||
"""
|
||||
|
||||
# Parse LTI launch message.
|
||||
|
||||
try:
|
||||
self.launch_message = self.get_launch_message()
|
||||
except LtiException as exc:
|
||||
log.exception('LTI 1.3: Tool launch failed: %s', exc)
|
||||
return self._bad_request_response()
|
||||
|
||||
log.info("LTI 1.3: Launch message body: %s",
|
||||
json.dumps(self.launch_data))
|
||||
|
||||
# Parse content key.
|
||||
|
||||
usage_key_str = request.GET.get('id')
|
||||
if not usage_key_str:
|
||||
return self._bad_request_response()
|
||||
usage_key = LibraryUsageLocatorV2.from_string(usage_key_str)
|
||||
log.info('LTI 1.3: Launch block: id=%s', usage_key)
|
||||
|
||||
# Authenticate the launch and setup LTI profiles.
|
||||
|
||||
if not self._authenticate_and_login(usage_key):
|
||||
return self._bad_request_response()
|
||||
|
||||
# Get the block.
|
||||
|
||||
self.block = xblock_api.load_block(
|
||||
usage_key,
|
||||
user=self.request.user)
|
||||
|
||||
# Handle Assignment and Grade Service request.
|
||||
|
||||
self.handle_ags()
|
||||
|
||||
# Render context and response.
|
||||
context = self.get_context_data()
|
||||
return self.render_to_response(context)
|
||||
|
||||
def handle_ags(self):
|
||||
"""
|
||||
Handle AGS-enabled launches for block in the request.
|
||||
"""
|
||||
|
||||
# Validate AGS.
|
||||
|
||||
if not self.launch_message.has_ags():
|
||||
return
|
||||
|
||||
endpoint_claim = 'https://purl.imsglobal.org/spec/lti-ags/claim/endpoint'
|
||||
endpoint = self.launch_data[endpoint_claim]
|
||||
required_scopes = [
|
||||
'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem',
|
||||
'https://purl.imsglobal.org/spec/lti-ags/scope/score'
|
||||
]
|
||||
|
||||
for scope in required_scopes:
|
||||
if scope not in endpoint['scope']:
|
||||
log.info('LTI 1.3: AGS: LTI platform does not support a required '
|
||||
'scope: %s', scope)
|
||||
return
|
||||
lineitem = endpoint.get('lineitem')
|
||||
if not lineitem:
|
||||
log.info("LTI 1.3: AGS: LTI platform didn't pass lineitem, ignoring "
|
||||
"request: %s", endpoint)
|
||||
return
|
||||
|
||||
# Create graded resource in the database for the current launch.
|
||||
|
||||
resource_claim = 'https://purl.imsglobal.org/spec/lti/claim/resource_link'
|
||||
resource_link = self.launch_data.get(resource_claim)
|
||||
|
||||
resource = LtiGradedResource.objects.upsert_from_ags_launch(
|
||||
self.request.user, self.block, endpoint, resource_link
|
||||
)
|
||||
|
||||
log.info("LTI 1.3: AGS: Upserted LTI graded resource from launch: %s",
|
||||
resource)
|
||||
|
||||
|
||||
class LtiToolJwksView(LtiToolView):
|
||||
"""
|
||||
JSON Web Key Sets view.
|
||||
"""
|
||||
|
||||
def get(self, request):
|
||||
"""
|
||||
Return the JWKS.
|
||||
"""
|
||||
return JsonResponse(self.lti_tool_config.get_jwks(), safe=False)
|
||||
|
||||
@@ -133,6 +133,7 @@ pyjwkest
|
||||
# TODO Replace PyJWT usage with pyjwkest
|
||||
# PyJWT 1.6.3 contains PyJWTError, which is required by Apple auth in social-auth-core
|
||||
PyJWT>=1.6.3
|
||||
pylti1p3 # Required by content_libraries core library to suport LTI 1.3 launches
|
||||
pymongo # MongoDB driver
|
||||
pynliner # Inlines CSS styles into HTML for email notifications
|
||||
python-dateutil
|
||||
|
||||
@@ -142,6 +142,7 @@ cryptography==3.4.8
|
||||
# -r requirements/edx/base.in
|
||||
# django-fernet-fields
|
||||
# edx-enterprise
|
||||
# jwcrypto
|
||||
# pyjwt
|
||||
# social-auth-core
|
||||
cssutils==2.3.0
|
||||
@@ -159,6 +160,8 @@ defusedxml==0.7.1
|
||||
# python3-saml
|
||||
# safe-lxml
|
||||
# social-auth-core
|
||||
deprecated==1.2.12
|
||||
# via jwcrypto
|
||||
django==2.2.24
|
||||
# via
|
||||
# -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt
|
||||
@@ -595,6 +598,8 @@ jsonfield2==3.0.3
|
||||
# edx-submissions
|
||||
# lti-consumer-xblock
|
||||
# ora2
|
||||
jwcrypto==0.9.1
|
||||
# via pylti1p3
|
||||
kombu==4.6.11
|
||||
# via celery
|
||||
laboratory==1.0.2
|
||||
@@ -768,9 +773,12 @@ pyjwt[crypto]==1.7.1
|
||||
# edx-auth-backends
|
||||
# edx-proctoring
|
||||
# edx-rest-api-client
|
||||
# pylti1p3
|
||||
# social-auth-core
|
||||
pylatexenc==2.10
|
||||
# via olxcleaner
|
||||
pylti1p3==1.9.1
|
||||
# via -r requirements/edx/base.in
|
||||
pymongo==3.10.1
|
||||
# via
|
||||
# -c requirements/edx/../constraints.txt
|
||||
@@ -878,6 +886,7 @@ requests==2.26.0
|
||||
# geoip2
|
||||
# mailsnake
|
||||
# pyjwkest
|
||||
# pylti1p3
|
||||
# python-swiftclient
|
||||
# requests-oauthlib
|
||||
# sailthru-client
|
||||
@@ -944,6 +953,7 @@ six==1.16.0
|
||||
# html5lib
|
||||
# interchange
|
||||
# isodate
|
||||
# jwcrypto
|
||||
# libsass
|
||||
# pansi
|
||||
# paver
|
||||
|
||||
@@ -201,6 +201,7 @@ cryptography==3.4.8
|
||||
# -r requirements/edx/testing.txt
|
||||
# django-fernet-fields
|
||||
# edx-enterprise
|
||||
# jwcrypto
|
||||
# pyjwt
|
||||
# social-auth-core
|
||||
cssselect==1.1.0
|
||||
@@ -225,6 +226,10 @@ defusedxml==0.7.1
|
||||
# python3-saml
|
||||
# safe-lxml
|
||||
# social-auth-core
|
||||
deprecated==1.2.12
|
||||
# via
|
||||
# -r requirements/edx/testing.txt
|
||||
# jwcrypto
|
||||
diff-cover==4.0.0
|
||||
# via
|
||||
# -c requirements/edx/../constraints.txt
|
||||
@@ -787,6 +792,10 @@ jsonfield2==3.0.3
|
||||
# ora2
|
||||
jsonschema==3.2.0
|
||||
# via sphinxcontrib-openapi
|
||||
jwcrypto==0.9.1
|
||||
# via
|
||||
# -r requirements/edx/testing.txt
|
||||
# pylti1p3
|
||||
kombu==4.6.11
|
||||
# via
|
||||
# -r requirements/edx/testing.txt
|
||||
@@ -1037,6 +1046,7 @@ pyjwt[crypto]==1.7.1
|
||||
# edx-auth-backends
|
||||
# edx-proctoring
|
||||
# edx-rest-api-client
|
||||
# pylti1p3
|
||||
# social-auth-core
|
||||
pylatexenc==2.10
|
||||
# via
|
||||
@@ -1066,6 +1076,8 @@ pylint-plugin-utils==0.6
|
||||
# pylint-django
|
||||
pylint-pytest==0.3.0
|
||||
# via -r requirements/edx/testing.txt
|
||||
pylti1p3==1.9.1
|
||||
# via -r requirements/edx/testing.txt
|
||||
pymongo==3.10.1
|
||||
# via
|
||||
# -c requirements/edx/../constraints.txt
|
||||
@@ -1221,6 +1233,7 @@ requests==2.26.0
|
||||
# mailsnake
|
||||
# pact-python
|
||||
# pyjwkest
|
||||
# pylti1p3
|
||||
# python-swiftclient
|
||||
# requests-oauthlib
|
||||
# sailthru-client
|
||||
@@ -1311,6 +1324,7 @@ six==1.16.0
|
||||
# interchange
|
||||
# isodate
|
||||
# jsonschema
|
||||
# jwcrypto
|
||||
# libsass
|
||||
# pact-python
|
||||
# pansi
|
||||
|
||||
@@ -189,6 +189,7 @@ cryptography==3.4.8
|
||||
# -r requirements/edx/base.txt
|
||||
# django-fernet-fields
|
||||
# edx-enterprise
|
||||
# jwcrypto
|
||||
# pyjwt
|
||||
# social-auth-core
|
||||
cssselect==1.1.0
|
||||
@@ -214,6 +215,10 @@ defusedxml==0.7.1
|
||||
# python3-saml
|
||||
# safe-lxml
|
||||
# social-auth-core
|
||||
deprecated==1.2.12
|
||||
# via
|
||||
# -r requirements/edx/base.txt
|
||||
# jwcrypto
|
||||
diff-cover==4.0.0
|
||||
# via
|
||||
# -c requirements/edx/../constraints.txt
|
||||
@@ -746,6 +751,10 @@ jsonfield2==3.0.3
|
||||
# edx-submissions
|
||||
# lti-consumer-xblock
|
||||
# ora2
|
||||
jwcrypto==0.9.1
|
||||
# via
|
||||
# -r requirements/edx/base.txt
|
||||
# pylti1p3
|
||||
kombu==4.6.11
|
||||
# via
|
||||
# -r requirements/edx/base.txt
|
||||
@@ -974,6 +983,7 @@ pyjwt[crypto]==1.7.1
|
||||
# edx-auth-backends
|
||||
# edx-proctoring
|
||||
# edx-rest-api-client
|
||||
# pylti1p3
|
||||
# social-auth-core
|
||||
pylatexenc==2.10
|
||||
# via
|
||||
@@ -997,6 +1007,8 @@ pylint-plugin-utils==0.6
|
||||
# pylint-django
|
||||
pylint-pytest==0.3.0
|
||||
# via -r requirements/edx/testing.in
|
||||
pylti1p3==1.9.1
|
||||
# via -r requirements/edx/base.txt
|
||||
pymongo==3.10.1
|
||||
# via
|
||||
# -c requirements/edx/../constraints.txt
|
||||
@@ -1145,6 +1157,7 @@ requests==2.26.0
|
||||
# mailsnake
|
||||
# pact-python
|
||||
# pyjwkest
|
||||
# pylti1p3
|
||||
# python-swiftclient
|
||||
# requests-oauthlib
|
||||
# sailthru-client
|
||||
@@ -1232,6 +1245,7 @@ six==1.16.0
|
||||
# httpretty
|
||||
# interchange
|
||||
# isodate
|
||||
# jwcrypto
|
||||
# libsass
|
||||
# pact-python
|
||||
# pansi
|
||||
|
||||
Reference in New Issue
Block a user