panda_pal 5.18.2 → 5.19.0.beta2
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 +39 -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 +88 -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 +2 -1
- metadata +19 -50
- data/spec/controllers/panda_pal/api_call_controller_spec.rb +0 -25
- data/spec/controllers/panda_pal/lti_v1_p0_controller_spec.rb +0 -110
- data/spec/controllers/panda_pal/lti_v1_p3_controller_spec.rb +0 -108
- data/spec/core/apartment_multidb_spec.rb +0 -166
- data/spec/factories/panda_pal_organizations.rb +0 -9
- data/spec/factories/panda_pal_sessions.rb +0 -6
- data/spec/internal/app/models/account.rb +0 -7
- data/spec/internal/app/models/admin.rb +0 -6
- data/spec/internal/config/database.yml +0 -18
- data/spec/internal/config/routes.rb +0 -5
- data/spec/internal/config/storage.yml +0 -3
- data/spec/internal/db/schema.rb +0 -30
- data/spec/jobs/panda_pal/jobs/grade_passback_job_spec.rb +0 -83
- data/spec/lib/panda_pal/ags_client_spec.rb +0 -385
- data/spec/lib/panda_pal/helpers/secure_headers_spec.rb +0 -77
- data/spec/lib/panda_pal/helpers/session_replacement_spec.rb +0 -72
- data/spec/models/panda_pal/api_call_spec.rb +0 -30
- data/spec/models/panda_pal/organization/settings_validation_spec.rb +0 -179
- data/spec/models/panda_pal/organization/task_scheduling_spec.rb +0 -145
- data/spec/models/panda_pal/organization_spec.rb +0 -50
- data/spec/models/panda_pal/platform_spec.rb +0 -349
- data/spec/models/panda_pal/session_account_roles_spec.rb +0 -182
- data/spec/models/panda_pal/session_spec.rb +0 -202
- data/spec/spec_helper.rb +0 -158
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b8433b20262277ab9a0e66156b4fb2b865919cbfe73701202c163b1dd4c063c9
|
|
4
|
+
data.tar.gz: fba4d18d40156ccf1799c33b4b2a5aed33b71c6e0e772a65df04c586f207503f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 33e70423978afce311e1e0d4a5eecddc17e8814e922bea12e854b1b57135cc336e1c418b6f8d0fcca061c4a25485164dab1468805639d16abd6f1c1951f7b4cb
|
|
7
|
+
data.tar.gz: fa08843f3ad5d268203908c22725c546825102127cce6858c907a84b21e0a58561fab5f1e017b569cb95abd5e42c1715045b699b7790f899e7bbabfbf8f9693c
|
data/README.md
CHANGED
|
@@ -30,9 +30,48 @@ 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
|
+
- **Install hooks:** `before_lti_install` / `after_lti_install` callbacks can read `current_lti_install` (`tool`, `host`, `context`, `version`, `exists`, `dedicated_deployment`, and, in `after_lti_install`, the resulting Canvas `installation`). That lets you install a second tool alongside the default one:
|
|
60
|
+
```ruby
|
|
61
|
+
# app/models/organization_extension.rb
|
|
62
|
+
included do
|
|
63
|
+
after_lti_install :install_translate_tool
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def install_translate_tool
|
|
67
|
+
install = current_lti_install
|
|
68
|
+
return unless install.tool == PandaPal::DEFAULT_TOOL # the nested :translate install runs these callbacks too
|
|
69
|
+
install_lti(**install.to_h.except(:installation), tool: :translate)
|
|
70
|
+
end
|
|
71
|
+
```
|
|
72
|
+
- **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.
|
|
73
|
+
- **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.
|
|
74
|
+
|
|
36
75
|
### LTI 1.3 Configuration
|
|
37
76
|
LTI 1.3 has some additional configuration steps required to setup an LTI:
|
|
38
77
|
|
|
@@ -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 }
|
|
@@ -62,10 +62,21 @@ module PandaPal
|
|
|
62
62
|
module OrgExtension
|
|
63
63
|
extend ActiveSupport::Concern
|
|
64
64
|
|
|
65
|
+
# What an install_lti call is installing, for :lti_install callbacks to read
|
|
66
|
+
# through #current_lti_install. `version` is normalized ('v1p0'/'v1p3').
|
|
67
|
+
# `installation` is the Canvas external tool, set once the install has run
|
|
68
|
+
# (so it's there for after_lti_install, not before_lti_install).
|
|
69
|
+
LtiInstall = Struct.new(:tool, :host, :context, :version, :exists, :dedicated_deployment, :installation, keyword_init: true)
|
|
70
|
+
|
|
65
71
|
included do
|
|
66
72
|
define_model_callbacks :lti_install
|
|
67
73
|
end
|
|
68
74
|
|
|
75
|
+
# The LtiInstall currently running callbacks, or nil outside install_lti.
|
|
76
|
+
# An install started from inside a callback gets its own LtiInstall, and the
|
|
77
|
+
# outer one comes back once it's done.
|
|
78
|
+
attr_reader :current_lti_install
|
|
79
|
+
|
|
69
80
|
def _parse_lti_context(context)
|
|
70
81
|
context = context.to_s
|
|
71
82
|
context = 'account/self' if context == 'root_account'
|
|
@@ -75,41 +86,59 @@ module PandaPal
|
|
|
75
86
|
[ctype, cid]
|
|
76
87
|
end
|
|
77
88
|
|
|
78
|
-
|
|
89
|
+
# `tool:` installs one of the tools registered with PandaPal.define_tool
|
|
90
|
+
# instead of the default one. In LTI 1.3 each tool is its own Developer Key,
|
|
91
|
+
# tracked by an LtiRegistration rather than by this Organization's `key`.
|
|
92
|
+
def _install_lti(host: nil, context: :root_account, version: nil, exists: :error, dedicated_deployment: false, tool: PandaPal::DEFAULT_TOOL)
|
|
93
|
+
outer_install = @current_lti_install
|
|
79
94
|
raise "Automatically installing this LTI requires Bearcat." unless defined?(Bearcat)
|
|
80
95
|
|
|
81
|
-
|
|
96
|
+
tool = PandaPal.normalize_lti_tool!(tool)
|
|
97
|
+
tool_options = PandaPal.lti_tool_options(tool)
|
|
98
|
+
|
|
99
|
+
version = version || tool_options[:lti_spec_version] || 'v1p3'
|
|
82
100
|
version = version.to_s
|
|
83
101
|
|
|
84
102
|
if (vs = version.split(".")).count > 1
|
|
85
|
-
version = "v#{vs[0]}p#{vs[
|
|
103
|
+
version = "v#{vs[0]}p#{vs[1]}"
|
|
86
104
|
end
|
|
105
|
+
# PandaPal's v1p0 endpoints serve LTI 1.1 as well
|
|
106
|
+
version = 'v1p0' if version == 'v1p1'
|
|
87
107
|
|
|
88
108
|
# TODO Ensure host is actually this LTI
|
|
89
109
|
|
|
110
|
+
@current_lti_install = LtiInstall.new(
|
|
111
|
+
tool: tool,
|
|
112
|
+
host: host,
|
|
113
|
+
context: context,
|
|
114
|
+
version: version,
|
|
115
|
+
exists: exists,
|
|
116
|
+
dedicated_deployment: dedicated_deployment,
|
|
117
|
+
)
|
|
118
|
+
|
|
90
119
|
run_callbacks :lti_install do
|
|
91
120
|
ctype, cid = _parse_lti_context(context)
|
|
92
121
|
|
|
93
122
|
if version == 'v1p0'
|
|
94
123
|
existing_installs = _find_existing_installs(context, exists: exists) do |lti|
|
|
95
|
-
lti[:consumer_key] == self.key
|
|
124
|
+
lti[:consumer_key] == self.key && _installed_lti_tool(lti) == tool
|
|
96
125
|
end
|
|
97
126
|
|
|
98
127
|
conf = {
|
|
99
|
-
name:
|
|
100
|
-
description:
|
|
128
|
+
name: tool_options[:title],
|
|
129
|
+
description: tool_options[:description],
|
|
101
130
|
consumer_key: self.key,
|
|
102
131
|
shared_secret: self.secret,
|
|
103
132
|
privacy_level: "public",
|
|
104
133
|
config_type: 'by_url',
|
|
105
|
-
config_url: PandaPal::LaunchUrlHelpers.resolve_route(:v1p0_config_url, host: host),
|
|
134
|
+
config_url: PandaPal::LaunchUrlHelpers.resolve_route(:v1p0_config_url, host: host, **PandaPal::LaunchUrlHelpers.tool_config_params(tool)),
|
|
106
135
|
}
|
|
107
136
|
|
|
108
137
|
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
138
|
|
|
110
139
|
@new_lti_installation = api_result
|
|
111
140
|
elsif version == 'v1p3'
|
|
112
|
-
ekey = _ensure_lti_v1p3_key(exists: exists, host: host)
|
|
141
|
+
ekey = _ensure_lti_v1p3_key(exists: exists, host: host, tool: tool)
|
|
113
142
|
|
|
114
143
|
existing_installs = _find_existing_installs(context, exists: exists) do |lti|
|
|
115
144
|
# TODO This may not be correct when actually inheriting LTI keys
|
|
@@ -119,7 +148,17 @@ module PandaPal
|
|
|
119
148
|
scope_tool = existing_installs[0]
|
|
120
149
|
|
|
121
150
|
# Don't need to do an update if it already exists - Settings are stored on the LTI Key instead of the installation
|
|
122
|
-
|
|
151
|
+
if tool != PandaPal::DEFAULT_TOOL
|
|
152
|
+
scope_tool ||= bearcat_client.send(:"create_#{ctype}_external_tool", cid, {
|
|
153
|
+
client_id: ekey[:id],
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
lti_registrations.find_or_create_by!(
|
|
157
|
+
tool: tool.to_s,
|
|
158
|
+
client_id: ekey[:id].to_s,
|
|
159
|
+
deployment_id: (scope_tool[:deployment_id].presence&.to_s if dedicated_deployment),
|
|
160
|
+
)
|
|
161
|
+
elsif !scope_tool
|
|
123
162
|
scope_tool = bearcat_client.send(:"create_#{ctype}_external_tool", cid, {
|
|
124
163
|
client_id: ekey[:id],
|
|
125
164
|
})
|
|
@@ -137,9 +176,13 @@ module PandaPal
|
|
|
137
176
|
else
|
|
138
177
|
raise "Unrecognized LTI Version #{version}"
|
|
139
178
|
end
|
|
179
|
+
|
|
180
|
+
@current_lti_install.installation = @new_lti_installation
|
|
140
181
|
end
|
|
141
182
|
|
|
142
183
|
save!
|
|
184
|
+
ensure
|
|
185
|
+
@current_lti_install = outer_install
|
|
143
186
|
end
|
|
144
187
|
|
|
145
188
|
def install_lti(*args, **kwargs)
|
|
@@ -148,20 +191,32 @@ module PandaPal
|
|
|
148
191
|
end
|
|
149
192
|
end
|
|
150
193
|
|
|
151
|
-
def
|
|
152
|
-
|
|
194
|
+
def _installed_lti_tool(installed_tool)
|
|
195
|
+
custom_fields = installed_tool[:custom_fields] || {}
|
|
196
|
+
name = custom_fields[PandaPal::TOOL_CUSTOM_FIELD] || custom_fields[PandaPal::TOOL_CUSTOM_FIELD.to_sym]
|
|
197
|
+
name.presence&.to_sym || PandaPal::DEFAULT_TOOL
|
|
198
|
+
end
|
|
153
199
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
200
|
+
def _ensure_lti_v1p3_key(exists:, host:, tool: PandaPal::DEFAULT_TOOL)
|
|
201
|
+
current_client_id = if tool == PandaPal::DEFAULT_TOOL
|
|
202
|
+
self.key.split('/')[0]
|
|
203
|
+
else
|
|
204
|
+
lti_registrations.where(tool: tool.to_s).pick(:client_id)
|
|
157
205
|
end
|
|
158
|
-
|
|
159
|
-
|
|
206
|
+
|
|
207
|
+
existing_keys = []
|
|
208
|
+
if current_client_id.present?
|
|
209
|
+
existing_keys += bearcat_client.get("api/v1/accounts/self/developer_keys?per_page=100").filter do |devkey|
|
|
210
|
+
devkey[:id].to_s == current_client_id
|
|
211
|
+
end
|
|
212
|
+
existing_keys += bearcat_client.get("api/v1/accounts/self/developer_keys?per_page=100", inherited: true).filter do |devkey|
|
|
213
|
+
devkey[:id].to_s == current_client_id
|
|
214
|
+
end
|
|
160
215
|
end
|
|
161
216
|
|
|
162
217
|
ekey = existing_keys[0]
|
|
163
218
|
|
|
164
|
-
lti_json_url = PandaPal::LaunchUrlHelpers.resolve_route(:v1p3_config_url, host: host)
|
|
219
|
+
lti_json_url = PandaPal::LaunchUrlHelpers.resolve_route(:v1p3_config_url, host: host, **PandaPal::LaunchUrlHelpers.tool_config_params(tool))
|
|
165
220
|
lti_json = JSON.parse(HTTParty.get(lti_json_url, format: :plain).body)
|
|
166
221
|
|
|
167
222
|
valid_redirect_uris = [
|
|
@@ -193,7 +248,7 @@ module PandaPal
|
|
|
193
248
|
# Create New
|
|
194
249
|
ekey = bearcat_client.post("api/lti/accounts/self/developer_keys/tool_configuration", {
|
|
195
250
|
developer_key: {
|
|
196
|
-
name: PandaPal.
|
|
251
|
+
name: PandaPal.lti_tool_options(tool)[:title],
|
|
197
252
|
redirect_uris: valid_redirect_uris.join("\n"),
|
|
198
253
|
},
|
|
199
254
|
tool_configuration: {
|
|
@@ -258,18 +313,21 @@ module PandaPal
|
|
|
258
313
|
install_lti(*args, exists: :replace, **kwargs)
|
|
259
314
|
end
|
|
260
315
|
|
|
261
|
-
def lti_api_configuration(host: nil)
|
|
316
|
+
def lti_api_configuration(host: nil, tool: PandaPal::DEFAULT_TOOL)
|
|
317
|
+
tool = PandaPal.normalize_lti_tool!(tool)
|
|
318
|
+
tool_options = PandaPal.lti_tool_options(tool)
|
|
319
|
+
|
|
262
320
|
PandaPal::LaunchUrlHelpers.with_uri_host(host) do
|
|
263
321
|
domain = PandaPal.lti_properties[:domain] || host.host
|
|
264
|
-
launch_url =
|
|
265
|
-
"#{domain}#{
|
|
266
|
-
|
|
267
|
-
"#{domain}#{
|
|
322
|
+
launch_url = tool_options[:secure_launch_url] ||
|
|
323
|
+
"#{domain}#{tool_options[:secure_launch_path]}" ||
|
|
324
|
+
tool_options[:launch_url] ||
|
|
325
|
+
"#{domain}#{tool_options[:launch_path]}" ||
|
|
268
326
|
domain
|
|
269
327
|
|
|
270
328
|
lti_json = {
|
|
271
|
-
name:
|
|
272
|
-
description:
|
|
329
|
+
name: tool_options[:title],
|
|
330
|
+
description: tool_options[:description],
|
|
273
331
|
domain: host.host,
|
|
274
332
|
url: launch_url,
|
|
275
333
|
consumer_key: self.key,
|
|
@@ -284,17 +342,19 @@ module PandaPal
|
|
|
284
342
|
lti_json = lti_json.with_indifferent_access
|
|
285
343
|
lti_json.merge!(PandaPal.lti_properties)
|
|
286
344
|
|
|
287
|
-
(
|
|
345
|
+
(tool_options[:custom_fields] || []).each do |k, v|
|
|
288
346
|
lti_json[:custom_fields][k] = v
|
|
289
347
|
end
|
|
348
|
+
lti_json[:custom_fields][PandaPal::TOOL_CUSTOM_FIELD] = tool.to_s unless tool == PandaPal::DEFAULT_TOOL
|
|
290
349
|
|
|
291
|
-
PandaPal.lti_paths.each do |k, options|
|
|
350
|
+
PandaPal.lti_paths(tool).each do |k, options|
|
|
292
351
|
options = PandaPal::LaunchUrlHelpers.normalize_lti_launch_desc(options)
|
|
293
352
|
options[:url] = PandaPal::LaunchUrlHelpers.absolute_launch_url(
|
|
294
353
|
k.to_sym,
|
|
295
354
|
host: host,
|
|
296
355
|
launch_handler: :v1p0_launch_path,
|
|
297
|
-
default_auto_launch: false
|
|
356
|
+
default_auto_launch: false,
|
|
357
|
+
tool: tool
|
|
298
358
|
).to_s
|
|
299
359
|
lti_json[k] = options
|
|
300
360
|
end
|