Skip to content

Commit 7ec0136

Browse files
committed
refactor: use lti_xblock_config in place of lti_configuration
Allow location to be any string instead of limiting to usage_key to allow lti to work out of xblock context.
1 parent 397db77 commit 7ec0136

10 files changed

Lines changed: 368 additions & 287 deletions

lti_consumer/api.py

Lines changed: 37 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@
66
"""
77

88
import json
9+
from typing import Any
910

1011
from opaque_keys.edx.keys import CourseKey, UsageKey
1112

1213
from lti_consumer.lti_1p3.constants import LTI_1P3_ROLE_MAP
14+
from lti_consumer.lti_1p3.exceptions import Lti1p3Exception
1315

1416
from .filters import get_external_config_from_filter
1517
from .models import CourseAllowPIISharingInLTIFlag, LtiConfiguration, LtiDlContentItem, LtiXBlockConfig
@@ -87,6 +89,17 @@ def _get_config_by_config_id(config_id) -> LtiConfiguration:
8789
return LtiConfiguration.objects.get(config_id=config_id)
8890

8991

92+
def get_lti_config_by_location(location: str, **filters: dict[str, Any]) -> LtiXBlockConfig:
93+
"""
94+
Gets the LTI xblock config by location
95+
"""
96+
config = LtiXBlockConfig.objects.get(
97+
location=location,
98+
**filters,
99+
)
100+
return config
101+
102+
90103
def try_get_config_by_id(config_id) -> LtiConfiguration | None:
91104
"""
92105
Tries to get the LTI config by its UUID config ID
@@ -142,28 +155,19 @@ def config_for_block(block):
142155
return xblock_config
143156

144157

145-
def get_lti_consumer(config_id: str, location: UsageKey | None = None):
146-
"""
147-
Retrieves an LTI Consumer instance for a given location.
148-
149-
Returns an instance of LtiConsumer1p1 or LtiConsumer1p3 depending
150-
on the configuration.
151-
"""
152-
# Return an instance of LTI 1.1 or 1.3 consumer, depending
153-
# on the configuration stored in the model.
154-
return _get_config_by_config_id(config_id).get_lti_consumer(location)
155-
156-
157158
def get_lti_1p3_launch_info(
158159
launch_data,
159-
location: UsageKey | None = None,
160+
location: UsageKey,
160161
):
161162
"""
162163
Retrieves the Client ID, Keyset URL and other urls used to configure a LTI tool.
163164
"""
164165
# Retrieve LTI Config and consumer
165-
lti_config = _get_config_by_config_id(launch_data.config_id)
166-
lti_consumer = lti_config.get_lti_consumer(location)
166+
lti_xblock_config = get_lti_config_by_location(
167+
str(location),
168+
lti_configuration__config_id=launch_data.config_id,
169+
)
170+
lti_consumer = lti_xblock_config.get_lti_consumer()
167171

168172
# Check if deep Linking is available, if so, add some extra context:
169173
# Deep linking launch URL, and if deep linking is already configured
@@ -180,19 +184,22 @@ def get_lti_1p3_launch_info(
180184

181185
# Retrieve LTI Content Items (if any was set up)
182186
dl_content_items = LtiDlContentItem.objects.filter(
183-
lti_configuration=lti_config
187+
lti_xblock_config=lti_xblock_config
184188
)
185189
# Add content item attributes to context
186190
if dl_content_items.exists():
187191
deep_linking_content_items = [item.attributes for item in dl_content_items]
188192

193+
lti_config = lti_xblock_config.lti_configuration
194+
if not lti_config:
195+
raise Lti1p3Exception("LTI configuration not found.")
189196
config_id = lti_config.config_id
190197
client_id = lti_config.lti_1p3_client_id
191198
deployment_id = "1"
192199

193200
# Display LTI launch information from external configuration.
194201
# if an external configuration is being used.
195-
if lti_config.config_store == CONFIG_EXTERNAL:
202+
if lti_config.config_store == CONFIG_EXTERNAL and lti_config.external_id:
196203
external_config = get_external_config_from_filter({}, lti_config.external_id)
197204
config_id = lti_config.external_id.replace(':', '/')
198205
client_id = external_config.get('lti_1p3_client_id')
@@ -213,15 +220,19 @@ def get_lti_1p3_launch_info(
213220

214221
def get_lti_1p3_launch_start_url(
215222
launch_data,
216-
location: UsageKey | None = None,
223+
location: UsageKey,
217224
deep_link_launch=False,
218225
dl_content_id=None,
219226
):
220227
"""
221228
Computes and retrieves the LTI URL that starts the OIDC flow.
222229
"""
223230
# Retrieve LTI consumer
224-
lti_consumer = get_lti_consumer(launch_data.config_id, location)
231+
lti_xblock_config = get_lti_config_by_location(
232+
str(location),
233+
lti_configuration__config_id=launch_data.config_id,
234+
)
235+
lti_consumer = lti_xblock_config.get_lti_consumer()
225236

226237
# Include a message hint in the launch_data depending on LTI launch type
227238
# Case 1: Performs Deep Linking configuration flow. Triggered by staff users to
@@ -239,7 +250,7 @@ def get_lti_1p3_launch_start_url(
239250

240251
def get_lti_1p3_content_url(
241252
launch_data,
242-
location: UsageKey | None = None,
253+
location: UsageKey,
243254
):
244255
"""
245256
Computes and returns which URL the LTI consumer should launch to.
@@ -252,10 +263,13 @@ def get_lti_1p3_content_url(
252263
Lti DL content in the database.
253264
"""
254265
# Retrieve LTI consumer
255-
lti_config = _get_config_by_config_id(launch_data.config_id)
266+
lti_xblock_config = get_lti_config_by_location(
267+
str(location),
268+
lti_configuration__config_id=launch_data.config_id,
269+
)
256270

257271
# List content items
258-
content_items = lti_config.ltidlcontentitem_set.all()
272+
content_items = lti_xblock_config.ltidlcontentitem_set.all()
259273

260274
# If there's no content items, return normal LTI launch URL
261275
if not content_items.count():
@@ -271,23 +285,7 @@ def get_lti_1p3_content_url(
271285
)
272286

273287
# If there's more than one content item, return content presentation URL
274-
return get_lti_deeplinking_content_url(lti_config.id, launch_data)
275-
276-
277-
def get_deep_linking_data(deep_linking_id, config_id):
278-
"""
279-
Retrieves deep linking attributes.
280-
281-
Only works with a single line item, this is a limitation in the
282-
current content presentation implementation.
283-
"""
284-
# Retrieve LTI Configuration
285-
lti_config = _get_config_by_config_id(config_id)
286-
# Only filter DL content item from content item set in the same LTI configuration.
287-
# This avoids a malicious user to input a random LTI id and perform LTI DL
288-
# content launches outside the scope of its configuration.
289-
content_item = lti_config.ltidlcontentitem_set.get(pk=deep_linking_id)
290-
return content_item.attributes
288+
return get_lti_deeplinking_content_url(lti_xblock_config.id, launch_data)
291289

292290

293291
def get_lti_pii_sharing_state_for_course(course_key: CourseKey) -> bool:

lti_consumer/filters.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""
22
Module that contains the openedx filters for this XBlock
33
"""
4-
from typing import Dict
4+
from typing import Any, Dict
55

66
from openedx_filters.tooling import OpenEdxPublicFilter
77

@@ -15,7 +15,7 @@ class LTIConfigurationListed(OpenEdxPublicFilter):
1515
filter_type = "org.openedx.xblock.lti_consumer.configuration.listed.v1"
1616

1717
@classmethod
18-
def run_filter(cls, context: Dict, config_id: str, configurations: Dict):
18+
def run_filter(cls, context: Dict, config_id: str, configurations: Dict) -> tuple[Dict, str, Dict]:
1919
"""
2020
Execute the filter with the signature specified.
2121
@@ -28,7 +28,7 @@ def run_filter(cls, context: Dict, config_id: str, configurations: Dict):
2828
return data.get("context"), data.get("config_id"), data.get("configurations")
2929

3030

31-
def get_external_config_from_filter(context, config_id=''):
31+
def get_external_config_from_filter(context, config_id='') -> dict[str, Any]:
3232
"""
3333
Thin wrapper around the LTIConfigurationListed filter to get the external
3434
configuration values using a certain context and config_id.

lti_consumer/migrations/0020_ltixblockconfig.py

Lines changed: 0 additions & 30 deletions
This file was deleted.
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Generated by Django 5.2.12 on 2026-03-17 11:55
2+
3+
import django.db.models.deletion
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
dependencies = [
9+
('lti_consumer', '0019_mariadb_uuid_conversion'),
10+
]
11+
12+
operations = [
13+
migrations.CreateModel(
14+
name='LtiXBlockConfig',
15+
fields=[
16+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
17+
(
18+
'location',
19+
models.CharField(
20+
db_index=True,
21+
help_text='Normally, this is the location of xblock. But it can any string to allow it work outside of xblock context.',
22+
max_length=255,
23+
unique=True,
24+
),
25+
),
26+
(
27+
'lti_configuration',
28+
models.ForeignKey(
29+
blank=True,
30+
null=True,
31+
on_delete=django.db.models.deletion.CASCADE,
32+
to='lti_consumer.lticonfiguration',
33+
),
34+
),
35+
],
36+
),
37+
migrations.AddField(
38+
model_name='ltiagslineitem',
39+
name='lti_xblock_config',
40+
field=models.ForeignKey(
41+
blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='lti_consumer.ltixblockconfig'
42+
),
43+
),
44+
migrations.AddField(
45+
model_name='ltidlcontentitem',
46+
name='lti_xblock_config',
47+
field=models.ForeignKey(
48+
blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='lti_consumer.ltixblockconfig'
49+
),
50+
),
51+
]

lti_consumer/migrations/0021_migrate_config_id_to_blocks.py

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1-
# Generated migration for copying config_id into modulestore from database (Django 5.2)
1+
# Generated migration for copying config_id into modulestore from database (Django 6.2)
22
"""
33
This migration will copy config_id from LtiConsumer database to LtiConsumerXBlock.
44
55
This will help us link xblocks with LtiConsumer database rows without relying on the location or usage_key of xblocks.
66
"""
7+
import uuid
8+
79
from django.db import migrations
810

911

@@ -15,8 +17,10 @@ def copy_config_id(apps, _):
1517
LtiXBlockConfig = apps.get_model("lti_consumer", "LtiXBlockConfig")
1618

1719
for configuration in LtiConfiguration.objects.all():
20+
# Create a new unique location for cconfiguration with no location.
21+
location = configuration.location or str(uuid.uuid4())
1822
LtiXBlockConfig.objects.update_or_create(
19-
location=configuration.location,
23+
location=str(location),
2024
defaults={
2125
"lti_configuration": configuration,
2226
}
@@ -33,22 +37,22 @@ def copy_config_id(apps, _):
3337
for line_item in LtiAgsLineItem.objects.all():
3438
xblock_config = LtiXBlockConfig.objects.filter(
3539
lti_configuration=line_item.lti_configuration,
36-
location=line_item.lti_configuration.location,
3740
).first()
3841
if not xblock_config:
3942
print(f"Invalid configuration linked to AGS line item: {line_item}.")
40-
line_item.xblock_config = xblock_config
43+
continue
44+
line_item.lti_xblock_config = xblock_config
4145
line_item.save()
4246

4347
LtiDlContentItem = apps.get_model("lti_consumer", "LtiDlContentItem")
4448
for content_item in LtiDlContentItem.objects.all():
4549
xblock_config = LtiXBlockConfig.objects.filter(
4650
lti_configuration=content_item.lti_configuration,
47-
location=content_item.lti_configuration.location,
4851
).first()
4952
if not xblock_config:
5053
print(f"Invalid configuration linked to Dl Conent Item: {content_item}.")
51-
content_item.xblock_config = xblock_config
54+
continue
55+
content_item.lti_xblock_config = xblock_config
5256
content_item.save()
5357

5458

@@ -59,7 +63,7 @@ def backwards(*_):
5963
class Migration(migrations.Migration):
6064

6165
dependencies = [
62-
('lti_consumer', '0020_ltixblockconfig'),
66+
('lti_consumer', '0020_ltixblockconfig_ltiagslineitem_lti_xblock_config_and_more'),
6367
]
6468

6569
operations = [

lti_consumer/migrations/0022_remove_lticonfiguration_location.py

Lines changed: 0 additions & 16 deletions
This file was deleted.

0 commit comments

Comments
 (0)