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:
J. Victor Martins
2021-06-24 17:34:48 -03:00
committed by Braden MacDonald
parent bc5e6118a9
commit 14e2f29516
24 changed files with 1684 additions and 5 deletions

View File

@@ -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',
]

View 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>

View File

@@ -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 #########################################

View File

@@ -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, )

View File

@@ -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,

View File

@@ -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

View 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

View File

@@ -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')},
},
),
]

View File

@@ -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'),
),
]

View File

@@ -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 = [
]

View File

@@ -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'),
),
]

View File

@@ -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)

View File

@@ -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)

View 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)

View File

@@ -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}/'

View 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)

View 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)

View File

@@ -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)

View File

@@ -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'),
])),
])),
]

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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