Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
53c74e5
feat(models): split LTI 1.3 configuration into separate Passport model
navinkarkera Mar 18, 2026
64a4af8
feat(lti): add signal handlers for LTI configuration deletion
navinkarkera Mar 19, 2026
73ed221
fix(lti): correct spelling and improve logging in signal handlers
navinkarkera Mar 19, 2026
76c138e
fix: lint issues
navinkarkera Mar 19, 2026
e7ee13f
feat: install openedx-events
navinkarkera Mar 19, 2026
42b0058
fix: model label
navinkarkera Mar 19, 2026
d87f83b
fix: handle no location
navinkarkera Mar 19, 2026
d39335d
test: fix tests
navinkarkera Mar 20, 2026
ff4adb2
fix: lint issues
navinkarkera Mar 20, 2026
02dba13
fixup! fix: lint issues
navinkarkera Mar 20, 2026
e6f764b
test: add signal tests
navinkarkera Mar 22, 2026
33a3af6
fix: coverage
navinkarkera Mar 22, 2026
02d60a1
refactor(models): rename create_lti_1p3_passport to get_or_create_lti…
navinkarkera Mar 24, 2026
475d2b5
fix: copy-paste bug when both public key and keyset url is specified
navinkarkera Mar 26, 2026
4ca5319
fix: lint issues
navinkarkera Mar 26, 2026
bda0876
test: improve coverage
navinkarkera Mar 26, 2026
4151f3a
refactor(views): remove unnecessary db call
navinkarkera Mar 26, 2026
6ad08c9
feat: add name and context key to passport and fix race condition
navinkarkera Mar 31, 2026
e18ab8c
fix: create name only if block is available
navinkarkera Mar 31, 2026
ae83e71
fix: test
navinkarkera Mar 31, 2026
df218ac
refactor: migration
navinkarkera Apr 3, 2026
993d7e0
chore: upgrade
navinkarkera Apr 3, 2026
73223f0
refactor: avoid duplicate signal triggers
navinkarkera Apr 3, 2026
a3540e9
refactor: api
navinkarkera Apr 3, 2026
a044512
refactor: rename
navinkarkera Apr 3, 2026
4e274e5
fix: tests
navinkarkera Apr 3, 2026
37634b2
chore: fix lint issues
navinkarkera Apr 3, 2026
e079a36
test: add some more
navinkarkera Apr 3, 2026
fedb4fb
fix: tests
navinkarkera Apr 3, 2026
8b68c1a
fix: coverage issue
navinkarkera Apr 3, 2026
c4c890b
chore: upgrade
navinkarkera Apr 7, 2026
e8199d6
fix: handle duplicate block explicitly
navinkarkera Apr 7, 2026
5ee39f8
fix: upgrade conflicts
navinkarkera Apr 13, 2026
f0672c5
refactor: robust duplicate signal handler
navinkarkera Apr 13, 2026
748c3ed
fix: migration for missing location field in configurations
navinkarkera Apr 14, 2026
b16c9bb
fix: lint issues
navinkarkera Apr 14, 2026
d8a7b7f
refactor: remove logic that update block fields in migration
navinkarkera Apr 16, 2026
a416676
refactor: add passport id to xml
navinkarkera Apr 16, 2026
6cd6122
fix(migrations): restore config fields from passport on reverse
navinkarkera Apr 18, 2026
965fe7f
refactor: apply suggestions and update docs
navinkarkera Apr 18, 2026
b07e30a
feat: bump version and update changelog
navinkarkera Apr 18, 2026
058609a
docs: Update lti_consumer/lti_xblock.py
feanil Apr 19, 2026
25e555f
docs: Update lti_consumer/migrations/0021_create_lti_1p3_passport.py
feanil Apr 19, 2026
90051fa
fix: remove unrelated file
navinkarkera Apr 20, 2026
5fae419
fix: update requirements
navinkarkera Apr 20, 2026
8e5b926
refactor: remove save_xblock helper
navinkarkera Apr 20, 2026
fac60e2
test: fix tests
navinkarkera Apr 20, 2026
2db7399
Revert "refactor: remove save_xblock helper"
navinkarkera Apr 20, 2026
c9d2adc
Revert "test: fix tests"
navinkarkera Apr 20, 2026
45f83c0
chore: fix typo
navinkarkera Apr 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@
[run]
data_file = .coverage
source = lti_consumer
omit = */urls.py
omit =
*/urls.py
6 changes: 6 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ Please See the `releases tab <https://github.com/openedx/xblock-lti-consumer/rel
Unreleased
~~~~~~~~~~

11.0.0 - 2026-04-20
--------------------
* Split LTI 1.3 Configuration into Passport Model
* Fix duplicate, copy-paste for LTI xblocks
* Add signal handlers for events like delete, duplicate etc.

10.0.1 - 2026-03-17
--------------------
* Revert the quoting of location/usage_keys done in version 9.14.4 & 9.14.5.
Expand Down
2 changes: 1 addition & 1 deletion lti_consumer/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@
from .apps import LTIConsumerApp
from .lti_xblock import LtiConsumerXBlock

__version__ = '10.0.1'
__version__ = '11.0.0'
34 changes: 34 additions & 0 deletions lti_consumer/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,33 @@
from lti_consumer.forms import CourseAllowPIISharingInLTIAdminForm
from lti_consumer.models import (
CourseAllowPIISharingInLTIFlag,
Lti1p3Passport,
LtiAgsLineItem,
LtiAgsScore,
LtiConfiguration,
LtiDlContentItem,
)


class LtiConfigurationInline(admin.TabularInline):
"""
Inline for the LtiConfiguration models in Lti1p3Passport.
"""
model = LtiConfiguration
extra = 0
can_delete = False
fields = ('location',)

def has_change_permission(self, request, obj=None): # pragma: nocover
return False

def has_delete_permission(self, request, obj=None): # pragma: nocover
return False

def has_add_permission(self, request, obj=None): # pragma: nocover
return False


@admin.register(LtiConfiguration)
class LtiConfigurationAdmin(admin.ModelAdmin):
"""
Expand All @@ -24,6 +44,20 @@ class LtiConfigurationAdmin(admin.ModelAdmin):
readonly_fields = ('location', 'config_id')


@admin.register(Lti1p3Passport)
class Lti1p3PassportAdmin(admin.ModelAdmin):
"""
Admin view for Lti1p3Passport models.
"""
list_display = (
'name',
'context_key',
'passport_id',
'lti_1p3_client_id',
)
inlines = [LtiConfigurationInline]


@admin.register(CourseAllowPIISharingInLTIFlag)
class CourseAllowPIISharingInLTIFlagAdmin(KeyedConfigurationModelAdmin):
"""
Expand Down
122 changes: 97 additions & 25 deletions lti_consumer/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,44 +6,117 @@
"""

import json
import logging

from opaque_keys.edx.keys import CourseKey

from lti_consumer.lti_1p3.constants import LTI_1P3_ROLE_MAP
from .models import CourseAllowPIISharingInLTIFlag, LtiConfiguration, LtiDlContentItem

from .filters import get_external_config_from_filter
from .models import CourseAllowPIISharingInLTIFlag, Lti1p3Passport, LtiConfiguration, LtiDlContentItem
from .utils import (
get_cache_key,
get_data_from_cache,
get_lti_1p3_context_types_claim,
get_lti_deeplinking_content_url,
get_lms_lti_access_token_link,
get_lms_lti_keyset_link,
get_lms_lti_launch_link,
get_lms_lti_access_token_link,
get_lti_1p3_context_types_claim,
get_lti_deeplinking_content_url,
)
from .filters import get_external_config_from_filter

log = logging.getLogger(__name__)


def _get_or_create_local_lti_config(lti_version, block_location,
config_store=LtiConfiguration.CONFIG_ON_XBLOCK, external_id=None):
def _ensure_lti_passport(block, lti_config):
"""
Retrieve the LtiConfiguration for the block described by block_location, if one exists. If one does not exist,
create an LtiConfiguration with the LtiConfiguration.CONFIG_ON_XBLOCK config_store.
Keep block-backed LTI passport aligned with current block key fields.
Function updates passport in place when safe, and splits to new passport
when current passport is shared and active key value changed.

Flow

passport missing or non-CONFIG_ON_XBLOCK
-> return current passport

passport unshared or tool fields blank
-> update in place from block if changed
-> return passport

passport shared
-> active tool key mode matches block
-> return passport
-> active key mode differs
-> create new passport
-> save new passport_id on block
-> return new passport
"""
passport = lti_config.lti_1p3_passport
if not passport or lti_config.config_store != LtiConfiguration.CONFIG_ON_XBLOCK:
return passport

block_public_key = str(block.lti_1p3_tool_public_key)
block_keyset_url = str(block.lti_1p3_tool_keyset_url)

# Update in place when passport not shared, or when key fields still empty.
if passport.lticonfiguration_set.count() == 1 or (
not passport.lti_1p3_tool_public_key and not passport.lti_1p3_tool_keyset_url
):
if passport.lti_1p3_tool_public_key != block_public_key or passport.lti_1p3_tool_keyset_url != block_keyset_url:
passport.lti_1p3_tool_public_key = block_public_key
passport.lti_1p3_tool_keyset_url = block_keyset_url
passport.save()
log.info("Updated LTI passport for %s", block.scope_ids.usage_id)
return passport

# For shared passport, check only active key mode before splitting passport.
key_mismatch = (
block.lti_1p3_tool_key_mode == 'public_key' and passport.lti_1p3_tool_public_key != block_public_key
) or (
block.lti_1p3_tool_key_mode == 'keyset_url' and passport.lti_1p3_tool_keyset_url != block_keyset_url
)

if key_mismatch:
from lti_consumer.plugin.compat import save_xblock # pylint: disable=import-outside-toplevel
passport = Lti1p3Passport.objects.create(
lti_1p3_tool_public_key=block_public_key,
lti_1p3_tool_keyset_url=block_keyset_url,
name=f"Passport of {block.display_name}",
context_key=block.context_id,
)
# Persist new passport link on block so future loads use split passport.
block.lti_1p3_passport_id = str(passport.passport_id)
save_xblock(block)
log.info("Created new LTI passport for %s", block.scope_ids.usage_id)

return passport

Treat the lti_version argument as the source of truth for LtiConfiguration.version and override the
LtiConfiguration.version with lti_version. This allows, for example, for
the XBlock to be the source of truth for the LTI version, which is a user-centric perspective we've adopted.
This allows XBlock users to update the LTI version without needing to update the database.

def _get_or_create_local_lti_config(lti_version, block, config_store=LtiConfiguration.CONFIG_ON_XBLOCK):
"""
Retrieve or create an LtiConfiguration for the block.

The lti_version parameter is treated as the source of truth, overriding
any stored version to allow XBlocks to control LTI version without DB updates.
"""
# The create operation is only performed when there is no existing configuration for the block
lti_config, _ = LtiConfiguration.objects.get_or_create(location=block_location)
lti_config, _ = LtiConfiguration.objects.get_or_create(location=block.scope_ids.usage_id)

lti_config.config_store = config_store
lti_config.external_id = external_id
# Ensure passport is synced with block
passport = _ensure_lti_passport(block, lti_config)

if lti_config.version != lti_version:
lti_config.version = lti_version
# Batch updates
updates = {
'config_store': config_store,
'external_id': block.external_config,
'version': lti_version,
}
if passport:
updates['lti_1p3_passport'] = passport

lti_config.save()
# Only save if changed
if any(getattr(lti_config, key) != value for key, value in updates.items()):
for key, value in updates.items():
setattr(lti_config, key, value)
lti_config.save()

return lti_config

Expand All @@ -65,7 +138,7 @@ def _get_lti_config_for_block(block):
if block.config_type == 'database':
lti_config = _get_or_create_local_lti_config(
block.lti_version,
block.scope_ids.usage_id,
block,
LtiConfiguration.CONFIG_ON_DB,
)
elif block.config_type == 'external':
Expand All @@ -75,14 +148,13 @@ def _get_lti_config_for_block(block):
)
lti_config = _get_or_create_local_lti_config(
config.get("version"),
block.scope_ids.usage_id,
block,
LtiConfiguration.CONFIG_EXTERNAL,
external_id=block.external_config,
)
else:
lti_config = _get_or_create_local_lti_config(
block.lti_version,
block.scope_ids.usage_id,
block,
LtiConfiguration.CONFIG_ON_XBLOCK,
)
return lti_config
Expand Down Expand Up @@ -140,7 +212,7 @@ def get_lti_1p3_launch_info(
if dl_content_items.exists():
deep_linking_content_items = [item.attributes for item in dl_content_items]

config_id = lti_config.config_id
config_id = lti_config.passport_id
client_id = lti_config.lti_1p3_client_id
deployment_id = "1"

Expand Down
2 changes: 1 addition & 1 deletion lti_consumer/lti_1p3/key_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ def validate_and_decode(self, token):
message = jwt.decode(
token,
key,
algorithms=['RS256', 'RS512',],
algorithms=['RS256', 'RS512'],
options={
'verify_signature': True,
'verify_aud': False
Expand Down
52 changes: 44 additions & 8 deletions lti_consumer/lti_xblock.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,11 @@
from django.conf import settings
from django.utils import timezone
from web_fragments.fragment import Fragment

from webob import Response
from xblock.core import List, Scope, String, XBlock
from xblock.fields import Boolean, Float, Integer
from xblock.validation import ValidationMessage

try:
from xblock.utils.resources import ResourceLoader
from xblock.utils.studio_editable import StudioEditableXBlockMixin
Expand All @@ -74,19 +74,19 @@

from .data import Lti1p3LaunchData
from .exceptions import LtiError
from .lti_1p1.consumer import LtiConsumer1p1, parse_result_json, LTI_PARAMETERS
from .lti_1p1.consumer import LTI_PARAMETERS, LtiConsumer1p1, parse_result_json
from .lti_1p1.oauth import log_authorization_header
from .outcomes import OutcomeService
from .plugin import compat
from .track import track_event
from .utils import (
EXTERNAL_ID_REGEX,
_,
resolve_custom_parameter_template,
external_config_filter_enabled,
external_user_id_1p1_launches_enabled,
database_config_enabled,
EXTERNAL_ID_REGEX,
external_config_filter_enabled,
external_multiple_launch_urls_enabled,
external_user_id_1p1_launches_enabled,
resolve_custom_parameter_template,
)

log = logging.getLogger(__name__)
Expand Down Expand Up @@ -311,6 +311,13 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock):
)

# LTI 1.3 fields
lti_1p3_passport_id = String(
display_name=_("Lti 1.3 passport ID that points to Lti1p3Passport table"),
scope=Scope.settings,
default="",
help=_("Passport ID for a reusable keys.")
)

lti_1p3_launch_url = String(
display_name=_("Tool Launch URL"),
default='',
Expand Down Expand Up @@ -366,6 +373,9 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock):
" requests received have the signature from the tool."
"<br /><b>This is not required when doing LTI 1.3 Launches"
" without LTI Advantage nor Basic Outcomes requests.</b>"
"<br /><br /><b>Changing the public key or keyset URL will cause the client ID, block keyset URL "
"and access token URL to be regenerated if they are shared between blocks. "
"Please check and update them in the LTI tool settings if necessary.</b>"
),
)
lti_1p3_tool_public_key = String(
Expand All @@ -380,6 +390,9 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock):
"from the tool."
"<br /><b>This is not required when doing LTI 1.3 Launches without LTI Advantage nor "
"Basic Outcomes requests.</b>"
"<br /><br /><b>Changing the public key or keyset URL will cause the client ID, block keyset URL "
"and access token URL to be regenerated if they are shared between blocks. "
"Please check and update them in the LTI tool settings if necessary.</b>"
),
)

Expand Down Expand Up @@ -991,7 +1004,7 @@ def resource_link_id(self):
i4x-2-3-lti-31de800015cf4afb973356dbe81496df this part of resource_link_id:
makes resource_link_id to be unique among courses inside same system.
"""
return str(urllib.parse.quote(f"{settings.LMS_BASE}-{self.scope_ids.usage_id.html_id()}"))
return str(self.scope_ids.usage_id)

@property
def lis_result_sourcedid(self):
Expand Down Expand Up @@ -1668,7 +1681,7 @@ def get_lti_1p3_launch_data(self):
user_id=self.lms_user_id,
user_role=self.role,
config_id=config_id,
resource_link_id=str(location),
resource_link_id=self.resource_link_id,
external_user_id=self.external_user_id,
preferred_username=username,
name=full_name,
Expand Down Expand Up @@ -1833,3 +1846,26 @@ def index_dictionary(self):
xblock_body["content_type"] = "LTI Consumer"

return xblock_body

def add_xml_to_node(self, node):
"""
The lti_1p3_passport_id XBlock field may be empty on blocks that existed before
the Lti1p3Passport model was introduced (migration 0021). Rather than backfilling
the field in the migration (which requires the XBlock runtime and can fail silently),
we read the authoritative passport_id from the DB at export time. This ensures that
when a block is duplicated or exported/imported, the receiving block's
lti_1p3_passport_id field is populated and can be used to find the shared passport
instead of creating new credentials.
"""
Comment thread
feanil marked this conversation as resolved.
super().add_xml_to_node(node)

try:
Comment thread
navinkarkera marked this conversation as resolved.
from .models import LtiConfiguration # pylint: disable=import-outside-toplevel

configuration = LtiConfiguration.objects.select_related("lti_1p3_passport").get(
location=self.scope_ids.usage_id,
)
if configuration.lti_1p3_passport:
node.set("lti_1p3_passport_id", str(configuration.lti_1p3_passport.passport_id))
except LtiConfiguration.DoesNotExist:
pass
Loading