Skip to content

Commit f36725a

Browse files
kuipumuSquirrel18alexjmpb
authored
feat: LTI 1.3 reusable configuration (#390)
Co-authored-by: Squirrel18 <daniel.quiroga@edunext.co> Co-authored-by: alexjmpb <alexander.mendoza@edunext.co>
1 parent 3d4221c commit f36725a

15 files changed

Lines changed: 507 additions & 111 deletions

File tree

CHANGELOG.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ Please See the `releases tab <https://github.com/openedx/xblock-lti-consumer/rel
1616
Unreleased
1717
~~~~~~~~~~
1818

19+
9.7.0 - 2023-10-23
20+
------------------
21+
* Added LTI 1.3 reusable configuration compatibility.
22+
1923
9.6.2 - 2023-08-22
2024
------------------
2125
* Fix extra claims and custom parameters for LTI 1.3.

README.rst

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,31 @@ This XBlock supports `LTI 2.0 Result Service 2.0 <https://www.imsglobal.org/lti/
275275
Please see the `LTI 2.0 Result Service 2.0 instructions <https://github.com/openedx/xblock-lti-consumer/tree/master/docs/result_service.rst>`_
276276
for testing the LTI 2.0 Result Service 2.0 implementation.
277277

278+
LTI Reusable configuration
279+
**************************
280+
281+
The LTI Consumer XBlock supports configuration reusability via plugins.
282+
It is compatible with both LTI 1.1 and LTI 1.3.
283+
All values (including the access token and keyset URL for LTI 1.3)
284+
are shared across the XBlocks with the same external configuration ID.
285+
This eliminates the need to have a tool deployment for each XBlock.
286+
287+
How to Setup
288+
============
289+
290+
1. Install and setup the `openedx-ltistore`_ plugin on the LMS and Studio.
291+
2. Go to LMS admin -> WAFFLE_UTILS -> Waffle flag course override
292+
(http://localhost:18000/admin/waffle_utils/waffleflagcourseoverridemodel/).
293+
3. Create a waffle flag course override with these values:
294+
- Waffle flag: lti_consumer.enable_external_config_filter
295+
- Course id: <your course id>
296+
- Override choice: Force On
297+
- Enabled: True
298+
4. Create a new external LTI configuration and use it in the XBlock.
299+
This is explained in the README of the `openedx-ltistore`_ repository.
300+
301+
.. _openedx-ltistore: https://github.com/open-craft/openedx-ltistore
302+
278303
Getting Help
279304
************
280305

lti_consumer/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@
44
from .apps import LTIConsumerApp
55
from .lti_xblock import LtiConsumerXBlock
66

7-
__version__ = '9.6.2'
7+
__version__ = '9.7.0'

lti_consumer/api.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,10 +141,18 @@ def get_lti_1p3_launch_info(
141141
deep_linking_content_items = [item.attributes for item in dl_content_items]
142142

143143
config_id = lti_config.config_id
144+
client_id = lti_config.lti_1p3_client_id
145+
146+
# Display LTI launch information from external configuration.
147+
# if an external configuration is being used.
148+
if lti_config.config_store == lti_config.CONFIG_EXTERNAL:
149+
external_config = get_external_config_from_filter({}, lti_config.external_id)
150+
config_id = lti_config.external_id.replace(':', '/')
151+
client_id = external_config.get('lti_1p3_client_id')
144152

145153
# Return LTI launch information for end user configuration
146154
return {
147-
'client_id': lti_config.lti_1p3_client_id,
155+
'client_id': client_id,
148156
'keyset_url': get_lms_lti_keyset_link(config_id),
149157
'deployment_id': '1',
150158
'oidc_callback': get_lms_lti_launch_link(),

lti_consumer/exceptions.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,10 @@ class LtiError(Exception):
77
"""
88
General error class for LTI Consumer usage.
99
"""
10+
11+
12+
class ExternalConfigurationNotFound(Exception):
13+
"""
14+
This exception is used when a reusable external configuration
15+
is not found for a given external ID.
16+
"""

lti_consumer/lti_xblock.py

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@
8282
external_config_filter_enabled,
8383
external_user_id_1p1_launches_enabled,
8484
database_config_enabled,
85+
EXTERNAL_ID_REGEX,
8586
)
8687

8788
log = logging.getLogger(__name__)
@@ -691,6 +692,16 @@ def validate_field_data(self, validation, data):
691692
_('Custom Parameters should be strings in "x=y" format.'),
692693
)))
693694

695+
# Validate the external config ID.
696+
if (
697+
data.config_type == 'external' and not
698+
(data.external_config and EXTERNAL_ID_REGEX.match(str(data.external_config)))
699+
):
700+
_ = self.runtime.service(self, 'i18n').ugettext
701+
validation.add(ValidationMessage(ValidationMessage.ERROR, str(
702+
_('Reusable configuration ID must be set when using external config (Example: "x:y").'),
703+
)))
704+
694705
# keyset URL and public key are mutually exclusive
695706
if data.lti_1p3_tool_key_mode == 'keyset_url':
696707
data.lti_1p3_tool_public_key = ''
@@ -1664,10 +1675,7 @@ def _get_lti_block_launch_handler(self):
16641675
"""
16651676
Return the LTI block launch handler.
16661677
"""
1667-
# The "external" config_type is not supported for LTI 1.3, only LTI 1.1. Therefore, ensure that we define
1668-
# the lti_block_launch_handler using LTI 1.1 logic for "external" config_types.
1669-
# TODO: This needs to change when the LTI 1.3 support is added to the external config_type in the future.
1670-
if self.lti_version == 'lti_1p1' or self.config_type == "external":
1678+
if self.lti_version == 'lti_1p1':
16711679
lti_block_launch_handler = self.runtime.handler_url(self, 'lti_launch_handler').rstrip('/?')
16721680
else:
16731681
launch_data = self.get_lti_1p3_launch_data()
@@ -1687,10 +1695,8 @@ def _get_lti_1p3_launch_url(self, consumer):
16871695
"""
16881696
lti_1p3_launch_url = self.lti_1p3_launch_url.strip()
16891697

1690-
# The "external" config_type is not supported for LTI 1.3, only LTI 1.1. Therefore, ensure that we define
1691-
# the lti_1p3_launch_url using the LTI 1.3 consumer only for config_types that support LTI 1.3.
1692-
# TODO: This needs to change when the LTI 1.3 support is added to the external config_type in the future.
1693-
if consumer and self.lti_version == "lti_1p3" and self.config_type == "database":
1698+
# Get LTI launch URL from consumer if using database or external configuration type.
1699+
if consumer and self.lti_version == 'lti_1p3' and self.config_type in ('database', 'external'):
16941700
lti_1p3_launch_url = consumer.launch_url
16951701

16961702
return lti_1p3_launch_url

lti_consumer/models.py

Lines changed: 50 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from opaque_keys.edx.django.models import CourseKeyField, UsageKeyField
1616
from opaque_keys.edx.keys import CourseKey
1717
from config_models.models import ConfigurationModel
18+
from django.utils.functional import cached_property
1819
from django.utils.translation import gettext_lazy as _
1920
from lti_consumer.filters import get_external_config_from_filter
2021

@@ -31,6 +32,7 @@
3132
get_lti_nrps_context_membership_url,
3233
choose_lti_1p3_redirect_uris,
3334
model_to_dict,
35+
EXTERNAL_ID_REGEX,
3436
)
3537

3638
log = logging.getLogger(__name__)
@@ -251,6 +253,12 @@ def clean(self):
251253
raise ValidationError({
252254
"config_store": _("LTI Configuration stores on XBlock needs a block location set."),
253255
})
256+
if self.config_store == self.CONFIG_EXTERNAL and not EXTERNAL_ID_REGEX.match(str(self.external_id)):
257+
raise ValidationError({
258+
"config_store": _(
259+
'LTI Configuration using reusable configuration needs a external ID in "x:y" format.',
260+
),
261+
})
254262
if self.version == self.LTI_1P3 and self.config_store == self.CONFIG_ON_DB:
255263
if self.lti_1p3_tool_public_key == "" and self.lti_1p3_tool_keyset_url == "":
256264
raise ValidationError({
@@ -280,7 +288,7 @@ def sync_configurations(self):
280288
otherwise, it will try to query any children configuration and update their fields using
281289
the current configuration values.
282290
"""
283-
EXCLUDED_FIELDS = ['id', 'config_id', 'location']
291+
EXCLUDED_FIELDS = ['id', 'config_id', 'location', 'external_config']
284292

285293
if isinstance(self.location, CCXBlockUsageLocator):
286294
# Query main configuration using main location.
@@ -364,6 +372,13 @@ def lti_1p3_public_jwk(self):
364372
self._generate_lti_1p3_keys_if_missing()
365373
return json.loads(self.lti_1p3_internal_public_jwk)
366374

375+
@cached_property
376+
def external_config(self):
377+
"""
378+
Return the external resuable configuration.
379+
"""
380+
return get_external_config_from_filter({}, self.external_id)
381+
367382
def _get_lti_1p1_consumer(self):
368383
"""
369384
Return a class of LTI 1.1 consumer.
@@ -374,10 +389,9 @@ def _get_lti_1p1_consumer(self):
374389
key, secret = block.lti_provider_key_secret
375390
launch_url = block.launch_url
376391
elif self.config_store == self.CONFIG_EXTERNAL:
377-
config = get_external_config_from_filter({}, self.external_id)
378-
key = config.get("lti_1p1_client_key")
379-
secret = config.get("lti_1p1_client_secret")
380-
launch_url = config.get("lti_1p1_launch_url")
392+
key = self.external_config.get("lti_1p1_client_key")
393+
secret = self.external_config.get("lti_1p1_client_secret")
394+
launch_url = self.external_config.get("lti_1p1_launch_url")
381395
else:
382396
key = self.lti_1p1_client_key
383397
secret = self.lti_1p1_client_secret
@@ -389,11 +403,10 @@ def get_lti_advantage_ags_mode(self):
389403
"""
390404
Return LTI 1.3 Advantage Assignment and Grade Services mode.
391405
"""
392-
if self.config_store == self.CONFIG_EXTERNAL:
393-
# TODO: Add support for CONFIG_EXTERNAL for LTI 1.3.
394-
raise NotImplementedError
395406
if self.config_store == self.CONFIG_ON_DB:
396407
return self.lti_advantage_ags_mode
408+
elif self.config_store == self.CONFIG_EXTERNAL:
409+
return self.external_config.get('lti_advantage_ags_mode')
397410
else:
398411
block = compat.load_enough_xblock(self.location)
399412
return block.lti_advantage_ags_mode
@@ -402,11 +415,10 @@ def get_lti_advantage_deep_linking_enabled(self):
402415
"""
403416
Return whether LTI 1.3 Advantage Deep Linking is enabled.
404417
"""
405-
if self.config_store == self.CONFIG_EXTERNAL:
406-
# TODO: Add support for CONFIG_EXTERNAL for LTI 1.3.
407-
raise NotImplementedError("CONFIG_EXTERNAL is not supported for LTI 1.3 Advantage services: %s")
408418
if self.config_store == self.CONFIG_ON_DB:
409419
return self.lti_advantage_deep_linking_enabled
420+
elif self.config_store == self.CONFIG_EXTERNAL:
421+
return self.external_config.get('lti_advantage_deep_linking_enabled')
410422
else:
411423
block = compat.load_enough_xblock(self.location)
412424
return block.lti_advantage_deep_linking_enabled
@@ -415,11 +427,10 @@ def get_lti_advantage_deep_linking_launch_url(self):
415427
"""
416428
Return the LTI 1.3 Advantage Deep Linking launch URL.
417429
"""
418-
if self.config_store == self.CONFIG_EXTERNAL:
419-
# TODO: Add support for CONFIG_EXTERNAL for LTI 1.3.
420-
raise NotImplementedError("CONFIG_EXTERNAL is not supported for LTI 1.3 Advantage services: %s")
421430
if self.config_store == self.CONFIG_ON_DB:
422431
return self.lti_advantage_deep_linking_launch_url
432+
elif self.config_store == self.CONFIG_EXTERNAL:
433+
return self.external_config.get('lti_advantage_deep_linking_launch_url')
423434
else:
424435
block = compat.load_enough_xblock(self.location)
425436
return block.lti_advantage_deep_linking_launch_url
@@ -428,11 +439,10 @@ def get_lti_advantage_nrps_enabled(self):
428439
"""
429440
Return whether LTI 1.3 Advantage Names and Role Provisioning Services is enabled.
430441
"""
431-
if self.config_store == self.CONFIG_EXTERNAL:
432-
# TODO: Add support for CONFIG_EXTERNAL for LTI 1.3.
433-
raise NotImplementedError("CONFIG_EXTERNAL is not supported for LTI 1.3 Advantage services: %s")
434442
if self.config_store == self.CONFIG_ON_DB:
435443
return self.lti_advantage_enable_nrps
444+
elif self.config_store == self.CONFIG_EXTERNAL:
445+
return self.external_config.get('lti_advantage_enable_nrps')
436446
else:
437447
block = compat.load_enough_xblock(self.location)
438448
return block.lti_1p3_enable_nrps
@@ -453,6 +463,7 @@ def _setup_lti_1p3_ags(self, consumer):
453463
return
454464

455465
lineitem = self.ltiagslineitem_set.first()
466+
456467
# If using the declarative approach, we should create a LineItem if it
457468
# doesn't exist. This is because on this mode the tool is not able to create
458469
# and manage lineitems using the AGS endpoints.
@@ -572,9 +583,25 @@ def _get_lti_1p3_consumer(self):
572583
tool_key=self.lti_1p3_tool_public_key,
573584
tool_keyset_url=self.lti_1p3_tool_keyset_url,
574585
)
586+
elif self.config_store == self.CONFIG_EXTERNAL:
587+
consumer = consumer_class(
588+
iss=get_lti_api_base(),
589+
lti_oidc_url=self.external_config.get('lti_1p3_oidc_url'),
590+
lti_launch_url=self.external_config.get('lti_1p3_launch_url'),
591+
client_id=self.external_config.get('lti_1p3_client_id'),
592+
# Deployment ID hardcoded to 1 since
593+
# we're not using multi-tenancy.
594+
deployment_id='1',
595+
rsa_key=self.external_config.get('lti_1p3_private_key'),
596+
rsa_key_id=self.external_config.get('lti_1p3_private_key_id'),
597+
# Registered redirect uris
598+
redirect_uris=self.get_lti_1p3_redirect_uris(),
599+
tool_key=self.external_config.get('lti_1p3_tool_public_key'),
600+
tool_keyset_url=self.external_config.get('lti_1p3_tool_keyset_url'),
601+
)
575602
else:
576-
# This should not occur, but raise an error if self.config_store is not CONFIG_ON_XBLOCK
577-
# or CONFIG_ON_DB.
603+
# This should not occur, but raise an error if self.config_store is not
604+
# CONFIG_ON_XBLOCK, CONFIG_ON_DB or CONFIG_EXTERNAL.
578605
raise NotImplementedError
579606

580607
if isinstance(consumer, LtiAdvantageConsumer):
@@ -598,10 +625,10 @@ def get_lti_1p3_redirect_uris(self):
598625
Return pre-registered redirect uris or sensible defaults
599626
"""
600627
if self.config_store == self.CONFIG_EXTERNAL:
601-
# TODO: Add support for CONFIG_EXTERNAL for LTI 1.3.
602-
raise NotImplementedError
603-
604-
if self.config_store == self.CONFIG_ON_DB:
628+
redirect_uris = self.external_config.get('lti_1p3_redirect_uris')
629+
launch_url = self.external_config.get('lti_1p3_launch_url')
630+
deep_link_launch_url = self.external_config.get('lti_advantage_deep_linking_launch_url')
631+
elif self.config_store == self.CONFIG_ON_DB:
605632
redirect_uris = self.lti_1p3_redirect_uris
606633
launch_url = self.lti_1p3_launch_url
607634
deep_link_launch_url = self.lti_advantage_deep_linking_launch_url

lti_consumer/plugin/urls.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,13 @@
3131
public_keyset_endpoint,
3232
name='lti_consumer.public_keyset_endpoint_via_location'
3333
),
34+
# The external ID is split into slashes to make the URL more readable
35+
# and avoid clashing with USAGE_ID_PATTERN.
36+
path(
37+
'lti_consumer/v1/public_keysets/<slug:external_app>/<slug:external_slug>',
38+
public_keyset_endpoint,
39+
name='lti_consumer.public_keyset_endpoint_via_external_id'
40+
),
3441
re_path(
3542
'lti_consumer/v1/launch/(?:/(?P<suffix>.*))?$',
3643
launch_gate_endpoint,
@@ -46,6 +53,13 @@
4653
access_token_endpoint,
4754
name='lti_consumer.access_token_via_location'
4855
),
56+
# The external ID is split into slashes to make the URL more readable
57+
# and avoid clashing with USAGE_ID_PATTERN.
58+
path(
59+
'lti_consumer/v1/token/<slug:external_app>/<slug:external_slug>',
60+
access_token_endpoint,
61+
name='lti_consumer.access_token_via_external_id'
62+
),
4963
re_path(
5064
r'lti_consumer/v1/lti/(?P<lti_config_id>[-\w]+)/lti-dl/response',
5165
deep_linking_response_endpoint,

0 commit comments

Comments
 (0)