panda_pal 5.18.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1cfa6473dc16edac94d626e45b19ffba9fad24f213a8696808237685a20a4131
4
- data.tar.gz: 9586403513860b1c18ac4203d468fd80ac48b7e13a4749ef7b75992e7b64241d
3
+ metadata.gz: 36f3440bb369f482a622cca1b58dbe3857bd74749d53776be10e6b99e8bf3f79
4
+ data.tar.gz: 02c42332159e06ae1778d8155c2a58d230ae24dc82a5f1e0c6027aa7e0a9f274
5
5
  SHA512:
6
- metadata.gz: 945a38a43d4107b62c3f2d9f2097ebf5345157d7c30fa28afccf177c96f71894b08b84cac3612c0954ebccf73b51425fc2d7c7fefab509578bbb4f83be5ce403
7
- data.tar.gz: 6dac2d051c4f718db105efa8b37d43de205c559f05800b07a0d671d983db9426f9831b80aae1cffa408da3fa57feb1aa5c2e39b41c3ff181c801d60f5da0604d
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(:"#{LaunchUrlHelpers.launch_route(params[:launch_type])}_url", route_context: main_app)
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
- platform = PandaPal.lti_options.delete(:platform) || 'canvas.instructure.com'
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(:"#{LaunchUrlHelpers.launch_route(ltype)}_url", route_context: main_app)
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 = LaunchUrlHelpers.deep_link_redirect_url(request)
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
- platform = PandaPal.lti_options.delete(:platform) || 'canvas.instructure.com'
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: PandaPal.lti_options[:title],
116
+ title: tool_options[:title],
108
117
  scopes: PandaPal.lti_scopes,
109
118
  public_jwk_url: v1p3_public_jwks_url,
110
- description: PandaPal.lti_options[:description] || 'PandaPal LTI',
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 PandaPal.lti_options[:launch_url] === false
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: PandaPal.lti_options[:title]}
34
- if PandaPal.lti_options[:secure_launch_url].present? # Assumes full URL is provided
35
- ims_lti_config[:secure_launch_url] = PandaPal.lti_options[:secure_launch_url]
36
- elsif PandaPal.lti_options[:secure_launch_path].present? # Assumes path is provided
37
- ims_lti_config[:secure_launch_url] = "#{lti_domain_from_url}#{PandaPal.lti_options[:secure_launch_path]}"
38
- elsif PandaPal.lti_options[:launch_url].present? # Assumes full URL is provided
39
- ims_lti_config[:launch_url] = PandaPal.lti_options[:launch_url]
40
- elsif PandaPal.lti_options[:launch_path].present? # Assumes path is provided
41
- ims_lti_config[:launch_url] = "#{lti_domain_from_url}#{PandaPal.lti_options[:launch_path]}"
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
- PandaPal.lti_custom_params.each do |k, v|
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 PandaPal.lti_options.has_key?(:custom_fields)
77
- @tc.set_ext_param(platform, :custom_fields, PandaPal.lti_options[:custom_fields])
78
- PandaPal.lti_options[:custom_fields].each do |k, v|
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
- PandaPal.lti_paths.each do |k, v|
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 PandaPal.lti_custom_params.any?
5
- @tc.set_ext_param(platform, :custom_fields, PandaPal.lti_custom_params)
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.lti_options[:deep_link_redirect_url]
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
- def _install_lti(host: nil, context: :root_account, version: nil, exists: :error, dedicated_deployment: false)
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
- version = version || PandaPal.lti_options[:lti_spec_version] || 'v1p3'
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[0]}"
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: PandaPal.lti_options[:title],
100
- description: PandaPal.lti_options[: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
- unless scope_tool
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 _ensure_lti_v1p3_key(exists:, host:)
152
- current_client_id = self.key.split('/')[0]
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
- existing_keys = []
155
- existing_keys += bearcat_client.get("api/v1/accounts/self/developer_keys?per_page=100").filter do |devkey|
156
- devkey[:id].to_s == current_client_id
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
- existing_keys += bearcat_client.get("api/v1/accounts/self/developer_keys?per_page=100", inherited: true).filter do |devkey|
159
- devkey[:id].to_s == current_client_id
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.lti_options[:title],
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 = PandaPal.lti_options[:secure_launch_url] ||
265
- "#{domain}#{PandaPal.lti_options[:secure_launch_path]}" ||
266
- PandaPal.lti_options[:launch_url] ||
267
- "#{domain}#{PandaPal.lti_options[:launch_path]}" ||
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: PandaPal.lti_options[:title],
272
- description: PandaPal.lti_options[: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
- (PandaPal.lti_options[:custom_fields] || []).each do |k, v|
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 = ->() { PandaPal.lti_custom_params[nkey] || PandaPal.lti_custom_params["custom_#{nkey}"] }
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
@@ -0,0 +1,5 @@
1
+ class AddToolToPandaPalSessions < ActiveRecord::Migration[6.1]
2
+ def change
3
+ add_column :panda_pal_sessions, :tool, :string
4
+ end
5
+ end
@@ -45,7 +45,9 @@ module PandaPal::Concerns
45
45
  raise "Ability class needs to set @rails_session or @controller to use this feature"
46
46
  end
47
47
 
48
- @rails_session ||= @controller.session unless defined?(@rails_session)
48
+ @rails_session ||= @controller&.session
49
+
50
+ @rails_session
49
51
  end
50
52
 
51
53
  # Delegate legacy AbilityHelper methods to panda_pal_session. TODO Should such direct usage be considered deprecated?