panda_pal 5.18.2 → 5.19.0.beta1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +26 -0
- data/app/controllers/panda_pal/lti_v1_p0_controller.rb +11 -4
- data/app/controllers/panda_pal/lti_v1_p3_controller.rb +17 -8
- data/app/lib/lti_xml/base_platform.rb +32 -18
- data/app/lib/lti_xml/bridge_platform.rb +2 -2
- data/app/lib/panda_pal/launch_url_helpers.rb +21 -14
- data/app/models/panda_pal/lti_registration.rb +41 -0
- data/app/models/panda_pal/organization.rb +2 -0
- data/app/models/panda_pal/platform/canvas.rb +63 -28
- data/app/models/panda_pal/session.rb +19 -1
- data/config/initializers/apartment.rb +1 -1
- data/db/migrate/20261006000000_create_panda_pal_lti_registrations.rb +14 -0
- data/db/migrate/20261006000001_add_tool_to_panda_pal_sessions.rb +5 -0
- data/lib/panda_pal/helpers/controller_helper.rb +20 -1
- data/lib/panda_pal/helpers/route_helper.rb +8 -1
- data/lib/panda_pal/helpers/session_replacement.rb +2 -2
- data/lib/panda_pal/version.rb +1 -1
- data/lib/panda_pal.rb +82 -10
- data/panda_pal.gemspec +1 -0
- data/spec/controllers/panda_pal/lti_v1_p0_controller_spec.rb +85 -1
- data/spec/controllers/panda_pal/lti_v1_p3_tools_spec.rb +144 -0
- data/spec/lib/panda_pal/lti_tools_spec.rb +153 -0
- data/spec/models/panda_pal/lti_registration_spec.rb +72 -0
- data/spec/models/panda_pal/platform/canvas_install_spec.rb +115 -0
- data/spec/spec_helper.rb +1 -0
- data/spec/support/isolated_lti_config.rb +24 -0
- metadata +28 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 36f3440bb369f482a622cca1b58dbe3857bd74749d53776be10e6b99e8bf3f79
|
|
4
|
+
data.tar.gz: 02c42332159e06ae1778d8155c2a58d230ae24dc82a5f1e0c6027aa7e0a9f274
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9d4253dfec794377b88c5efbd6b2167b225373049e0e7b7694ef3f6f4d5e39253e71aa720094b39b336e6b4afc251b00c7f60f35dc1dbe3bb11dde35ce9b966b
|
|
7
|
+
data.tar.gz: b5004d4d2f7be4dcfb91d70d81445284084ea24a11f03f8db8513c6794cacdef99bf9c4da96c50b51cd1a9870ae4129ca3d66ac6b91a0065f4ef34e1aac59157
|
data/README.md
CHANGED
|
@@ -30,9 +30,35 @@ org.install_lti(
|
|
|
30
30
|
exists: :error, # (Optional) Action to take if an LTI with the same Key already exists. Options are :error, :replace, :duplicate, :update
|
|
31
31
|
version: "v1p3", # (Optional, default `v1p3`) LTI Version. Accepts `v1p0` or `v1p3`.
|
|
32
32
|
dedicated_deployment: false, # (Optional) If true, the Organization will be updated to link to a single deployment rather then to the general LTI Key. (experimental, LTI 1.3 only)
|
|
33
|
+
tool: :default, # (Optional) Which tool to install - see Multiple Tools below
|
|
33
34
|
)
|
|
34
35
|
```
|
|
35
36
|
|
|
37
|
+
### Multiple Tools
|
|
38
|
+
One app can publish more than one LTI tool - for example a teacher-facing tool and a separate, student-facing editor button with its own label and visibility. In Canvas each tool is its own Developer Key (LTI 1.3) or its own External Tool (LTI 1.0), with its own placements.
|
|
39
|
+
|
|
40
|
+
Everything configured without a `tool:` belongs to the default tool, so single-tool apps don't change.
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
# config/initializers/lti.rb
|
|
44
|
+
PandaPal.define_tool(:translate,
|
|
45
|
+
title: 'Teacher Reports - Translate', # Merged over lti_options for this tool's config
|
|
46
|
+
custom_params: { mode: 'translate' }, # Merged over lti_custom_params for this tool's config
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
PandaPal.stage_placement(:editor_button, tool: :translate, text: 'Translate', visibility: 'public')
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
# config/routes.rb
|
|
54
|
+
lti_nav editor_button: 'translate#editor_button', tool: :translate # Served at <mount>/translate/editor_button
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
- **Config:** each tool has its own config URL: `/v1p3/config?tool=translate` and `/v1p0/config?tool=translate`. The default tool's URLs are unchanged.
|
|
58
|
+
- **Install:** `org.install_lti(host: ..., tool: :translate)`. On LTI 1.3 this creates (or reuses) the tool's own Developer Key and records it as a `PandaPal::LtiRegistration` on the Organization. The Organization's `key` keeps pointing at the default tool. On LTI 1.0 both tools share the Organization's consumer key and secret.
|
|
59
|
+
- **Launches:** `current_session.lti_tool` says which tool was launched. On LTI 1.3 the tool comes from the registration matching the launch's client ID (and deployment ID). On LTI 1.0 it comes from the signed `custom_panda_pal_tool` field that every non-default tool config includes. PandaPal routes each launch, and each deep-link redirect, using that tool's placements.
|
|
60
|
+
- **Registering by hand:** if a tool's Developer Key was set up outside `install_lti`, register it yourself: `org.lti_registrations.create!(tool: 'translate', client_id: '10000000000123', deployment_id: nil)`. A registration with a `deployment_id` takes priority over one without.
|
|
61
|
+
|
|
36
62
|
### LTI 1.3 Configuration
|
|
37
63
|
LTI 1.3 has some additional configuration steps required to setup an LTI:
|
|
38
64
|
|
|
@@ -18,7 +18,7 @@ module PandaPal
|
|
|
18
18
|
launch_params: request.request_parameters.to_h,
|
|
19
19
|
})
|
|
20
20
|
|
|
21
|
-
redirect_with_session_to(:"#{
|
|
21
|
+
redirect_with_session_to(:"#{current_session.lti_launch_route(params[:launch_type])}_url", route_context: main_app)
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
def tool_config
|
|
@@ -26,13 +26,20 @@ module PandaPal
|
|
|
26
26
|
render plain: 'Domains must be set in lti_environments'
|
|
27
27
|
return
|
|
28
28
|
end
|
|
29
|
-
|
|
29
|
+
|
|
30
|
+
unless PandaPal.lti_tool?(params[:tool])
|
|
31
|
+
render plain: "Unknown LTI tool '#{params[:tool]}'", status: :not_found
|
|
32
|
+
return
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
tool = PandaPal.normalize_lti_tool!(params[:tool])
|
|
36
|
+
platform = PandaPal.lti_tool_options(tool).delete(:platform) || 'canvas.instructure.com'
|
|
30
37
|
request_url = "#{request.scheme}://#{request.host_with_port}"
|
|
31
38
|
case platform
|
|
32
39
|
when 'canvas.instructure.com'
|
|
33
|
-
xml_config = LtiXml::CanvasPlatform.new(platform, request_url, main_app)
|
|
40
|
+
xml_config = LtiXml::CanvasPlatform.new(platform, request_url, main_app, tool: tool)
|
|
34
41
|
when 'bridgeapp.com'
|
|
35
|
-
xml_config = LtiXml::BridgePlatform.new(platform, request_url, main_app)
|
|
42
|
+
xml_config = LtiXml::BridgePlatform.new(platform, request_url, main_app, tool: tool)
|
|
36
43
|
else
|
|
37
44
|
render plain: 'platform must be set under lti_options'
|
|
38
45
|
return
|
|
@@ -50,7 +50,7 @@ module PandaPal
|
|
|
50
50
|
launch_params: @decoded_lti_jwt,
|
|
51
51
|
})
|
|
52
52
|
|
|
53
|
-
redirect_with_session_to(:"#{
|
|
53
|
+
redirect_with_session_to(:"#{current_session.lti_launch_route(ltype)}_url", route_context: main_app)
|
|
54
54
|
end
|
|
55
55
|
end
|
|
56
56
|
|
|
@@ -72,7 +72,7 @@ module PandaPal
|
|
|
72
72
|
deep_link_data: @decoded_lti_jwt[LtiConstants::DeepLinking::DATA]
|
|
73
73
|
})
|
|
74
74
|
|
|
75
|
-
redirect_url =
|
|
75
|
+
redirect_url = current_session.deep_link_redirect_url(request)
|
|
76
76
|
if redirect_url
|
|
77
77
|
redirect_with_session_to(redirect_url, route_context: main_app)
|
|
78
78
|
else
|
|
@@ -86,28 +86,37 @@ module PandaPal
|
|
|
86
86
|
return
|
|
87
87
|
end
|
|
88
88
|
|
|
89
|
-
|
|
89
|
+
unless PandaPal.lti_tool?(params[:tool])
|
|
90
|
+
render plain: "Unknown LTI tool '#{params[:tool]}'", status: :not_found
|
|
91
|
+
return
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
tool = PandaPal.normalize_lti_tool!(params[:tool])
|
|
95
|
+
tool_options = PandaPal.lti_tool_options(tool)
|
|
96
|
+
|
|
97
|
+
platform = tool_options.delete(:platform) || 'canvas.instructure.com'
|
|
90
98
|
request_url = "#{request.scheme}://#{request.host_with_port}"
|
|
91
99
|
parsed_request_url = URI.parse(request_url)
|
|
92
100
|
|
|
93
|
-
mapped_placements = PandaPal.lti_paths.map do |k, opts|
|
|
101
|
+
mapped_placements = PandaPal.lti_paths(tool).map do |k, opts|
|
|
94
102
|
opts = LaunchUrlHelpers.normalize_lti_launch_desc(opts)
|
|
95
103
|
opts.merge!({
|
|
96
104
|
placement: k,
|
|
97
105
|
target_link_uri: LaunchUrlHelpers.absolute_launch_url(
|
|
98
106
|
k.to_sym,
|
|
99
107
|
host: parsed_request_url,
|
|
100
|
-
default_auto_launch: true
|
|
108
|
+
default_auto_launch: true,
|
|
109
|
+
tool: tool
|
|
101
110
|
),
|
|
102
111
|
})
|
|
103
112
|
opts
|
|
104
113
|
end
|
|
105
114
|
|
|
106
115
|
config_json = {
|
|
107
|
-
title:
|
|
116
|
+
title: tool_options[:title],
|
|
108
117
|
scopes: PandaPal.lti_scopes,
|
|
109
118
|
public_jwk_url: v1p3_public_jwks_url,
|
|
110
|
-
description:
|
|
119
|
+
description: tool_options[:description] || 'PandaPal LTI',
|
|
111
120
|
target_link_uri: v1p3_resource_link_request_url, #app_url(:resource_link_request, request),
|
|
112
121
|
oidc_initiation_url: v1p3_oidc_login_url,
|
|
113
122
|
extensions: [{
|
|
@@ -117,7 +126,7 @@ module PandaPal
|
|
|
117
126
|
placements: mapped_placements,
|
|
118
127
|
},
|
|
119
128
|
}],
|
|
120
|
-
custom_fields: PandaPal.lti_custom_params, # PandaPal.lti_options[:custom_fields],
|
|
129
|
+
custom_fields: PandaPal.lti_custom_params(tool), # PandaPal.lti_options[:custom_fields],
|
|
121
130
|
}
|
|
122
131
|
|
|
123
132
|
render json: config_json
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
module LtiXml
|
|
2
2
|
class BasePlatform
|
|
3
|
-
attr_accessor :parsed_request_url, :platform, :main_app
|
|
3
|
+
attr_accessor :parsed_request_url, :platform, :main_app, :tool
|
|
4
4
|
|
|
5
|
-
def initialize(platform, full_request_url, main_app)
|
|
5
|
+
def initialize(platform, full_request_url, main_app, tool: PandaPal::DEFAULT_TOOL)
|
|
6
6
|
@platform = platform
|
|
7
7
|
@parsed_request_url = URI.parse(full_request_url)
|
|
8
8
|
@main_app = main_app
|
|
9
|
+
@tool = tool
|
|
9
10
|
end
|
|
10
11
|
|
|
11
12
|
# Override one of the following formatter methods in your individual platform class, or if you must, you can override
|
|
@@ -22,7 +23,7 @@ module LtiXml
|
|
|
22
23
|
|
|
23
24
|
#strip the launch url
|
|
24
25
|
xml = @tc.to_xml
|
|
25
|
-
xml = xml.sub(/<blti:launch_url>.*<\/blti:launch_url>/, '') if
|
|
26
|
+
xml = xml.sub(/<blti:launch_url>.*<\/blti:launch_url>/, '') if lti_options[:launch_url] === false
|
|
26
27
|
|
|
27
28
|
return xml
|
|
28
29
|
end
|
|
@@ -30,15 +31,15 @@ module LtiXml
|
|
|
30
31
|
private
|
|
31
32
|
|
|
32
33
|
def ims_tool_config
|
|
33
|
-
ims_lti_config = {title:
|
|
34
|
-
if
|
|
35
|
-
ims_lti_config[:secure_launch_url] =
|
|
36
|
-
elsif
|
|
37
|
-
ims_lti_config[:secure_launch_url] = "#{lti_domain_from_url}#{
|
|
38
|
-
elsif
|
|
39
|
-
ims_lti_config[:launch_url] =
|
|
40
|
-
elsif
|
|
41
|
-
ims_lti_config[:launch_url] = "#{lti_domain_from_url}#{
|
|
34
|
+
ims_lti_config = {title: lti_options[:title]}
|
|
35
|
+
if lti_options[:secure_launch_url].present? # Assumes full URL is provided
|
|
36
|
+
ims_lti_config[:secure_launch_url] = lti_options[:secure_launch_url]
|
|
37
|
+
elsif lti_options[:secure_launch_path].present? # Assumes path is provided
|
|
38
|
+
ims_lti_config[:secure_launch_url] = "#{lti_domain_from_url}#{lti_options[:secure_launch_path]}"
|
|
39
|
+
elsif lti_options[:launch_url].present? # Assumes full URL is provided
|
|
40
|
+
ims_lti_config[:launch_url] = lti_options[:launch_url]
|
|
41
|
+
elsif lti_options[:launch_path].present? # Assumes path is provided
|
|
42
|
+
ims_lti_config[:launch_url] = "#{lti_domain_from_url}#{lti_options[:launch_path]}"
|
|
42
43
|
else
|
|
43
44
|
ims_lti_config[:launch_url] = lti_domain_from_url
|
|
44
45
|
end
|
|
@@ -67,22 +68,22 @@ module LtiXml
|
|
|
67
68
|
end
|
|
68
69
|
|
|
69
70
|
def add_lti_custom_params
|
|
70
|
-
|
|
71
|
+
lti_custom_params.each do |k, v|
|
|
71
72
|
@tc.set_custom_param k, v
|
|
72
73
|
end
|
|
73
74
|
end
|
|
74
75
|
|
|
75
76
|
def add_lti_options
|
|
76
|
-
if
|
|
77
|
-
@tc.set_ext_param(platform, :custom_fields,
|
|
78
|
-
|
|
77
|
+
if lti_options.has_key?(:custom_fields)
|
|
78
|
+
@tc.set_ext_param(platform, :custom_fields, lti_options[:custom_fields])
|
|
79
|
+
lti_options[:custom_fields].each do |k, v|
|
|
79
80
|
@tc.set_ext_param(platform, k, v)
|
|
80
81
|
end
|
|
81
82
|
end
|
|
82
83
|
end
|
|
83
84
|
|
|
84
85
|
def add_lti_nav
|
|
85
|
-
|
|
86
|
+
lti_paths.each do |k, v|
|
|
86
87
|
@tc.set_ext_param(platform, k.to_sym, ext_params(v, k))
|
|
87
88
|
end
|
|
88
89
|
end
|
|
@@ -97,9 +98,22 @@ module LtiXml
|
|
|
97
98
|
k.to_sym,
|
|
98
99
|
host: parsed_request_url,
|
|
99
100
|
launch_handler: :v1p0_launch_path,
|
|
100
|
-
default_auto_launch: false
|
|
101
|
+
default_auto_launch: false,
|
|
102
|
+
tool: tool
|
|
101
103
|
)
|
|
102
104
|
options
|
|
103
105
|
end
|
|
106
|
+
|
|
107
|
+
def lti_options
|
|
108
|
+
PandaPal.lti_tool_options(tool)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def lti_custom_params
|
|
112
|
+
PandaPal.lti_custom_params(tool)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def lti_paths
|
|
116
|
+
PandaPal.lti_paths(tool)
|
|
117
|
+
end
|
|
104
118
|
end
|
|
105
119
|
end
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
module LtiXml
|
|
2
2
|
class BridgePlatform < BasePlatform
|
|
3
3
|
def add_lti_custom_params
|
|
4
|
-
if
|
|
5
|
-
@tc.set_ext_param(platform, :custom_fields,
|
|
4
|
+
if lti_custom_params.any?
|
|
5
|
+
@tc.set_ext_param(platform, :custom_fields, lti_custom_params)
|
|
6
6
|
end
|
|
7
7
|
end
|
|
8
8
|
end
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
module PandaPal
|
|
2
2
|
module LaunchUrlHelpers
|
|
3
|
-
def self.absolute_launch_url(launch_type, host: uri_host, launch_handler: nil, default_auto_launch: false)
|
|
4
|
-
opts = PandaPal.lti_paths[launch_type]
|
|
3
|
+
def self.absolute_launch_url(launch_type, host: uri_host, launch_handler: nil, default_auto_launch: false, tool: PandaPal::DEFAULT_TOOL)
|
|
4
|
+
opts = PandaPal.lti_paths(tool)[launch_type]
|
|
5
5
|
|
|
6
6
|
if launch_handler.nil? && opts.present?
|
|
7
7
|
launch_handler = opts[:message_type] == PandaPal::LtiConstants::MessageTypes::DEEP_LINKING_REQUEST ?
|
|
@@ -18,7 +18,7 @@ module PandaPal
|
|
|
18
18
|
launch_type: launch_type,
|
|
19
19
|
})
|
|
20
20
|
else
|
|
21
|
-
final_url = launch_url(opts, launch_type: launch_type)
|
|
21
|
+
final_url = launch_url(opts, launch_type: launch_type, tool: tool)
|
|
22
22
|
return final_url if URI.parse(final_url).absolute?
|
|
23
23
|
return [host.to_s, final_url].join
|
|
24
24
|
end
|
|
@@ -43,16 +43,16 @@ module PandaPal
|
|
|
43
43
|
opts
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
-
def self.launch_url(opts, launch_type: nil)
|
|
47
|
-
url = launch_route(opts, launch_type: launch_type)
|
|
46
|
+
def self.launch_url(opts, launch_type: nil, tool: PandaPal::DEFAULT_TOOL)
|
|
47
|
+
url = launch_route(opts, launch_type: launch_type, tool: tool)
|
|
48
48
|
url = resolve_url_symbol(url) if url.is_a?(Symbol)
|
|
49
49
|
url
|
|
50
50
|
end
|
|
51
51
|
|
|
52
|
-
def self.launch_route(opts, launch_type: nil)
|
|
52
|
+
def self.launch_route(opts, launch_type: nil, tool: PandaPal::DEFAULT_TOOL)
|
|
53
53
|
if opts.is_a?(Symbol) || opts.is_a?(String)
|
|
54
54
|
launch_type = opts.to_sym
|
|
55
|
-
opts = PandaPal.lti_paths[launch_type] || {}
|
|
55
|
+
opts = PandaPal.lti_paths(tool)[launch_type] || {}
|
|
56
56
|
end
|
|
57
57
|
|
|
58
58
|
if opts[:route_helper_key]
|
|
@@ -79,6 +79,13 @@ module PandaPal
|
|
|
79
79
|
uri.to_s
|
|
80
80
|
end
|
|
81
81
|
|
|
82
|
+
# Query params selecting a tool's config (/v1p0/config, /v1p3/config) - none for
|
|
83
|
+
# the default tool, so its config URLs stay exactly as they've always been.
|
|
84
|
+
def self.tool_config_params(tool)
|
|
85
|
+
tool = PandaPal.normalize_lti_tool!(tool)
|
|
86
|
+
tool == PandaPal::DEFAULT_TOOL ? {} : { tool: tool.to_s }
|
|
87
|
+
end
|
|
88
|
+
|
|
82
89
|
def self.with_uri_host(uri)
|
|
83
90
|
uri = URI.parse(uri) unless uri.is_a?(URI)
|
|
84
91
|
raise "host: param must have a protocal and no path" if uri.path.present?
|
|
@@ -108,24 +115,24 @@ module PandaPal
|
|
|
108
115
|
engine.routes.url_helpers.send(key_bits[1], *arguments, **kwargs)
|
|
109
116
|
end
|
|
110
117
|
|
|
111
|
-
def self.deep_link_redirect_url(request)
|
|
118
|
+
def self.deep_link_redirect_url(request, tool: PandaPal::DEFAULT_TOOL)
|
|
112
119
|
# Try placement-specific configuration first
|
|
113
120
|
launch_type = request.query_parameters['launch_type']&.to_sym
|
|
114
|
-
return global_configuration_url unless launch_type
|
|
121
|
+
return global_configuration_url(tool: tool) unless launch_type
|
|
115
122
|
|
|
116
|
-
placement_config = PandaPal.lti_paths[launch_type]
|
|
117
|
-
return global_configuration_url unless deep_linking_placement?(placement_config)
|
|
123
|
+
placement_config = PandaPal.lti_paths(tool)[launch_type]
|
|
124
|
+
return global_configuration_url(tool: tool) unless deep_linking_placement?(placement_config)
|
|
118
125
|
|
|
119
126
|
# Check for explicit deep_link_redirect_url configuration
|
|
120
127
|
redirect_url = placement_config[:deep_link_redirect_url]
|
|
121
128
|
return normalize_redirect_url(redirect_url) if redirect_url.present?
|
|
122
129
|
|
|
123
130
|
# Fall back to global configuration
|
|
124
|
-
global_configuration_url
|
|
131
|
+
global_configuration_url(tool: tool)
|
|
125
132
|
end
|
|
126
133
|
|
|
127
|
-
def self.global_configuration_url
|
|
128
|
-
redirect_url = PandaPal.
|
|
134
|
+
def self.global_configuration_url(tool: PandaPal::DEFAULT_TOOL)
|
|
135
|
+
redirect_url = PandaPal.lti_tool_options(tool)[:deep_link_redirect_url]
|
|
129
136
|
normalize_redirect_url(redirect_url) if redirect_url.present?
|
|
130
137
|
end
|
|
131
138
|
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module PandaPal
|
|
2
|
+
# Maps an LTI 1.3 client_id (Developer Key), and optionally one deployment of
|
|
3
|
+
# it, to an Organization and to the PandaPal tool it serves.
|
|
4
|
+
#
|
|
5
|
+
# The Organization's own `key` still identifies its default tool. A
|
|
6
|
+
# registration is what lets one Organization be reached through more than one
|
|
7
|
+
# Developer Key (one per tool - see PandaPal.define_tool), or through
|
|
8
|
+
# additional deployments of a key.
|
|
9
|
+
class LtiRegistration < PandaPalRecord
|
|
10
|
+
belongs_to :panda_pal_organization, class_name: 'PandaPal::Organization'
|
|
11
|
+
|
|
12
|
+
validates :tool, presence: true
|
|
13
|
+
validates :client_id, presence: true
|
|
14
|
+
validates :deployment_id, uniqueness: { scope: :client_id }
|
|
15
|
+
validate :tool_is_defined
|
|
16
|
+
|
|
17
|
+
before_validation do
|
|
18
|
+
self.client_id = client_id.to_s if client_id.present?
|
|
19
|
+
self.deployment_id = deployment_id.presence&.to_s
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# A deployment-specific registration wins over one that covers the whole key.
|
|
23
|
+
def self.for_launch(client_id, deployment_id)
|
|
24
|
+
return nil if client_id.blank?
|
|
25
|
+
|
|
26
|
+
scope = where(client_id: client_id.to_s)
|
|
27
|
+
registration = scope.find_by(deployment_id: deployment_id.to_s) if deployment_id.present?
|
|
28
|
+
registration || scope.find_by(deployment_id: nil)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def lti_tool
|
|
32
|
+
tool.to_sym
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def tool_is_defined
|
|
38
|
+
errors.add(:tool, "'#{tool}' is not a defined PandaPal tool") if tool.present? && !PandaPal.lti_tool?(tool)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -53,6 +53,8 @@ module PandaPal
|
|
|
53
53
|
self.settings_panda_pal_super = settings
|
|
54
54
|
end
|
|
55
55
|
|
|
56
|
+
has_many :lti_registrations, class_name: 'PandaPal::LtiRegistration', foreign_key: :panda_pal_organization_id, dependent: :destroy
|
|
57
|
+
|
|
56
58
|
validates :key, uniqueness: { case_sensitive: false }, presence: true
|
|
57
59
|
validates :secret, presence: true
|
|
58
60
|
validates :name, uniqueness: { case_sensitive: false }, presence: true, format: { with: /\A[a-z0-9_]+\z/i }
|
|
@@ -75,15 +75,23 @@ module PandaPal
|
|
|
75
75
|
[ctype, cid]
|
|
76
76
|
end
|
|
77
77
|
|
|
78
|
-
|
|
78
|
+
# `tool:` installs one of the tools registered with PandaPal.define_tool
|
|
79
|
+
# instead of the default one. In LTI 1.3 each tool is its own Developer Key,
|
|
80
|
+
# tracked by an LtiRegistration rather than by this Organization's `key`.
|
|
81
|
+
def _install_lti(host: nil, context: :root_account, version: nil, exists: :error, dedicated_deployment: false, tool: PandaPal::DEFAULT_TOOL)
|
|
79
82
|
raise "Automatically installing this LTI requires Bearcat." unless defined?(Bearcat)
|
|
80
83
|
|
|
81
|
-
|
|
84
|
+
tool = PandaPal.normalize_lti_tool!(tool)
|
|
85
|
+
tool_options = PandaPal.lti_tool_options(tool)
|
|
86
|
+
|
|
87
|
+
version = version || tool_options[:lti_spec_version] || 'v1p3'
|
|
82
88
|
version = version.to_s
|
|
83
89
|
|
|
84
90
|
if (vs = version.split(".")).count > 1
|
|
85
|
-
version = "v#{vs[0]}p#{vs[
|
|
91
|
+
version = "v#{vs[0]}p#{vs[1]}"
|
|
86
92
|
end
|
|
93
|
+
# PandaPal's v1p0 endpoints serve LTI 1.1 as well
|
|
94
|
+
version = 'v1p0' if version == 'v1p1'
|
|
87
95
|
|
|
88
96
|
# TODO Ensure host is actually this LTI
|
|
89
97
|
|
|
@@ -92,24 +100,24 @@ module PandaPal
|
|
|
92
100
|
|
|
93
101
|
if version == 'v1p0'
|
|
94
102
|
existing_installs = _find_existing_installs(context, exists: exists) do |lti|
|
|
95
|
-
lti[:consumer_key] == self.key
|
|
103
|
+
lti[:consumer_key] == self.key && _installed_lti_tool(lti) == tool
|
|
96
104
|
end
|
|
97
105
|
|
|
98
106
|
conf = {
|
|
99
|
-
name:
|
|
100
|
-
description:
|
|
107
|
+
name: tool_options[:title],
|
|
108
|
+
description: tool_options[:description],
|
|
101
109
|
consumer_key: self.key,
|
|
102
110
|
shared_secret: self.secret,
|
|
103
111
|
privacy_level: "public",
|
|
104
112
|
config_type: 'by_url',
|
|
105
|
-
config_url: PandaPal::LaunchUrlHelpers.resolve_route(:v1p0_config_url, host: host),
|
|
113
|
+
config_url: PandaPal::LaunchUrlHelpers.resolve_route(:v1p0_config_url, host: host, **PandaPal::LaunchUrlHelpers.tool_config_params(tool)),
|
|
106
114
|
}
|
|
107
115
|
|
|
108
116
|
api_result = existing_installs[0] ? bearcat_client.send(:"edit_#{ctype}_external_tool", cid, existing_installs[0][:id], conf) : bearcat_client.send(:"create_#{ctype}_external_tool", cid, conf)
|
|
109
117
|
|
|
110
118
|
@new_lti_installation = api_result
|
|
111
119
|
elsif version == 'v1p3'
|
|
112
|
-
ekey = _ensure_lti_v1p3_key(exists: exists, host: host)
|
|
120
|
+
ekey = _ensure_lti_v1p3_key(exists: exists, host: host, tool: tool)
|
|
113
121
|
|
|
114
122
|
existing_installs = _find_existing_installs(context, exists: exists) do |lti|
|
|
115
123
|
# TODO This may not be correct when actually inheriting LTI keys
|
|
@@ -119,7 +127,17 @@ module PandaPal
|
|
|
119
127
|
scope_tool = existing_installs[0]
|
|
120
128
|
|
|
121
129
|
# Don't need to do an update if it already exists - Settings are stored on the LTI Key instead of the installation
|
|
122
|
-
|
|
130
|
+
if tool != PandaPal::DEFAULT_TOOL
|
|
131
|
+
scope_tool ||= bearcat_client.send(:"create_#{ctype}_external_tool", cid, {
|
|
132
|
+
client_id: ekey[:id],
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
lti_registrations.find_or_create_by!(
|
|
136
|
+
tool: tool.to_s,
|
|
137
|
+
client_id: ekey[:id].to_s,
|
|
138
|
+
deployment_id: (scope_tool[:deployment_id].presence&.to_s if dedicated_deployment),
|
|
139
|
+
)
|
|
140
|
+
elsif !scope_tool
|
|
123
141
|
scope_tool = bearcat_client.send(:"create_#{ctype}_external_tool", cid, {
|
|
124
142
|
client_id: ekey[:id],
|
|
125
143
|
})
|
|
@@ -148,20 +166,32 @@ module PandaPal
|
|
|
148
166
|
end
|
|
149
167
|
end
|
|
150
168
|
|
|
151
|
-
def
|
|
152
|
-
|
|
169
|
+
def _installed_lti_tool(installed_tool)
|
|
170
|
+
custom_fields = installed_tool[:custom_fields] || {}
|
|
171
|
+
name = custom_fields[PandaPal::TOOL_CUSTOM_FIELD] || custom_fields[PandaPal::TOOL_CUSTOM_FIELD.to_sym]
|
|
172
|
+
name.presence&.to_sym || PandaPal::DEFAULT_TOOL
|
|
173
|
+
end
|
|
153
174
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
175
|
+
def _ensure_lti_v1p3_key(exists:, host:, tool: PandaPal::DEFAULT_TOOL)
|
|
176
|
+
current_client_id = if tool == PandaPal::DEFAULT_TOOL
|
|
177
|
+
self.key.split('/')[0]
|
|
178
|
+
else
|
|
179
|
+
lti_registrations.where(tool: tool.to_s).pick(:client_id)
|
|
157
180
|
end
|
|
158
|
-
|
|
159
|
-
|
|
181
|
+
|
|
182
|
+
existing_keys = []
|
|
183
|
+
if current_client_id.present?
|
|
184
|
+
existing_keys += bearcat_client.get("api/v1/accounts/self/developer_keys?per_page=100").filter do |devkey|
|
|
185
|
+
devkey[:id].to_s == current_client_id
|
|
186
|
+
end
|
|
187
|
+
existing_keys += bearcat_client.get("api/v1/accounts/self/developer_keys?per_page=100", inherited: true).filter do |devkey|
|
|
188
|
+
devkey[:id].to_s == current_client_id
|
|
189
|
+
end
|
|
160
190
|
end
|
|
161
191
|
|
|
162
192
|
ekey = existing_keys[0]
|
|
163
193
|
|
|
164
|
-
lti_json_url = PandaPal::LaunchUrlHelpers.resolve_route(:v1p3_config_url, host: host)
|
|
194
|
+
lti_json_url = PandaPal::LaunchUrlHelpers.resolve_route(:v1p3_config_url, host: host, **PandaPal::LaunchUrlHelpers.tool_config_params(tool))
|
|
165
195
|
lti_json = JSON.parse(HTTParty.get(lti_json_url, format: :plain).body)
|
|
166
196
|
|
|
167
197
|
valid_redirect_uris = [
|
|
@@ -193,7 +223,7 @@ module PandaPal
|
|
|
193
223
|
# Create New
|
|
194
224
|
ekey = bearcat_client.post("api/lti/accounts/self/developer_keys/tool_configuration", {
|
|
195
225
|
developer_key: {
|
|
196
|
-
name: PandaPal.
|
|
226
|
+
name: PandaPal.lti_tool_options(tool)[:title],
|
|
197
227
|
redirect_uris: valid_redirect_uris.join("\n"),
|
|
198
228
|
},
|
|
199
229
|
tool_configuration: {
|
|
@@ -258,18 +288,21 @@ module PandaPal
|
|
|
258
288
|
install_lti(*args, exists: :replace, **kwargs)
|
|
259
289
|
end
|
|
260
290
|
|
|
261
|
-
def lti_api_configuration(host: nil)
|
|
291
|
+
def lti_api_configuration(host: nil, tool: PandaPal::DEFAULT_TOOL)
|
|
292
|
+
tool = PandaPal.normalize_lti_tool!(tool)
|
|
293
|
+
tool_options = PandaPal.lti_tool_options(tool)
|
|
294
|
+
|
|
262
295
|
PandaPal::LaunchUrlHelpers.with_uri_host(host) do
|
|
263
296
|
domain = PandaPal.lti_properties[:domain] || host.host
|
|
264
|
-
launch_url =
|
|
265
|
-
"#{domain}#{
|
|
266
|
-
|
|
267
|
-
"#{domain}#{
|
|
297
|
+
launch_url = tool_options[:secure_launch_url] ||
|
|
298
|
+
"#{domain}#{tool_options[:secure_launch_path]}" ||
|
|
299
|
+
tool_options[:launch_url] ||
|
|
300
|
+
"#{domain}#{tool_options[:launch_path]}" ||
|
|
268
301
|
domain
|
|
269
302
|
|
|
270
303
|
lti_json = {
|
|
271
|
-
name:
|
|
272
|
-
description:
|
|
304
|
+
name: tool_options[:title],
|
|
305
|
+
description: tool_options[:description],
|
|
273
306
|
domain: host.host,
|
|
274
307
|
url: launch_url,
|
|
275
308
|
consumer_key: self.key,
|
|
@@ -284,17 +317,19 @@ module PandaPal
|
|
|
284
317
|
lti_json = lti_json.with_indifferent_access
|
|
285
318
|
lti_json.merge!(PandaPal.lti_properties)
|
|
286
319
|
|
|
287
|
-
(
|
|
320
|
+
(tool_options[:custom_fields] || []).each do |k, v|
|
|
288
321
|
lti_json[:custom_fields][k] = v
|
|
289
322
|
end
|
|
323
|
+
lti_json[:custom_fields][PandaPal::TOOL_CUSTOM_FIELD] = tool.to_s unless tool == PandaPal::DEFAULT_TOOL
|
|
290
324
|
|
|
291
|
-
PandaPal.lti_paths.each do |k, options|
|
|
325
|
+
PandaPal.lti_paths(tool).each do |k, options|
|
|
292
326
|
options = PandaPal::LaunchUrlHelpers.normalize_lti_launch_desc(options)
|
|
293
327
|
options[:url] = PandaPal::LaunchUrlHelpers.absolute_launch_url(
|
|
294
328
|
k.to_sym,
|
|
295
329
|
host: host,
|
|
296
330
|
launch_handler: :v1p0_launch_path,
|
|
297
|
-
default_auto_launch: false
|
|
331
|
+
default_auto_launch: false,
|
|
332
|
+
tool: tool
|
|
298
333
|
).to_s
|
|
299
334
|
lti_json[k] = options
|
|
300
335
|
end
|
|
@@ -211,6 +211,20 @@ module PandaPal
|
|
|
211
211
|
launch_params['https://www.instructure.com/placement'] || launch_params[:launch_type]
|
|
212
212
|
end
|
|
213
213
|
|
|
214
|
+
# The PandaPal tool (see PandaPal.define_tool) this session was launched through.
|
|
215
|
+
def lti_tool
|
|
216
|
+
(tool.presence || PandaPal::DEFAULT_TOOL).to_sym
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# The main_app route a +launch_type+ launch lands on, per this session's tool.
|
|
220
|
+
def lti_launch_route(launch_type)
|
|
221
|
+
LaunchUrlHelpers.launch_route(launch_type, tool: lti_tool)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def deep_link_redirect_url(request)
|
|
225
|
+
LaunchUrlHelpers.deep_link_redirect_url(request, tool: lti_tool)
|
|
226
|
+
end
|
|
227
|
+
|
|
214
228
|
def custom_lti_params
|
|
215
229
|
@custom_lti_params ||= begin
|
|
216
230
|
# LT 1.3
|
|
@@ -230,7 +244,11 @@ module PandaPal
|
|
|
230
244
|
|
|
231
245
|
def get_lti_cust_param(key, default: :if_not_var)
|
|
232
246
|
nkey = key.to_s.gsub(/^custom_/, '')
|
|
233
|
-
default_value = ->()
|
|
247
|
+
default_value = ->() do
|
|
248
|
+
tool = PandaPal.lti_tool?(lti_tool) ? lti_tool : PandaPal::DEFAULT_TOOL
|
|
249
|
+
params = PandaPal.lti_custom_params(tool)
|
|
250
|
+
params[nkey] || params["custom_#{nkey}"]
|
|
251
|
+
end
|
|
234
252
|
|
|
235
253
|
val = launch_params.dig("https://purl.imsglobal.org/spec/lti/claim/custom", nkey) || launch_params[nkey] || launch_params["custom_#{nkey}"]
|
|
236
254
|
|
|
@@ -253,7 +253,7 @@ end
|
|
|
253
253
|
|
|
254
254
|
Apartment.configure do |config|
|
|
255
255
|
config.excluded_models ||= []
|
|
256
|
-
config.excluded_models |= ['PandaPal::Organization']
|
|
256
|
+
config.excluded_models |= ['PandaPal::Organization', 'PandaPal::LtiRegistration']
|
|
257
257
|
|
|
258
258
|
config.with_multi_server_setup = true unless Rails.env.test?
|
|
259
259
|
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
class CreatePandaPalLtiRegistrations < ActiveRecord::Migration[6.1]
|
|
2
|
+
def change
|
|
3
|
+
create_table :panda_pal_lti_registrations do |t|
|
|
4
|
+
t.references :panda_pal_organization, null: false, index: true
|
|
5
|
+
t.string :tool, null: false
|
|
6
|
+
t.string :client_id, null: false
|
|
7
|
+
t.string :deployment_id
|
|
8
|
+
|
|
9
|
+
t.timestamps null: false
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
add_index :panda_pal_lti_registrations, [:client_id, :deployment_id], unique: true, name: 'index_panda_pal_lti_registrations_on_client_and_deployment'
|
|
13
|
+
end
|
|
14
|
+
end
|