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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +39 -0
  3. data/app/controllers/panda_pal/lti_v1_p0_controller.rb +11 -4
  4. data/app/controllers/panda_pal/lti_v1_p3_controller.rb +17 -8
  5. data/app/lib/lti_xml/base_platform.rb +32 -18
  6. data/app/lib/lti_xml/bridge_platform.rb +2 -2
  7. data/app/lib/panda_pal/launch_url_helpers.rb +21 -14
  8. data/app/models/panda_pal/lti_registration.rb +41 -0
  9. data/app/models/panda_pal/organization.rb +2 -0
  10. data/app/models/panda_pal/platform/canvas.rb +88 -28
  11. data/app/models/panda_pal/session.rb +19 -1
  12. data/config/initializers/apartment.rb +1 -1
  13. data/db/migrate/20261006000000_create_panda_pal_lti_registrations.rb +14 -0
  14. data/db/migrate/20261006000001_add_tool_to_panda_pal_sessions.rb +5 -0
  15. data/lib/panda_pal/helpers/controller_helper.rb +20 -1
  16. data/lib/panda_pal/helpers/route_helper.rb +8 -1
  17. data/lib/panda_pal/helpers/session_replacement.rb +2 -2
  18. data/lib/panda_pal/version.rb +1 -1
  19. data/lib/panda_pal.rb +82 -10
  20. data/panda_pal.gemspec +2 -1
  21. metadata +19 -50
  22. data/spec/controllers/panda_pal/api_call_controller_spec.rb +0 -25
  23. data/spec/controllers/panda_pal/lti_v1_p0_controller_spec.rb +0 -110
  24. data/spec/controllers/panda_pal/lti_v1_p3_controller_spec.rb +0 -108
  25. data/spec/core/apartment_multidb_spec.rb +0 -166
  26. data/spec/factories/panda_pal_organizations.rb +0 -9
  27. data/spec/factories/panda_pal_sessions.rb +0 -6
  28. data/spec/internal/app/models/account.rb +0 -7
  29. data/spec/internal/app/models/admin.rb +0 -6
  30. data/spec/internal/config/database.yml +0 -18
  31. data/spec/internal/config/routes.rb +0 -5
  32. data/spec/internal/config/storage.yml +0 -3
  33. data/spec/internal/db/schema.rb +0 -30
  34. data/spec/jobs/panda_pal/jobs/grade_passback_job_spec.rb +0 -83
  35. data/spec/lib/panda_pal/ags_client_spec.rb +0 -385
  36. data/spec/lib/panda_pal/helpers/secure_headers_spec.rb +0 -77
  37. data/spec/lib/panda_pal/helpers/session_replacement_spec.rb +0 -72
  38. data/spec/models/panda_pal/api_call_spec.rb +0 -30
  39. data/spec/models/panda_pal/organization/settings_validation_spec.rb +0 -179
  40. data/spec/models/panda_pal/organization/task_scheduling_spec.rb +0 -145
  41. data/spec/models/panda_pal/organization_spec.rb +0 -50
  42. data/spec/models/panda_pal/platform_spec.rb +0 -349
  43. data/spec/models/panda_pal/session_account_roles_spec.rb +0 -182
  44. data/spec/models/panda_pal/session_spec.rb +0 -202
  45. data/spec/spec_helper.rb +0 -158
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: eb0e4fb14c2e0d6b9e35ec6dd8e865c428d903ecea13870eaaf573c3799af975
4
- data.tar.gz: 93e7c30bd6501db96b4a5cadd4b5d412b67992f292d39959496f3962c38f0963
3
+ metadata.gz: b8433b20262277ab9a0e66156b4fb2b865919cbfe73701202c163b1dd4c063c9
4
+ data.tar.gz: fba4d18d40156ccf1799c33b4b2a5aed33b71c6e0e772a65df04c586f207503f
5
5
  SHA512:
6
- metadata.gz: 4ed68a456f64341cd6627e08f6b24b7936e75b39b077d2d458695db7e7be58ddd723cfcf44b095001c411b66993971b042a33d8bc62f7f2aa07d95e36773568d
7
- data.tar.gz: '08ccb27dd49356d5a51d020cd93cb86626ef1f108b99fcc75f38cd2a147fbc28859570cdd7dbafe7ef37d74b7ca08aa0a09793bc3d6b6e1ebac5c64158aec9aa'
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(:"#{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 }
@@ -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
- def _install_lti(host: nil, context: :root_account, version: nil, exists: :error, dedicated_deployment: false)
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
- version = version || PandaPal.lti_options[:lti_spec_version] || 'v1p3'
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[0]}"
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: PandaPal.lti_options[:title],
100
- description: PandaPal.lti_options[: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
- unless scope_tool
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 _ensure_lti_v1p3_key(exists:, host:)
152
- current_client_id = self.key.split('/')[0]
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
- 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
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
- 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
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.lti_options[:title],
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 = 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]}" ||
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: PandaPal.lti_options[:title],
272
- description: PandaPal.lti_options[: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
- (PandaPal.lti_options[:custom_fields] || []).each do |k, v|
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