forked from openedx/xblock-lti-consumer
-
Notifications
You must be signed in to change notification settings - Fork 0
/
lti_xblock.py
1812 lines (1549 loc) · 73.9 KB
/
lti_xblock.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
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
XBlock implementation of the LTI (Learning Tools Interoperability) consumer specification.
Resources
---------
Background and detailed LTI specification can be found at:
http://www.imsglobal.org/specs/ltiv1p1p1/implementation-guide
This module is based on the version 1.1.1 of the LTI specification by the
IMS Global authority. For authentication, it uses OAuth1.
When responding back to the LTI tool provider, we must issue a correct
response. Types of responses and their message payload is available at:
Table A1.2 Interpretation of the 'CodeMajor/severity' matrix.
http://www.imsglobal.org/gws/gwsv1p0/imsgws_wsdlBindv1p0.html
A resource to test the LTI protocol (PHP realization):
http://www.imsglobal.org/developers/LTI/test/v1p1/lms.php
We have also begun to add support for LTI 1.2/2.0. We will keep this
docstring in synch with what support is available. The first LTI 2.0
feature to be supported is the REST API results service, see specification
at
http://www.imsglobal.org/lti/ltiv2p0/uml/purl.imsglobal.org/vocab/lis/v2/outcomes/Result/service.html
What is supported:
------------------
1.) Display of simple LTI in iframe or a new window.
2.) Multiple LTI components on a single page.
3.) The use of multiple LTI providers per course.
4.) Use of advanced LTI component that provides back a grade.
A) LTI 1.1.1 XML endpoint
a.) The LTI provider sends back a grade to a specified URL.
b.) Currently only action "update" is supported. "Read", and "delete"
actions initially weren't required.
B) LTI 2.0 Result Service JSON REST endpoint
(http://www.imsglobal.org/lti/ltiv2p0/uml/purl.imsglobal.org/vocab/lis/v2/outcomes/Result/service.html)
a.) Discovery of all such LTI http endpoints for a course. External tools GET from this discovery
endpoint and receive URLs for interacting with individual grading units.
(see lms/djangoapps/courseware/views.py:get_course_lti_endpoints)
b.) GET, PUT and DELETE in LTI Result JSON binding
(http://www.imsglobal.org/lti/ltiv2p0/mediatype/application/vnd/ims/lis/v2/result+json/index.html)
for a provider to synchronize grades into edx-platform. Reading, Setting, and Deleteing
Numeric grades between 0 and 1 and text + basic HTML feedback comments are supported, via
GET / PUT / DELETE HTTP methods respectively
"""
import logging
import re
import urllib.parse
from collections import namedtuple
from importlib import import_module
import pkg_resources
import bleach
from django.conf import settings
from django.utils import timezone, translation
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
from xblockutils.resources import ResourceLoader
from xblockutils.studio_editable import StudioEditableXBlockMixin
from .data import Lti1p3LaunchData
from .exceptions import LtiError
from .lti_1p1.consumer import LtiConsumer1p1, parse_result_json, LTI_PARAMETERS
from .lti_1p1.oauth import log_authorization_header
from .outcomes import OutcomeService
from .plugin import compat
from .track import track_event
from .utils import (
_,
resolve_custom_parameter_template,
external_config_filter_enabled,
external_user_id_1p1_launches_enabled,
database_config_enabled,
EXTERNAL_ID_REGEX,
)
log = logging.getLogger(__name__)
DOCS_ANCHOR_TAG_OPEN = (
"<a "
"target='_blank' "
"href='"
"http://edx.readthedocs.org"
"/projects/open-edx-building-and-running-a-course/en/latest/exercises_tools/lti_component.html"
"'>"
)
RESULT_SERVICE_SUFFIX_PARSER = re.compile(r"^user/(?P<anon_id>[\w-]+)", re.UNICODE)
LTI_1P1_ROLE_MAP = {
'student': 'Student,Learner',
'staff': 'Administrator',
'instructor': 'Instructor',
}
CUSTOM_PARAMETER_SEPARATOR = '='
# Allow a key-pair key and value to contain any character except "=".
CUSTOM_PARAMETER_REGEX = re.compile(
rf'^([^{CUSTOM_PARAMETER_SEPARATOR}]+{CUSTOM_PARAMETER_SEPARATOR}[^{CUSTOM_PARAMETER_SEPARATOR}]+)$',
)
# Catch a value enclosed by ${}, the value enclosed can contain any charater except "=".
CUSTOM_PARAMETER_TEMPLATE_REGEX = re.compile(r'^(\${[^%s]+})$' % CUSTOM_PARAMETER_SEPARATOR)
def parse_handler_suffix(suffix):
"""
Parser function for HTTP request path suffixes
parses the suffix argument (the trailing parts of the URL) of the LTI2.0 REST handler.
must be of the form "user/<anon_id>". Returns anon_id if match found, otherwise raises LtiError
Arguments:
suffix (unicode): suffix to parse
Returns:
unicode: anon_id if match found
Raises:
LtiError if suffix cannot be parsed or is not in its expected form
"""
if suffix:
match_obj = RESULT_SERVICE_SUFFIX_PARSER.match(suffix)
if match_obj:
return match_obj.group('anon_id')
# fall-through handles all error cases
msg = _("No valid user id found in endpoint URL")
log.info("[LTI]: %s", msg)
raise LtiError(msg)
def valid_config_type_values(block):
"""
Return a list of valid values for the config_type XBlock field.
Always return "new" as a config_type value. Determine whether the "database" and "external" config_type values are
valid value options, depending on the state of the appropriate toggle.
"""
values = [
{"display_name": _("Configuration on block"), "value": "new"}
]
if database_config_enabled(block.scope_ids.usage_id.context_key):
values.append({"display_name": _("Database Configuration"), "value": "database"})
if external_config_filter_enabled(block.scope_ids.usage_id.context_key):
values.append({"display_name": _("Reusable Configuration"), "value": "external"})
return values
LaunchTargetOption = namedtuple('LaunchTargetOption', ['display_name', 'value'])
class LaunchTarget:
"""
Constants for launch_target field options
"""
IFRAME = LaunchTargetOption('Inline', 'iframe')
MODAL = LaunchTargetOption('Modal', 'modal')
NEW_WINDOW = LaunchTargetOption('New Window', 'new_window')
@XBlock.needs('i18n')
@XBlock.needs('rebind_user')
@XBlock.wants('user')
@XBlock.wants('settings')
@XBlock.wants('lti-configuration')
class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock):
"""
This XBlock provides an LTI consumer interface for integrating
third-party tools using the LTI specification.
Except usual Xmodule structure it proceeds with OAuth signing.
How it works::
1. Get credentials from course settings.
2. There is minimal set of parameters need to be signed (presented for Vitalsource)::
user_id
oauth_callback
lis_outcome_service_url
lis_result_sourcedid
launch_presentation_return_url
lti_message_type
lti_version
roles
*+ all custom parameters*
These parameters should be encoded and signed by *OAuth1* together with
`launch_url` and *POST* request type.
3. Signing proceeds with client key/secret pair obtained from course settings.
That pair should be obtained from LTI provider and set into course settings by course author.
After that signature and other OAuth data are generated.
OAuth data which is generated after signing is usual::
oauth_callback
oauth_nonce
oauth_consumer_key
oauth_signature_method
oauth_timestamp
oauth_version
4. All that data is passed to form and sent to LTI provider server by browser via
autosubmit via JavaScript.
Form example::
<form
action="${launch_url}"
name="ltiLaunchForm-${element_id}"
class="ltiLaunchForm"
method="post"
target="ltiLaunchFrame-${element_id}"
encType="application/x-www-form-urlencoded"
>
<input name="launch_presentation_return_url" value="" />
<input name="lis_outcome_service_url" value="" />
<input name="lis_result_sourcedid" value="" />
<input name="lti_message_type" value="basic-lti-launch-request" />
<input name="lti_version" value="LTI-1p0" />
<input name="oauth_callback" value="about:blank" />
<input name="oauth_consumer_key" value="${oauth_consumer_key}" />
<input name="oauth_nonce" value="${oauth_nonce}" />
<input name="oauth_signature_method" value="HMAC-SHA1" />
<input name="oauth_timestamp" value="${oauth_timestamp}" />
<input name="oauth_version" value="1.0" />
<input name="user_id" value="${user_id}" />
<input name="role" value="student" />
<input name="oauth_signature" value="${oauth_signature}" />
<input name="custom_1" value="${custom_param_1_value}" />
<input name="custom_2" value="${custom_param_2_value}" />
<input name="custom_..." value="${custom_param_..._value}" />
<input type="submit" value="Press to Launch" />
</form>
5. LTI provider has same secret key and it signs data string via *OAuth1* and compares signatures.
If signatures are correct, LTI provider redirects iframe source to LTI tool web page,
and LTI tool is rendered to iframe inside course.
Otherwise error message from LTI provider is generated.
"""
block_settings_key = 'lti_consumer'
display_name = String(
display_name=_("Display Name"),
help=_(
"Enter the name that students see for this component. "
"Analytics reports may also use the display name to identify this component."
),
scope=Scope.settings,
default=_("LTI Consumer"),
)
description = String(
display_name=_("LTI Application Information"),
help=_(
"Enter a description of the third party application. "
"If requesting username and/or email, use this text box to inform users "
"why their username and/or email will be forwarded to a third party application."
),
default="",
scope=Scope.settings
)
config_type = String(
display_name=_("Configuration Type"),
scope=Scope.settings,
values_provider=valid_config_type_values,
default="new",
help=_(
"Select 'Configuration on block' to configure a new LTI Tool. "
"If the support staff provided you with a pre-configured LTI reusable Tool ID, select"
"'Reusable Configuration' and enter it in the text field below."
)
)
lti_version = String(
display_name=_("LTI Version"),
scope=Scope.settings,
values=[
{"display_name": "LTI 1.1/1.2", "value": "lti_1p1"},
{"display_name": "LTI 1.3", "value": "lti_1p3"},
],
default="lti_1p1",
help=_(
"Select the LTI version that your tool supports."
"<br />The XBlock LTI Consumer fully supports LTI 1.1.1, "
"LTI 1.3 and LTI Advantage features."
),
)
external_config = String(
display_name=_("LTI Reusable Configuration ID"),
scope=Scope.settings,
help=_("Enter the reusable LTI external configuration ID provided by the support staff."),
)
# LTI 1.3 fields
lti_1p3_launch_url = String(
display_name=_("Tool Launch URL"),
default='',
scope=Scope.settings,
help=_(
"Enter the LTI 1.3 Tool Launch URL. "
"<br />This is the URL the LMS will use to launch the LTI Tool."
),
)
lti_1p3_oidc_url = String(
display_name=_("Tool Initiate Login URL"),
default='',
scope=Scope.settings,
help=_(
"Enter the LTI 1.3 Tool OIDC Authorization url (can also be called login or login initiation URL)."
"<br />This is the URL the LMS will use to start a LTI authorization "
"prior to doing the launch request."
),
)
lti_1p3_redirect_uris = List(
display_name=_("Registered Redirect URIs"),
help=_(
"Valid urls the Tool may request us to redirect the id token to. The redirect uris "
"are often the same as the launch url/deep linking url so if this field is "
"empty, it will use them as the default. If you need to use different redirect "
"uri's, enter them here. If you use this field you must enter all valid redirect "
"uri's the tool may request."
),
scope=Scope.settings
)
lti_1p3_tool_key_mode = String(
display_name=_("Tool Public Key Mode"),
scope=Scope.settings,
values=[
{"display_name": "Public Key", "value": "public_key"},
{"display_name": "Keyset URL", "value": "keyset_url"},
],
default="public_key",
help=_(
"Select how the tool's public key information will be specified."
),
)
lti_1p3_tool_keyset_url = String(
display_name=_("Tool Keyset URL"),
default='',
scope=Scope.settings,
help=_(
"Enter the LTI 1.3 Tool's JWK keysets URL."
"<br />This link should retrieve a JSON file containing"
" public keys and signature algorithm information, so"
" that the LMS can check if the messages and launch"
" 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>"
),
)
lti_1p3_tool_public_key = String(
display_name=_("Tool Public Key"),
multiline_editor=True,
default='',
scope=Scope.settings,
help=_(
"Enter the LTI 1.3 Tool's public key."
"<br />This is a string that starts with '-----BEGIN PUBLIC KEY-----' and is required "
"so that the LMS can check if the messages and launch 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>"
),
)
lti_1p3_enable_nrps = Boolean(
display_name=_("Enable LTI NRPS"),
help=_("Enable LTI Names and Role Provisioning Services."),
default=False,
scope=Scope.settings
)
# DEPRECATED - These variables were moved to the LtiConfiguration Model
lti_1p3_client_id = String(
display_name=_("LTI 1.3 Block Client ID - DEPRECATED"),
default='',
scope=Scope.settings,
help=_("DEPRECATED - This is now stored in the LtiConfiguration model."),
)
lti_1p3_block_key = String(
display_name=_("LTI 1.3 Block Key - DEPRECATED"),
default='',
scope=Scope.settings
)
# Switch to enable/disable the LTI Advantage Deep linking service
lti_advantage_deep_linking_enabled = Boolean(
display_name=_("Deep linking"),
help=_("Select True if you want to enable LTI Advantage Deep Linking."),
default=False,
scope=Scope.settings
)
lti_advantage_deep_linking_launch_url = String(
display_name=_("Deep Linking Launch URL"),
default='',
scope=Scope.settings,
help=_(
"Enter the LTI Advantage Deep Linking Launch URL. If the tool does not specify one, "
"use the same value as 'Tool Launch URL'."
),
)
lti_advantage_ags_mode = String(
display_name=_("LTI Assignment and Grades Service"),
values=[
{"display_name": _("Disabled"), "value": "disabled"},
{"display_name": _("Allow tools to submit grades only (declarative)"), "value": "declarative"},
{"display_name": _("Allow tools to manage and submit grade (programmatic)"), "value": "programmatic"},
],
default='declarative',
scope=Scope.settings,
help=_(
"Enable the LTI-AGS service and select the functionality enabled for LTI tools. "
"The 'declarative' mode (default) will provide a tool with a LineItem created from the XBlock settings, "
"while the 'programmatic' one will allow tools to manage, create and link the grades."
),
)
# LTI 1.1 fields
lti_id = String(
display_name=_("LTI ID"),
help=_(
"Enter the LTI ID for the external LTI provider. "
"This value must be the same LTI ID that you entered in the "
"LTI Passports setting on the Advanced Settings page."
"<br />See the {docs_anchor_open}edX LTI documentation{anchor_close} for more details on this setting."
).format(
docs_anchor_open=DOCS_ANCHOR_TAG_OPEN,
anchor_close="</a>"
),
default='',
scope=Scope.settings
)
launch_url = String(
display_name=_("LTI URL"),
help=_(
"Enter the URL of the external tool that this component launches. "
"This setting is only used when Hide External Tool is set to False."
"<br />See the {docs_anchor_open}edX LTI documentation{anchor_close} for more details on this setting."
).format(
docs_anchor_open=DOCS_ANCHOR_TAG_OPEN,
anchor_close="</a>"
),
default='',
scope=Scope.settings
)
# Misc
custom_parameters = List(
display_name=_("Custom Parameters"),
help=_(
"Add the key/value pair for any custom parameters, such as the page your e-book should open to or "
"the background color for this component. Ex. [\"page=1\", \"color=white\"]"
"<br />See the {docs_anchor_open}edX LTI documentation{anchor_close} for more details on this setting."
).format(
docs_anchor_open=DOCS_ANCHOR_TAG_OPEN,
anchor_close="</a>"
),
scope=Scope.settings
)
launch_target = String(
display_name=_("LTI Launch Target"),
help=_(
"Select Inline if you want the LTI content to open in an IFrame in the current page. "
"Select Modal if you want the LTI content to open in a modal window in the current page. "
"Select New Window if you want the LTI content to open in a new browser window. "
"This setting is only used when Hide External Tool is set to False."
),
default=LaunchTarget.IFRAME.value,
scope=Scope.settings,
values=[
{"display_name": LaunchTarget.IFRAME.display_name, "value": LaunchTarget.IFRAME.value},
{"display_name": LaunchTarget.MODAL.display_name, "value": LaunchTarget.MODAL.value},
{"display_name": LaunchTarget.NEW_WINDOW.display_name, "value": LaunchTarget.NEW_WINDOW.value},
],
)
button_text = String(
display_name=_("Button Text"),
help=_(
"Enter the text on the button used to launch the third party application. "
"This setting is only used when Hide External Tool is set to False and "
"LTI Launch Target is set to Modal or New Window."
),
default="",
scope=Scope.settings
)
inline_height = Integer(
display_name=_("Inline Height"),
help=_(
"Enter the desired pixel height of the iframe which will contain the LTI tool. "
"This setting is only used when Hide External Tool is set to False and "
"LTI Launch Target is set to Inline."
),
default=800,
scope=Scope.settings
)
modal_height = Integer(
display_name=_("Modal Height"),
help=_(
"Enter the desired viewport percentage height of the modal overlay which will contain the LTI tool. "
"This setting is only used when Hide External Tool is set to False and "
"LTI Launch Target is set to Modal."
),
default=80,
scope=Scope.settings
)
modal_width = Integer(
display_name=_("Modal Width"),
help=_(
"Enter the desired viewport percentage width of the modal overlay which will contain the LTI tool. "
"This setting is only used when Hide External Tool is set to False and "
"LTI Launch Target is set to Modal."
),
default=80,
scope=Scope.settings
)
has_score = Boolean(
display_name=_("Scored"),
help=_("Select True if this component will receive a numerical score from the external LTI system."),
default=False,
scope=Scope.settings
)
weight = Float(
display_name="Weight",
help=_(
"Enter the number of points possible for this component. "
"The default value is 1.0. "
"This setting is only used when Scored is set to True."
),
default=1.0,
scope=Scope.settings,
values={"min": 0},
)
module_score = Float(
help=_("The score kept in the xblock KVS -- duplicate of the published score in django DB"),
default=None,
scope=Scope.user_state
)
score_comment = String(
help=_("Comment as returned from grader, LTI2.0 spec"),
default="",
scope=Scope.user_state
)
hide_launch = Boolean(
display_name=_("Hide External Tool"),
help=_(
"Select True if you want to use this component as a placeholder for syncing with an external grading "
"system rather than launch an external tool. "
"This setting hides the Launch button and any IFrames for this component."
),
default=False,
scope=Scope.settings
)
accept_grades_past_due = Boolean(
display_name=_("Accept grades past deadline"),
help=_("Select True to allow third party systems to post grades past the deadline."),
default=True,
scope=Scope.settings
)
# Users will be presented with a message indicating that their e-mail/username would be sent to a third
# party application. When "Open in New Page" is not selected, the tool automatically appears without any
# user action.
ask_to_send_username = Boolean(
display_name=_("Request user's username"),
# Translators: This is used to request the user's username for a third party service.
help=_("Select True to request the user's username."),
default=False,
scope=Scope.settings
)
ask_to_send_full_name = Boolean(
display_name=_("Request user's full name"),
# Translators: This is used to request the user's full name for a third party service.
help=_("Select True to request the user's full name."),
default=False,
scope=Scope.settings
)
ask_to_send_email = Boolean(
display_name=_("Request user's email"),
# Translators: This is used to request the user's email for a third party service.
help=_("Select True to request the user's email address."),
default=False,
scope=Scope.settings
)
enable_processors = Boolean(
display_name=_("Send extra parameters"),
help=_("Select True to send the extra parameters, which might contain Personally Identifiable Information. "
"The processors are site-wide, please consult the site administrator if you have any questions."),
default=False,
scope=Scope.settings
)
# Possible editable fields
editable_field_names = (
'display_name', 'description', 'config_type', 'lti_version', 'external_config',
# LTI 1.3 variables
'lti_1p3_launch_url', 'lti_1p3_redirect_uris', 'lti_1p3_oidc_url',
'lti_1p3_tool_key_mode', 'lti_1p3_tool_keyset_url', 'lti_1p3_tool_public_key',
'lti_1p3_enable_nrps',
# LTI Advantage variables
'lti_advantage_deep_linking_enabled', 'lti_advantage_deep_linking_launch_url',
'lti_advantage_ags_mode',
# LTI 1.1 variables
'lti_id', 'launch_url',
# Other parameters
'custom_parameters', 'launch_target', 'button_text', 'inline_height', 'modal_height',
'modal_width', 'has_score', 'weight', 'hide_launch', 'accept_grades_past_due',
'ask_to_send_username', 'ask_to_send_full_name', 'ask_to_send_email', 'enable_processors',
)
# Author view
has_author_view = True
@staticmethod
def workbench_scenarios():
"""
Gather scenarios to be displayed in the workbench
"""
scenarios = [
('LTI Consumer XBlock',
'''<sequence_demo>
<lti_consumer
display_name="LTI Consumer - New Window"
lti_id="test"
description=""
ask_to_send_username="False"
ask_to_send_email="False"
enable_processors="True"
launch_target="new_window"
launch_url="https://lti.tools/saltire/tp" />
<lti_consumer
display_name="LTI Consumer - IFrame"
lti_id="test"
ask_to_send_username="False"
ask_to_send_email="False"
enable_processors="True"
description=""
launch_target="iframe"
launch_url="https://lti.tools/saltire/tp" />
</sequence_demo>
'''),
]
return scenarios
@staticmethod
def _get_statici18n_js_url(loader): # pragma: no cover
"""
Returns the Javascript translation file for the currently selected language, if any found by
`pkg_resources`
"""
lang_code = translation.get_language()
if not lang_code:
return None
text_js = 'public/js/translations/{lang_code}/text.js'
country_code = lang_code.split('-')[0]
for code in (translation.to_locale(lang_code), lang_code, country_code):
if pkg_resources.resource_exists(loader.module_name, text_js.format(lang_code=code)):
return text_js.format(lang_code=code)
return None
def validate_field_data(self, validation, data):
# Validate custom parameters is a list.
if not isinstance(data.custom_parameters, list):
_ = self.runtime.service(self, "i18n").ugettext
validation.add(ValidationMessage(ValidationMessage.ERROR, str(
_("Custom Parameters must be a list")
)))
# Validate custom parameters format.
if not all(map(CUSTOM_PARAMETER_REGEX.match, data.custom_parameters)):
_ = self.runtime.service(self, 'i18n').ugettext
validation.add(ValidationMessage(ValidationMessage.ERROR, str(
_('Custom Parameters should be strings in "x=y" format.'),
)))
# Validate the external config ID.
if (
data.config_type == 'external' and not
(data.external_config and EXTERNAL_ID_REGEX.match(str(data.external_config)))
):
_ = self.runtime.service(self, 'i18n').ugettext
validation.add(ValidationMessage(ValidationMessage.ERROR, str(
_('Reusable configuration ID must be set when using external config (Example: "x:y").'),
)))
# keyset URL and public key are mutually exclusive
if data.lti_1p3_tool_key_mode == 'keyset_url':
data.lti_1p3_tool_public_key = ''
elif data.lti_1p3_tool_key_mode == 'public_key':
data.lti_1p3_tool_keyset_url = ''
def validate(self):
"""
Validate this XBlock's configuration
"""
validation = super().validate()
_ = self.runtime.service(self, "i18n").ugettext
# Check if lti_id exists in the LTI passports of the current course. (LTI 1.1 only)
# This validation is just for the Unit page in Studio; we don't want to block users from saving
# a new LTI ID before they've added it to advanced settings, but we do want to warn them about it.
# If we put this check in validate_field_data(), the settings editor wouldn't let them save changes.
if self.lti_version == "lti_1p1" and self.lti_id:
lti_passport_ids = [lti_passport.split(':')[0].strip() for lti_passport in self.course.lti_passports]
if self.lti_id.strip() not in lti_passport_ids:
validation.add(ValidationMessage(ValidationMessage.WARNING, str(
_("The specified LTI ID is not configured in this course's Advanced Settings.")
)))
return validation
def get_settings(self):
"""
Get the XBlock settings bucket via the SettingsService.
"""
settings_service = self.runtime.service(self, 'settings')
if settings_service:
return settings_service.get_settings_bucket(self)
return {}
def get_parameter_processors(self):
"""
Read the parameter processor functions from the settings and return their functions.
"""
if not self.enable_processors:
return
try:
for path in self.get_settings().get('parameter_processors', []):
module_path, func_name = path.split(':', 1)
module = import_module(module_path)
yield getattr(module, func_name)
except Exception:
log.exception('Something went wrong in reading the LTI XBlock configuration.')
raise
def get_pii_sharing_enabled(self):
"""
Returns whether PII can be transmitted via this XBlock. This controls both whether the PII sharing XBlock
fields ask_to_send_username, ask_to_send_full_name, and ask_to_send_email are displayed in Studio and whether
these data are shared in LTI launches, regardless of the values of the settings on the XBlock.
"""
config_service = self.runtime.service(self, 'lti-configuration')
if config_service:
is_already_sharing_learner_info = (
self.ask_to_send_username or
self.ask_to_send_full_name or
self.ask_to_send_email
)
return config_service.configuration.lti_access_to_learners_editable(
self.scope_ids.usage_id.context_key,
is_already_sharing_learner_info,
)
# TODO: The LTI configuration service is currently only available from the studio_view. This means that
# the CourseAllowPIISharingInLTIFlag does not control PII sharing in the author_view or student_view,
# because the service is not defined in those contexts.
return True
@property
def editable_fields(self):
"""
Return a list of editable fields that should be editable by the user. Any XBlock fields not included in the
returned list are not available or visible to the user to be edited.
Note that the Javascript in xblock_studio_view.js shows and hides various fields depending on the option
currently selected for these fields. Because editable_fields defines a list of fields when that's used rendering
the Studio edit view, it cannot support the dynamic experience we want the user to have when editing the XBlock.
This property should return the set of all properties the user should be able to modify based on the current
environment. For example, if the external_config_filter_enabled flag is not enabled, the external_config field
should not be a part of editable_fields, because no user can edit this field in this case. On the other hand, if
the currently selected config_type is 'database', the fields that are otherwise stored in the database should
still be a part of editable_fields, because a user may select a different config_type from the menu, and we want
those fields to become editable at that time. The Javascript will determine when to show or to hide a given
field.
Fields that are potentially filtered out include "config_type", "external_config", "ask_to_send_username",
"ask_to_send_full_name", and "ask_to_send_email".
"""
editable_fields = self.editable_field_names
noneditable_fields = []
is_database_config_enabled = database_config_enabled(self.scope_ids.usage_id.context_key)
is_external_config_filter_enabled = external_config_filter_enabled(self.scope_ids.usage_id.context_key)
# If neither additional config_types are enabled, do not display the "config_type" field to users, as "new" is
# the only option and does not make sense without other options.
if not is_database_config_enabled and not is_external_config_filter_enabled:
noneditable_fields.append('config_type')
# If the enable_external_config_filter is not enabled, do not display the "external_config" field to users.
if not is_external_config_filter_enabled:
noneditable_fields.append('external_config')
# update the editable fields if this XBlock is configured to not to allow the
# editing of 'ask_to_send_username', 'ask_to_send_full_name', and 'ask_to_send_email'.
pii_sharing_enabled = self.get_pii_sharing_enabled()
if not pii_sharing_enabled:
noneditable_fields.extend(['ask_to_send_username', 'ask_to_send_full_name', 'ask_to_send_email'])
editable_fields = tuple(
field
for field in editable_fields
if field not in noneditable_fields
)
return editable_fields
@property
def descriptor(self):
"""
Returns this XBlock object.
This is for backwards compatibility with the XModule API.
Some LMS code still assumes a descriptor attribute on the XBlock object.
"""
return self
@property
def context_id(self):
"""
Return context_id.
context_id is an opaque identifier that uniquely identifies the context (e.g., a course)
that contains the link being launched.
"""
return str(self.scope_ids.usage_id.context_key)
@property
def role(self):
"""
Get system user role.
"""
user = self.runtime.service(self, 'user').get_current_user()
if not user.opt_attrs["edx-platform.is_authenticated"]:
raise LtiError(self.ugettext("Could not get user data for current request"))
return user.opt_attrs.get('edx-platform.user_role', 'student')
@property
def course(self):
"""
Return course by course id.
"""
return self.runtime.modulestore.get_course(self.scope_ids.usage_id.context_key)
@property
def lti_provider_key_secret(self):
"""
Obtains client_key and client_secret credentials from current course.
"""
for lti_passport in self.course.lti_passports:
try:
# NOTE While unpacking the lti_passport by using ":" as delimiter, first item will be lti_id,
# last item will be client_secret and the rest are considered as client_key.
# So you can have more than one colon for client_key.
lti_id, *key, secret = [i.strip() for i in lti_passport.split(':')]
if not key:
raise ValueError
key = ':'.join(key)
except ValueError as err:
msg = self.ugettext(
'Could not parse LTI passport: {lti_passport!r}. Should be "id:key:secret" string.'
).format(lti_passport=lti_passport)
raise LtiError(msg) from err
if lti_id == self.lti_id.strip():
return key, secret
return '', ''
@property
def lms_user_id(self):
"""
Returns the edx-platform database user id for the current user.
"""
user_id = self.runtime.service(self, 'user').get_current_user().opt_attrs.get(
'edx-platform.user_id', None)
if user_id is None:
raise LtiError(self.ugettext("Could not get user id for current request"))
return user_id
@property
def anonymous_user_id(self):
"""
Returns the opaque anonymous_student_id for the current user.
This defaults to 'student' when testing in studio.
It will return the proper anonymous ID in the LMS.
"""
user_id = self.runtime.service(self, 'user').get_current_user().opt_attrs.get(
'edx-platform.anonymous_user_id', None)
if user_id is None:
raise LtiError(self.ugettext("Could not get user id for current request"))
return str(user_id)
def get_icon_class(self):
""" Returns the icon class """
if self.graded and self.has_score: # pylint: disable=no-member
return 'problem'
return 'other'
@property
def external_user_id(self):
"""
Returns the opaque external user id for the current user.
"""
user_id = self.runtime.service(self, 'user').get_external_user_id('lti')
if user_id is None:
raise LtiError(self.ugettext("Could not get user id for current request"))
return str(user_id)
def get_lti_1p1_user_id(self):
"""
Returns the user ID to send to an LTI tool during an LTI 1.1/2.0 launch. If the
enable_external_user_id_1p1_launches CourseWaffleFlag is enabled for the course, returns the external_user_id
defined by the external_user_ids Djangoapp. Otherwise, returns the anonymous_user_id.
This addresses cases where LTI tools require a static, opaque user_id that is consistent across contexts. On an
opt-in basis, courses can be set up to send the external_user_id instead of the anonymous_user_id. Note that
toggling this flag in a running course carries the risk of breaking the LTI integrations in the course. This
flag should also only be enabled for new courses in which no LTI attempts have been made.
"""
if external_user_id_1p1_launches_enabled(self.scope_ids.usage_id.context_key):
return self.external_user_id
return self.anonymous_user_id
def get_lti_1p1_user_from_user_id(self, user_id):
"""
Returns the user object associated with a user_id. This is used in LTI 1.1/2.0 integrations for calls to the
LTI 1.1 Basic Outcomes service and the LTI 2.0 Results service. Tool Providers may make calls to this library's
endpoints with a user identifier. This function returns a user object associated with that user identifier.
The user identifier may be a course-anonymized user ID (i.e. the anonymous_user_id) or the global, consistent
user ID (i.e. the external_user_id). This functions returns the correct User object.
"""
if external_user_id_1p1_launches_enabled(self.scope_ids.usage_id.context_key):
try:
return compat.get_user_from_external_user_id(user_id)
except LtiError:
return None
else:
return self.runtime.service(self, 'user').get_user_by_anonymous_id(user_id)
@property
def resource_link_id(self):
"""
This is an opaque unique identifier that the LTI Tool Consumer guarantees will be unique
within the Tool Consumer for every placement of the link.
If the tool / activity is placed multiple times in the same context,
each of those placements will be distinct.
This value will also change if the item is exported from one system or
context and imported into another system or context.
resource_link_id is a required LTI launch parameter.
Example: u'edx.org-i4x-2-3-lti-31de800015cf4afb973356dbe81496df'
Hostname, edx.org,
makes resource_link_id change on import to another system.
Last part of location, location.name - 31de800015cf4afb973356dbe81496df,
is random hash, updated by course_id,
this makes resource_link_id unique inside single course.
First part of location is tag-org-course-category, i4x-2-3-lti.
Location.name itself does not change on import to another course,
but org and course_id change.
So together with org and course_id in a form of
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()}"))
@property
def lis_result_sourcedid(self):