-
Notifications
You must be signed in to change notification settings - Fork 84
/
api.py
324 lines (263 loc) · 11.9 KB
/
api.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
"""
Python APIs used to handle LTI configuration and launches.
Some methods are meant to be used inside the XBlock, so they
return plaintext to allow easy testing/mocking.
"""
import json
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 .utils import (
get_cache_key,
get_data_from_cache,
get_lti_1p3_context_types_claim,
get_lti_deeplinking_content_url,
get_lms_lti_keyset_link,
get_lms_lti_launch_link,
get_lms_lti_access_token_link,
)
from .filters import get_external_config_from_filter
def _get_or_create_local_lti_config(lti_version, block_location,
config_store=LtiConfiguration.CONFIG_ON_XBLOCK, external_id=None):
"""
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.
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.
"""
# 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.config_store = config_store
lti_config.external_id = external_id
if lti_config.version != lti_version:
lti_config.version = lti_version
lti_config.save()
return lti_config
def _get_config_by_config_id(config_id):
"""
Gets the LTI config by its UUID config ID
"""
return LtiConfiguration.objects.get(config_id=config_id)
def _get_lti_config_for_block(block):
"""
Retrieves or creates a LTI Configuration for a block.
This wraps around `_get_or_create_local_lti_config` and handles the block and modulestore
bits of configuration.
"""
if block.config_type == 'database':
lti_config = _get_or_create_local_lti_config(
block.lti_version,
block.scope_ids.usage_id,
LtiConfiguration.CONFIG_ON_DB,
)
elif block.config_type == 'external':
config = get_external_config_from_filter(
{"course_key": block.scope_ids.usage_id.context_key},
block.external_config
)
lti_config = _get_or_create_local_lti_config(
config.get("version"),
block.scope_ids.usage_id,
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,
LtiConfiguration.CONFIG_ON_XBLOCK,
)
return lti_config
def config_id_for_block(block):
"""
Returns the externally facing config_id of the LTI Configuration used by this block,
creating one if required. That ID is suitable for use in launch data or get_consumer.
"""
config = _get_lti_config_for_block(block)
return config.config_id
def get_lti_consumer(config_id):
"""
Retrieves an LTI Consumer instance for a given configuration.
Returns an instance of LtiConsumer1p1 or LtiConsumer1p3 depending
on the configuration.
"""
# Return an instance of LTI 1.1 or 1.3 consumer, depending
# on the configuration stored in the model.
return _get_config_by_config_id(config_id).get_lti_consumer()
def get_lti_1p3_launch_info(
launch_data,
):
"""
Retrieves the Client ID, Keyset URL and other urls used to configure a LTI tool.
"""
# Retrieve LTI Config and consumer
lti_config = _get_config_by_config_id(launch_data.config_id)
lti_consumer = lti_config.get_lti_consumer()
# Check if deep Linking is available, if so, add some extra context:
# Deep linking launch URL, and if deep linking is already configured
deep_linking_launch_url = None
deep_linking_content_items = []
deep_linking_enabled = lti_consumer.lti_dl_enabled()
if deep_linking_enabled:
launch_data.message_type = "LtiDeepLinkingRequest"
deep_linking_launch_url = lti_consumer.prepare_preflight_url(
launch_data,
)
# Retrieve LTI Content Items (if any was set up)
dl_content_items = LtiDlContentItem.objects.filter(
lti_configuration=lti_config
)
# Add content item attributes to context
if dl_content_items.exists():
deep_linking_content_items = [item.attributes for item in dl_content_items]
config_id = lti_config.config_id
client_id = lti_config.lti_1p3_client_id
# Display LTI launch information from external configuration.
# if an external configuration is being used.
if lti_config.config_store == lti_config.CONFIG_EXTERNAL:
external_config = get_external_config_from_filter({}, lti_config.external_id)
config_id = lti_config.external_id.replace(':', '/')
client_id = external_config.get('lti_1p3_client_id')
# Return LTI launch information for end user configuration
return {
'client_id': client_id,
'keyset_url': get_lms_lti_keyset_link(config_id),
'deployment_id': '1',
'oidc_callback': get_lms_lti_launch_link(),
'token_url': get_lms_lti_access_token_link(config_id),
'deep_linking_launch_url': deep_linking_launch_url,
'deep_linking_content_items':
json.dumps(deep_linking_content_items, indent=4) if deep_linking_content_items else None,
}
def get_lti_1p3_launch_start_url(
launch_data,
deep_link_launch=False,
dl_content_id=None,
):
"""
Computes and retrieves the LTI URL that starts the OIDC flow.
"""
# Retrieve LTI consumer
lti_consumer = get_lti_consumer(launch_data.config_id)
# Include a message hint in the launch_data depending on LTI launch type
# Case 1: Performs Deep Linking configuration flow. Triggered by staff users to
# configure tool options and select content to be presented.
if deep_link_launch:
launch_data.message_type = "LtiDeepLinkingRequest"
# Case 2: Perform a LTI Launch for `ltiResourceLink` content types, since they
# need to use the launch mechanism from the callback view.
elif dl_content_id:
launch_data.deep_linking_content_item_id = dl_content_id
# Prepare and return OIDC flow start url
return lti_consumer.prepare_preflight_url(launch_data)
def get_lti_1p3_content_url(
launch_data,
):
"""
Computes and returns which URL the LTI consumer should launch to.
This can return:
1. A LTI Launch link if:
a. No deep linking is set
b. Deep Linking is configured, but a single ltiResourceLink was selected.
2. The Deep Linking content presentation URL if there's more than one
Lti DL content in the database.
"""
# Retrieve LTI consumer
lti_config = _get_config_by_config_id(launch_data.config_id)
# List content items
content_items = lti_config.ltidlcontentitem_set.all()
# If there's no content items, return normal LTI launch URL
if not content_items.count():
return get_lti_1p3_launch_start_url(launch_data)
# If there's a single `ltiResourceLink` content, return the launch
# url for that specific deep link
if content_items.count() == 1 and content_items.get().content_type == LtiDlContentItem.LTI_RESOURCE_LINK:
return get_lti_1p3_launch_start_url(
launch_data,
dl_content_id=content_items.get().id,
)
# If there's more than one content item, return content presentation URL
return get_lti_deeplinking_content_url(lti_config.id, launch_data)
def get_deep_linking_data(deep_linking_id, config_id):
"""
Retrieves deep linking attributes.
Only works with a single line item, this is a limitation in the
current content presentation implementation.
"""
# Retrieve LTI Configuration
lti_config = _get_config_by_config_id(config_id)
# Only filter DL content item from content item set in the same LTI configuration.
# This avoids a malicious user to input a random LTI id and perform LTI DL
# content launches outside the scope of its configuration.
content_item = lti_config.ltidlcontentitem_set.get(pk=deep_linking_id)
return content_item.attributes
def get_lti_pii_sharing_state_for_course(course_key: CourseKey) -> bool:
"""
Returns the status of PII sharing for the provided course.
Args:
course_key (CourseKey): Course key for the course to check for PII sharing
Returns:
bool: The state of PII sharing for this course for LTI.
"""
return CourseAllowPIISharingInLTIFlag.current(course_key).enabled
def validate_lti_1p3_launch_data(launch_data):
"""
Validate that the data in Lti1p3LaunchData are valid and raise an LtiMessageHintValidationFailure exception if they
are not.
The initializer of the Lti1p3LaunchData takes care of ensuring that required data is provided to the class. This
utility method verifies that other requirements of the data are met.
"""
validation_messages = []
# The context claim is an object that composes properties about the context. The claim itself is optional, but if it
# is provided, then the id property is required. Ensure that if any other of the other optional properties are
# provided that the id property is also provided.
if ((launch_data.context_type or launch_data.context_title or launch_data.context_label) and not
launch_data.context_id):
validation_messages.append(
"The context_id attribute is required in the launch data if any optional context properties are provided."
)
if launch_data.user_role not in LTI_1P3_ROLE_MAP and launch_data.user_role is not None:
validation_messages.append(f"The user_role attribute {launch_data.user_role} is not a valid user_role.")
context_type = launch_data.context_type
if context_type:
try:
get_lti_1p3_context_types_claim(context_type)
except ValueError:
validation_messages.append(
f"The context_type attribute {context_type} in the launch data is not a valid context_type."
)
proctoring_launch_data = launch_data.proctoring_launch_data
if (launch_data.message_type in ["LtiStartProctoring", "LtiEndAssessment"] and not
proctoring_launch_data):
validation_messages.append(
"The proctoring_launch_data attribute is required if the message_type attribute is \"LtiStartProctoring\" "
"or \"LtiEndAssessment\"."
)
if (proctoring_launch_data and launch_data.message_type == "LtiStartProctoring" and not
proctoring_launch_data.start_assessment_url):
validation_messages.append(
"The proctoring_start_assessment_url attribute is required if the message_type attribute is "
"\"LtiStartProctoring\"."
)
if validation_messages:
return False, validation_messages
else:
return True, []
def get_end_assessment_return(user_id, resource_link_id):
"""
Returns the end_assessment_return value stored in the cache. This can be used by applications to determine whether
to invoke an LtiEndAssessment LTI launch.
Arguments:
* user_id: the database of the requesting User model instance
* resource_link_id: the resource_link_id of the LTI link for the assessment
"""
end_assessment_return_key = get_cache_key(
app="lti",
key="end_assessment_return",
user_id=user_id,
resource_link_id=resource_link_id,
)
cached_end_assessment_return = get_data_from_cache(end_assessment_return_key)
return cached_end_assessment_return