panda_pal 5.19.0 → 5.19.2
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 +152 -703
- data/app/models/panda_pal/organization.rb +1 -1
- data/app/models/panda_pal/platform/canvas.rb +1 -1
- data/app/models/panda_pal/platform.rb +1 -1
- data/config/initializers/apartment.rb +18 -12
- data/db/migrate/20171205194657_remove_old_organization_settings.rb +1 -0
- data/docs/action-cable.md +60 -0
- data/docs/api-calls.md +39 -0
- data/docs/architecture.md +210 -0
- data/docs/before-you-build.md +83 -0
- data/docs/configuration.md +120 -0
- data/docs/deep-linking.md +355 -0
- data/docs/development.md +31 -0
- data/docs/frontend.md +129 -0
- data/docs/grade-passback.md +27 -0
- data/docs/installation.md +129 -0
- data/docs/multi-tenancy.md +117 -0
- data/docs/multiple-tools.md +73 -0
- data/docs/organizations.md +159 -0
- data/docs/roles-and-abilities.md +82 -0
- data/docs/routing-and-launches.md +108 -0
- data/docs/security.md +60 -0
- data/docs/sessions.md +150 -0
- data/docs/task-scheduling.md +49 -0
- data/docs/upgrading.md +69 -0
- data/lib/panda_pal/version.rb +1 -1
- data/panda_pal.gemspec +1 -1
- metadata +21 -2
- /data/{AGS.md → docs/ags.md} +0 -0
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
# Deep Linking Configuration
|
|
2
|
+
|
|
3
|
+
This document explains how to configure and implement LTI Deep Linking in your PandaPal application.
|
|
4
|
+
|
|
5
|
+
## Configuration Options
|
|
6
|
+
|
|
7
|
+
### 1. Placement-Specific Configuration (Recommended)
|
|
8
|
+
|
|
9
|
+
Configure redirect URLs for specific deep linking placements:
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
# config/initializers/lti.rb
|
|
13
|
+
|
|
14
|
+
# Content selection with explicit redirect URL
|
|
15
|
+
PandaPal.stage_placement(:content_selection, {
|
|
16
|
+
deep_linking: true,
|
|
17
|
+
deep_link_redirect_url: :content_selection_url,
|
|
18
|
+
text: 'Select Content',
|
|
19
|
+
enabled: true
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
# Module selection using default placement route
|
|
23
|
+
PandaPal.stage_placement(:module_selection, {
|
|
24
|
+
deep_linking: true,
|
|
25
|
+
text: 'Select Module',
|
|
26
|
+
enabled: true,
|
|
27
|
+
url: :module_selection_url # Will be used as fallback redirect
|
|
28
|
+
})
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### 2. Global Configuration
|
|
32
|
+
|
|
33
|
+
Set a global default for all deep linking requests:
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
# config/initializers/lti.rb
|
|
37
|
+
|
|
38
|
+
PandaPal.lti_options = {
|
|
39
|
+
title: 'My LTI Tool',
|
|
40
|
+
deep_link_redirect_url: :deep_link_selection_url
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Implementation
|
|
45
|
+
|
|
46
|
+
### 1. Create Deep Linking Controller
|
|
47
|
+
|
|
48
|
+
Create a controller in your main application to handle content selection:
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
# app/controllers/content_selection_controller.rb
|
|
52
|
+
class ContentSelectionController < ApplicationController
|
|
53
|
+
include PandaPal::Helpers::ControllerHelper
|
|
54
|
+
include PandaPal::DeepLinkingHelpers
|
|
55
|
+
|
|
56
|
+
def index
|
|
57
|
+
render component: 'ContentSelection', layout: 'application', prerender: false
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def create
|
|
61
|
+
# Handle the content selection and build response
|
|
62
|
+
selected_content = params[:selected_content_ids]
|
|
63
|
+
content_items = build_content_items_for_selection(selected_content)
|
|
64
|
+
|
|
65
|
+
# Render the deep linking response (this will redirect back to Canvas)
|
|
66
|
+
render_deep_linking_response(content_items)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def build_content_items_for_selection(selected_ids)
|
|
72
|
+
content_items = []
|
|
73
|
+
|
|
74
|
+
selected_ids.each do |content_id|
|
|
75
|
+
content = find_content(content_id)
|
|
76
|
+
|
|
77
|
+
content_items << build_link_content_item(
|
|
78
|
+
url: panda_pal.v1p3_resource_link_request_url,
|
|
79
|
+
title: content.title,
|
|
80
|
+
text: content.description,
|
|
81
|
+
icon: content.icon_url,
|
|
82
|
+
thumbnail: content.thumbnail_url,
|
|
83
|
+
custom: {
|
|
84
|
+
content_id: content.id,
|
|
85
|
+
launch_type: :content_launch # REQUIRED: Used for routing in resource_link_request
|
|
86
|
+
}
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
content_items
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### 2. Add Routes
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
# config/routes.rb
|
|
99
|
+
Rails.application.routes.draw do
|
|
100
|
+
get '/content-selection', to: 'content_selection#index', as: :content_selection
|
|
101
|
+
post '/content-selection', to: 'content_selection#create'
|
|
102
|
+
|
|
103
|
+
# For simple single content selection
|
|
104
|
+
post '/quiz-selection', to: 'quiz_selection#create', as: :quiz_selection
|
|
105
|
+
|
|
106
|
+
# Add launch routes for your content types
|
|
107
|
+
lti_nav content_launch: 'content#launch'
|
|
108
|
+
lti_nav quiz_launch: 'quiz#launch'
|
|
109
|
+
end
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### 3. Create Views
|
|
113
|
+
|
|
114
|
+
#### ERB Template Example
|
|
115
|
+
|
|
116
|
+
```erb
|
|
117
|
+
<!-- app/views/content_selection/index.html.erb -->
|
|
118
|
+
<h2>Select Content to Add</h2>
|
|
119
|
+
|
|
120
|
+
<%= form_with url: content_selection_path, method: :post do |form| %>
|
|
121
|
+
<% @available_content.each do |content| %>
|
|
122
|
+
<div class="content-item">
|
|
123
|
+
<%= form.check_box :selected_content_ids, { multiple: true }, content.id, nil %>
|
|
124
|
+
<%= form.label "selected_content_ids_#{content.id}", content.title %>
|
|
125
|
+
<p><%= content.description %></p>
|
|
126
|
+
</div>
|
|
127
|
+
<% end %>
|
|
128
|
+
|
|
129
|
+
<%= form.submit "Add Selected Content" %>
|
|
130
|
+
<% end %>
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
#### React Component Example
|
|
134
|
+
|
|
135
|
+
```jsx
|
|
136
|
+
// app/javascript/components/ContentSelection.jsx
|
|
137
|
+
import React, { useState } from 'react';
|
|
138
|
+
|
|
139
|
+
const ContentSelection = ({ availableContent, submitUrl, csrfToken }) => {
|
|
140
|
+
const [selectedContentIds, setSelectedContentIds] = useState([]);
|
|
141
|
+
|
|
142
|
+
const handleContentToggle = (contentId) => {
|
|
143
|
+
setSelectedContentIds(prev =>
|
|
144
|
+
prev.includes(contentId)
|
|
145
|
+
? prev.filter(id => id !== contentId)
|
|
146
|
+
: [...prev, contentId]
|
|
147
|
+
);
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const handleSubmit = (e) => {
|
|
151
|
+
e.preventDefault();
|
|
152
|
+
|
|
153
|
+
if (selectedContentIds.length === 0) {
|
|
154
|
+
alert('Please select at least one content item');
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Submit the form programmatically
|
|
159
|
+
e.target.submit();
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
return (
|
|
163
|
+
<div className="content-selection">
|
|
164
|
+
<h2>Select Content to Add</h2>
|
|
165
|
+
|
|
166
|
+
<form action={submitUrl} method="POST" onSubmit={handleSubmit}>
|
|
167
|
+
<input type="hidden" name="authenticity_token" value={csrfToken} />
|
|
168
|
+
|
|
169
|
+
{availableContent.map(content => (
|
|
170
|
+
<div key={content.id} className="content-item">
|
|
171
|
+
<label>
|
|
172
|
+
<input
|
|
173
|
+
type="checkbox"
|
|
174
|
+
checked={selectedContentIds.includes(content.id)}
|
|
175
|
+
onChange={() => handleContentToggle(content.id)}
|
|
176
|
+
/>
|
|
177
|
+
<span>{content.title}</span>
|
|
178
|
+
<p>{content.description}</p>
|
|
179
|
+
</label>
|
|
180
|
+
</div>
|
|
181
|
+
))}
|
|
182
|
+
|
|
183
|
+
{selectedContentIds.map(id => (
|
|
184
|
+
<input key={id} type="hidden" name="selected_content_ids[]" value={id} />
|
|
185
|
+
))}
|
|
186
|
+
|
|
187
|
+
<button type="submit" disabled={selectedContentIds.length === 0}>
|
|
188
|
+
Add {selectedContentIds.length} Selected Item{selectedContentIds.length !== 1 ? 's' : ''}
|
|
189
|
+
</button>
|
|
190
|
+
</form>
|
|
191
|
+
</div>
|
|
192
|
+
);
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
export default ContentSelection;
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
## Helper Methods
|
|
199
|
+
|
|
200
|
+
PandaPal provides several helper methods to make deep linking easier:
|
|
201
|
+
|
|
202
|
+
### Content Item Builders
|
|
203
|
+
|
|
204
|
+
**IMPORTANT: All content items MUST include `launch_type` in the `custom` hash for proper routing.**
|
|
205
|
+
|
|
206
|
+
```ruby
|
|
207
|
+
# Link content item - REQUIRED: launch_type in custom hash
|
|
208
|
+
build_link_content_item(
|
|
209
|
+
url: panda_pal.v1p3_resource_link_request_url,
|
|
210
|
+
title: 'My Content',
|
|
211
|
+
text: 'Description of the content',
|
|
212
|
+
icon: 'https://example.com/icon.png',
|
|
213
|
+
thumbnail: 'https://example.com/thumb.jpg',
|
|
214
|
+
custom: {
|
|
215
|
+
content_id: 123,
|
|
216
|
+
launch_type: :content_launch # REQUIRED for proper routing
|
|
217
|
+
}
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
# File content item - REQUIRED: launch_type in custom hash
|
|
221
|
+
build_file_content_item(
|
|
222
|
+
url: 'https://example.com/files/document.pdf',
|
|
223
|
+
title: 'Important Document',
|
|
224
|
+
text: 'A very important PDF document',
|
|
225
|
+
media_type: 'application/pdf',
|
|
226
|
+
icon: 'https://example.com/pdf-icon.png',
|
|
227
|
+
custom: {
|
|
228
|
+
document_id: 456,
|
|
229
|
+
launch_type: :document_launch # REQUIRED for proper routing
|
|
230
|
+
}
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
# HTML content item - REQUIRED: launch_type in custom hash
|
|
234
|
+
build_html_content_item(
|
|
235
|
+
html: '<h1>Hello World</h1><p>This is embedded HTML content.</p>',
|
|
236
|
+
title: 'Custom HTML Content',
|
|
237
|
+
text: 'Custom HTML that will be embedded',
|
|
238
|
+
custom: {
|
|
239
|
+
template_id: 789,
|
|
240
|
+
launch_type: :html_launch # REQUIRED for proper routing
|
|
241
|
+
}
|
|
242
|
+
)
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
### Launch Type Requirement
|
|
246
|
+
|
|
247
|
+
The `launch_type` parameter in the `custom` hash is **mandatory** because:
|
|
248
|
+
|
|
249
|
+
1. When Canvas launches your content, it calls the `resource_link_request` action
|
|
250
|
+
2. This action uses the `launch_type` from custom parameters to determine which route to redirect to
|
|
251
|
+
3. Without `launch_type`, the launch will fail to route properly
|
|
252
|
+
|
|
253
|
+
Make sure to:
|
|
254
|
+
- Define corresponding `lti_nav` routes for each `launch_type`
|
|
255
|
+
- Use descriptive, unique `launch_type` values (e.g., `:quiz_launch`, `:assignment_launch`, `:content_launch`)
|
|
256
|
+
|
|
257
|
+
### Response Builder
|
|
258
|
+
|
|
259
|
+
```ruby
|
|
260
|
+
# Simply pass content items to render the deep linking response
|
|
261
|
+
# The helper will automatically use current_session, current_organization, and build custom data
|
|
262
|
+
render_deep_linking_response(content_items)
|
|
263
|
+
|
|
264
|
+
# You can also pass optional custom data if needed
|
|
265
|
+
render_deep_linking_response(content_items, custom_data: {
|
|
266
|
+
selection_time: Time.now,
|
|
267
|
+
selected_by_user_id: current_user.id
|
|
268
|
+
})
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
## Priority Order
|
|
272
|
+
|
|
273
|
+
The deep linking redirect URL is determined in this order:
|
|
274
|
+
|
|
275
|
+
1. **Placement-specific** `deep_link_redirect_url` (highest priority)
|
|
276
|
+
2. **Global** `deep_link_redirect_url` from `PandaPal.lti_options`
|
|
277
|
+
|
|
278
|
+
If neither is configured, an error will be returned.
|
|
279
|
+
|
|
280
|
+
## Auto-Submit Form
|
|
281
|
+
|
|
282
|
+
The `render_deep_linking_response` method automatically builds the JWT response and renders the PandaPal auto-submit form partial (`panda_pal/partials/auto_submit_form`) which will immediately redirect the user back to Canvas with the selected content. All the necessary session data, organization info, and JWT signing is handled automatically.
|
|
283
|
+
|
|
284
|
+
## Example: Simple Single Content Selection
|
|
285
|
+
|
|
286
|
+
For a simple case where you always return the same content:
|
|
287
|
+
|
|
288
|
+
```ruby
|
|
289
|
+
class QuizController < ApplicationController
|
|
290
|
+
include PandaPal::Helpers::ControllerHelper
|
|
291
|
+
include PandaPal::DeepLinkingHelpers
|
|
292
|
+
|
|
293
|
+
def create
|
|
294
|
+
# Build a single content item
|
|
295
|
+
content_item = build_link_content_item(
|
|
296
|
+
url: panda_pal.v1p3_resource_link_request_url,
|
|
297
|
+
title: "Quiz: #{@quiz.title}",
|
|
298
|
+
text: @quiz.description,
|
|
299
|
+
custom: {
|
|
300
|
+
quiz_id: params[:quiz_id],
|
|
301
|
+
launch_type: :quiz_launch # REQUIRED for proper routing
|
|
302
|
+
}
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
# Render response immediately
|
|
306
|
+
render_deep_linking_response([content_item])
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
## Complete Example: Group Quiz Selection
|
|
312
|
+
|
|
313
|
+
```ruby
|
|
314
|
+
class GroupQuizSelectionController < ApplicationController
|
|
315
|
+
include PandaPal::DeepLinkingHelpers
|
|
316
|
+
|
|
317
|
+
def create
|
|
318
|
+
group_quiz = GroupQuiz.find(params[:group_quiz_id])
|
|
319
|
+
|
|
320
|
+
content_item = build_link_content_item(
|
|
321
|
+
url: panda_pal.v1p3_resource_link_request_url,
|
|
322
|
+
title: "Group Quiz: #{group_quiz.title}",
|
|
323
|
+
text: group_quiz.description,
|
|
324
|
+
custom: {
|
|
325
|
+
group_quiz_id: group_quiz.id,
|
|
326
|
+
launch_type: :deep_link_group_quiz # REQUIRED for proper routing
|
|
327
|
+
}
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
render_deep_linking_response([content_item])
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
And the corresponding route configuration:
|
|
336
|
+
|
|
337
|
+
```ruby
|
|
338
|
+
# config/routes.rb
|
|
339
|
+
Rails.application.routes.draw do
|
|
340
|
+
# Deep linking selection routes
|
|
341
|
+
post '/group-quiz-selection', to: 'group_quiz_selection#create', as: :group_quiz_selection
|
|
342
|
+
|
|
343
|
+
# Launch routes (required for the launch_type values used above)
|
|
344
|
+
lti_nav deep_link_group_quiz: 'group_quiz#launch'
|
|
345
|
+
end
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
## Troubleshooting
|
|
349
|
+
|
|
350
|
+
- **Missing launch_type error**: Ensure all content items include `launch_type` in the `custom` hash
|
|
351
|
+
- **Route not found after launch**: Make sure you have corresponding `lti_nav` routes for each `launch_type`
|
|
352
|
+
- Ensure your deep linking URLs are properly configured in the LTI placement
|
|
353
|
+
- Make sure to include the `PandaPal::DeepLinkingHelpers` module in your controllers
|
|
354
|
+
- The auto-submit form requires JavaScript to be enabled in the browser
|
|
355
|
+
- Check that your content URLs are accessible and properly formatted
|
data/docs/development.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Developing PandaPal.
|
|
2
|
+
|
|
3
|
+
PandaPal is a gem, so it has no app of its own to run. The specs use the
|
|
4
|
+
[Combustion](https://github.com/pat/combustion) gem's Rails app in `spec/internal`, which only has a
|
|
5
|
+
`test` database config.
|
|
6
|
+
|
|
7
|
+
## Requirements.
|
|
8
|
+
|
|
9
|
+
- Ruby 3.0 or later. `mise.toml` pins the local version.
|
|
10
|
+
- Postgres and Redis. Run them however you like, for example `docker run postgres:17` and
|
|
11
|
+
`docker run redis`.
|
|
12
|
+
|
|
13
|
+
## Running specs.
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
bundle install
|
|
17
|
+
bundle exec rspec spec # Default bundle (Rails version depends on your Ruby)
|
|
18
|
+
bundle exec appraisal install # Generate gemfiles/rails_*.gemfile
|
|
19
|
+
bundle exec appraisal rspec spec # Every Rails version your Ruby supports
|
|
20
|
+
COVERAGE=1 bundle exec rspec spec # SimpleCov report in coverage/
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
CI runs Ruby 3.0 and 3.4 against Rails 6.1, 7.1, and 8.0, wherever the Ruby version supports that
|
|
24
|
+
Rails version. See `Appraisals` and `.github/workflows/ci.yml`.
|
|
25
|
+
|
|
26
|
+
`Gemfile.lock` is gitignored on purpose, because this is a library.
|
|
27
|
+
|
|
28
|
+
## Specs that change global config.
|
|
29
|
+
|
|
30
|
+
Tag specs that call `PandaPal.define_tool`, `stage_placement`, or other global LTI config with
|
|
31
|
+
`:isolated_lti_config`, so the changes don't leak into other specs.
|
data/docs/frontend.md
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# Frontend with `@inst_proserv/toolkit`.
|
|
2
|
+
|
|
3
|
+
`@inst_proserv/toolkit` is the CustomDev frontend package for PandaPal apps. It reads the session
|
|
4
|
+
from the page, sends it with API calls, mints limited-use panda tokens in the browser, and opens
|
|
5
|
+
authenticated ActionCable connections. This page covers the parts that talk to PandaPal. The
|
|
6
|
+
package also has InstUI-based components (tables, trays, alerts, prompts, and more).
|
|
7
|
+
|
|
8
|
+
Modules are imported by path, for example `@inst_proserv/toolkit/cable`. Many apps alias
|
|
9
|
+
`@toolkit` to `@inst_proserv/toolkit` in webpack and `tsconfig.json`.
|
|
10
|
+
|
|
11
|
+
## Passing the environment to the page.
|
|
12
|
+
|
|
13
|
+
Toolkit loads app-wide values from a `js-env` meta tag into `env`
|
|
14
|
+
(`@inst_proserv/toolkit/environment`). It also reads `csrf_token` from Rails' `csrf-token` meta tag.
|
|
15
|
+
|
|
16
|
+
```erb
|
|
17
|
+
<%# app/views/layouts/application.html.erb %>
|
|
18
|
+
<%= csrf_meta_tags %>
|
|
19
|
+
<meta name="js-env" content="<%= js_env.to_json %>">
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
PandaPal doesn't define `js_env`. Each app defines its own helper. A typical one:
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
# app/controllers/application_controller.rb
|
|
26
|
+
def js_env
|
|
27
|
+
@js_env ||= {
|
|
28
|
+
csrf_token: form_authenticity_token,
|
|
29
|
+
organization_id: current_organization&.id,
|
|
30
|
+
base_path: url_for(path: nil, only_path: true),
|
|
31
|
+
canvas_url: current_organization&.canvas_url,
|
|
32
|
+
}.tap do |env|
|
|
33
|
+
if current_session.present?
|
|
34
|
+
env[:session_key] = current_session.session_key # Full-key panda token
|
|
35
|
+
env[:launch_params] = current_session.launch_params
|
|
36
|
+
# Lets toolkit components talk to the Canvas window via postMessage
|
|
37
|
+
env[:post_message_token] = current_session.get_lti_cust_param(:canvas_post_message_token)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
helper_method :js_env
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
These are the keys toolkit reads:
|
|
45
|
+
|
|
46
|
+
| Key | Used by |
|
|
47
|
+
| --- | --- |
|
|
48
|
+
| `session_key` | `APIBase`, `panda_tokens`, and `cable`. It must be the full-key panda token from `current_session.session_key`. |
|
|
49
|
+
| `csrf_token` | `APIBase` sends it as `X-CSRF-Token`. |
|
|
50
|
+
| `post_message_token` | `AlertManager` and `ScrollHacksManager`, for Canvas `postMessage` calls. Add `custom_canvas_post_message_token` to `PandaPal.lti_custom_params` to get it from the launch. |
|
|
51
|
+
|
|
52
|
+
Add your own keys with `setEnvVars({...})`, or extend the `Environment` TypeScript interface.
|
|
53
|
+
|
|
54
|
+
## API calls.
|
|
55
|
+
|
|
56
|
+
`APIBase` (`@inst_proserv/toolkit/api`) is an Axios instance preconfigured for PandaPal:
|
|
57
|
+
|
|
58
|
+
- It sends `Authorization: token=<session_key>` and `X-CSRF-Token` on every request. PandaPal still
|
|
59
|
+
accepts this legacy header form (see [sessions.md](sessions.md#passing-the-token-to-the-frontend)).
|
|
60
|
+
- `create_href(path)` builds a link with `?session_key=<session_key>`.
|
|
61
|
+
- `download_file(url)` signs same-origin URLs with a `T-URL` panda token that expires after 30
|
|
62
|
+
seconds, then downloads the file (or opens it in a tab with `in_tab: true`).
|
|
63
|
+
- `protectedRequest` shows the `error` or `message` from a 4xx response as an alert, maps
|
|
64
|
+
validation `errors` onto a form, and reports anything else to Sentry.
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
import { APIBase } from "@inst_proserv/toolkit/api";
|
|
68
|
+
|
|
69
|
+
export const api = new APIBase({ subURL: `/organizations/${env.organization_id}/api/v1` });
|
|
70
|
+
const { data } = await api.get("/reports");
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Minting panda tokens in the browser.
|
|
74
|
+
|
|
75
|
+
`@inst_proserv/toolkit/panda_tokens` signs URLs with the session secret from `env.session_key`, so
|
|
76
|
+
the browser can make `GET` links (downloads, images, and new tabs) without a round trip to the
|
|
77
|
+
server.
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
import { signURL } from "@inst_proserv/toolkit/panda_tokens";
|
|
81
|
+
|
|
82
|
+
const url = await signURL("/organizations/1/reports/5.csv", {
|
|
83
|
+
params: { format: "csv" }, // Added to the URL before signing
|
|
84
|
+
claims: { exp: 60 }, // Seconds. Defaults to 15 minutes. Pass false for no expiration.
|
|
85
|
+
});
|
|
86
|
+
window.open(url);
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`signURL` creates a `T-URL` token, which PandaPal accepts as `T-URI`. The token covers the URL's path
|
|
90
|
+
and query, so changing either breaks the signature. It only works for `GET` requests.
|
|
91
|
+
|
|
92
|
+
Call `configure({ default_expiration, session_secret, ... })` to change the defaults.
|
|
93
|
+
|
|
94
|
+
## ActionCable.
|
|
95
|
+
|
|
96
|
+
`@inst_proserv/toolkit/cable` opens an ActionCable connection to `/cable`, authenticated with a
|
|
97
|
+
signed `T-URL` panda token. It works with PandaPal's `connection.panda_session` (see
|
|
98
|
+
[action-cable.md](action-cable.md)).
|
|
99
|
+
|
|
100
|
+
- The token lasts about six minutes and refreshes every five.
|
|
101
|
+
- Before every connect or reconnect (including after the computer wakes from sleep), toolkit checks
|
|
102
|
+
the token and refreshes it first if it's stale, so it never connects with an expired token.
|
|
103
|
+
- Subscriptions with the same channel and params share one ActionCable subscription.
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
import { subscribe_channel, useChannelSubscription, useModelSubscription } from "@inst_proserv/toolkit/cable";
|
|
107
|
+
|
|
108
|
+
// Plain subscription. Returns an unsubscribe function.
|
|
109
|
+
const unsubscribe = subscribe_channel("BackgroundTaskChannel", { id: task.id }, (data) => {
|
|
110
|
+
console.log(data);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// React hook version
|
|
114
|
+
useChannelSubscription("BackgroundTaskChannel", { id: task.id }, (data) => setProgress(data.progress));
|
|
115
|
+
|
|
116
|
+
// Keep a MobX model up to date. Each message is merged into the instance.
|
|
117
|
+
useModelSubscription(report, "ReportChannel"); // Subscribes with { id: report.id }
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
`SubscribeChannel` and `SubscribeModel` are component versions of the hooks. To connect somewhere
|
|
121
|
+
other than `/cable`, or without a panda token, pass your own consumer to
|
|
122
|
+
`setDefaultCableConsumer(...)`, or create a `new CableSubscriptionManager(consumer)`.
|
|
123
|
+
|
|
124
|
+
## Routing inside the iframe.
|
|
125
|
+
|
|
126
|
+
`createLtiHistory` (`@inst_proserv/toolkit/extra/LtiHistory`) is a React Router (v5) history that
|
|
127
|
+
keeps the app's location in `history.state` instead of the URL. The iframe URL, and its panda token,
|
|
128
|
+
stay the same while the user moves around the app, and the browser back button still works.
|
|
129
|
+
`<LtiRouter>` from the same module wraps it as a ready-made `<Router>`.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# LTI 1.1 grade passback.
|
|
2
|
+
|
|
3
|
+
For LTI 1.0/1.1 launches, `PandaPal::Jobs::GradePassbackJob` posts a score back to Canvas with the
|
|
4
|
+
LTI Basic Outcomes service, signed with the Organization's key and secret. For LTI 1.3, use
|
|
5
|
+
[AGS](ags.md) instead.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
PandaPal::Jobs::GradePassbackJob.perform_later(current_organization, {
|
|
9
|
+
passback_guid: launch_params['lis_result_sourcedid'],
|
|
10
|
+
passback_url: launch_params['lis_outcome_service_url'],
|
|
11
|
+
score: 0.8,
|
|
12
|
+
})
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
| Option | Required | Notes |
|
|
16
|
+
| --- | --- | --- |
|
|
17
|
+
| `passback_guid` | Yes | The launch's `lis_result_sourcedid`. |
|
|
18
|
+
| `passback_url` | Yes | The launch's `lis_outcome_service_url`. |
|
|
19
|
+
| `score` and/or `total_score` | Yes, at least one | |
|
|
20
|
+
| `text`, `cdata_text`, `url`, `lti_launch_url`, `submitted_at` | No | Canvas submission extensions. |
|
|
21
|
+
|
|
22
|
+
The job raises `PandaPal::Jobs::MissingGradePassbackParams` when a required option is missing, and
|
|
23
|
+
`PandaPal::Jobs::GradePassbackFailure` when Canvas rejects the result. On Sidekiq, it retries five
|
|
24
|
+
times.
|
|
25
|
+
|
|
26
|
+
See the [Canvas assignment tools docs](https://canvas.instructure.com/doc/api/file.assignment_tools.html)
|
|
27
|
+
for what each option does.
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# Installing into Canvas.
|
|
2
|
+
|
|
3
|
+
PandaPal can create an Organization and install the tool into Canvas for it, from the console or
|
|
4
|
+
with rake tasks. Automatic installs need the `bearcat` gem and a Canvas API token in the
|
|
5
|
+
Organization's settings (`settings[:canvas][:api_token]` and `settings[:canvas][:base_url]`).
|
|
6
|
+
|
|
7
|
+
## From the console.
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
org.install_lti(
|
|
11
|
+
host: "https://your_lti.herokuapp.com",
|
|
12
|
+
context: :org_account, # (Optional) Where to install. See "Contexts" below.
|
|
13
|
+
exists: :error, # (Optional) :error, :replace, :duplicate, or :update
|
|
14
|
+
version: "v1p3", # (Optional) "v1p3" or "v1p0"
|
|
15
|
+
dedicated_deployment: false, # (Optional, LTI 1.3 only, experimental) Link the Organization to one deployment instead of the whole Developer Key
|
|
16
|
+
tool: :default, # (Optional) Which tool to install. See multiple-tools.md.
|
|
17
|
+
)
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`org.reinstall_lti!(...)` is the same as `install_lti(..., exists: :replace)`.
|
|
21
|
+
`org.lti_installations(context)` lists the matching external tools already in Canvas.
|
|
22
|
+
|
|
23
|
+
### Contexts.
|
|
24
|
+
|
|
25
|
+
| Value | Installs into |
|
|
26
|
+
| --- | --- |
|
|
27
|
+
| `:org_account` (default) | The Organization's `canvas_account_id` |
|
|
28
|
+
| `:root_account` | `account/self`, the root account of the API token |
|
|
29
|
+
| `"account/3"`, `"course/1"` | That account or course |
|
|
30
|
+
|
|
31
|
+
### What `exists:` does when the tool is already installed.
|
|
32
|
+
|
|
33
|
+
| Value | Behavior |
|
|
34
|
+
| --- | --- |
|
|
35
|
+
| `:error` (default) | Raise an error. |
|
|
36
|
+
| `:update` | Update the existing install. |
|
|
37
|
+
| `:replace` | Delete the existing install, then create a new one. |
|
|
38
|
+
| `:duplicate` | Create another install alongside it. |
|
|
39
|
+
|
|
40
|
+
### Versions.
|
|
41
|
+
|
|
42
|
+
`version:` defaults to the tool's `lti_spec_version` option, then `v1p3`. It also accepts `"1.3"`,
|
|
43
|
+
`"1.1"`, and `"v1p1"`. LTI 1.1 installs use the `v1p0` endpoints.
|
|
44
|
+
|
|
45
|
+
On LTI 1.3, `install_lti`:
|
|
46
|
+
|
|
47
|
+
- Creates (or reuses) a Developer Key, and sets the Organization's `key` and `secret` right away, so
|
|
48
|
+
a failed install retries against the same key instead of making another one.
|
|
49
|
+
- Turns the key's account binding on.
|
|
50
|
+
- Turns off `lock_deploying` on the key's LTI Registration during the install if Canvas turned it on, then
|
|
51
|
+
turns it back on, even if the install fails. The API token needs permission to update LTI
|
|
52
|
+
Registrations for this to work.
|
|
53
|
+
|
|
54
|
+
## Install callbacks.
|
|
55
|
+
|
|
56
|
+
`before_lti_install`, `around_lti_install`, and `after_lti_install` callbacks run around every
|
|
57
|
+
install. Inside them, `current_lti_install` describes the install in progress: `tool`, `host`,
|
|
58
|
+
`context`, `version`, `exists`, `dedicated_deployment`, and, after the install, the Canvas
|
|
59
|
+
`installation`.
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
# app/models/organization_extension.rb
|
|
63
|
+
module OrganizationExtension
|
|
64
|
+
extend ActiveSupport::Concern
|
|
65
|
+
|
|
66
|
+
included do
|
|
67
|
+
after_lti_install :configure_canvas
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def configure_canvas
|
|
71
|
+
puts "Installed #{current_lti_install.tool} as external tool #{current_lti_install.installation[:id]}"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
The interactive install (`interactive_install!`, used by the rake tasks) also runs
|
|
77
|
+
`panda_pal_interactive_install` callbacks around the whole prompt-and-install loop, inside the
|
|
78
|
+
Organization's tenant:
|
|
79
|
+
|
|
80
|
+
```ruby
|
|
81
|
+
included do
|
|
82
|
+
after_panda_pal_interactive_install :seed_defaults
|
|
83
|
+
end
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Rake tasks.
|
|
87
|
+
|
|
88
|
+
| Task | What it does |
|
|
89
|
+
| --- | --- |
|
|
90
|
+
| `panda_pal:org:new[name]` | Opens `$EDITOR` to create an Organization. The file is prefilled with every setting, with optional ones commented out. |
|
|
91
|
+
| `panda_pal:org:edit[name]` | Same as `new`, for an existing Organization. |
|
|
92
|
+
| `panda_pal:org:install[name,host]` | Installs the tool into Canvas, or updates an existing install (`exists: :update`). |
|
|
93
|
+
| `panda_pal:org:reinstall[name,host]` | Deletes the existing install, then installs again (`exists: :replace`). |
|
|
94
|
+
| `panda_pal:org:dev` | Development only. Creates a `local` Organization with development defaults. |
|
|
95
|
+
| `panda_pal:clean_sessions` | Deletes sessions not updated in the last week. See [sessions.md](sessions.md#session-cleanup). |
|
|
96
|
+
|
|
97
|
+
Both `install` tasks prompt for the host when it isn't given, and offer to retry if the install
|
|
98
|
+
fails.
|
|
99
|
+
|
|
100
|
+
### `~/pandapalrc.yml`.
|
|
101
|
+
|
|
102
|
+
`panda_pal:org:dev` and the interactive install read defaults from `~/pandapalrc.yml`:
|
|
103
|
+
|
|
104
|
+
```yml
|
|
105
|
+
lti_host: http://localhost:5000
|
|
106
|
+
settings:
|
|
107
|
+
canvas:
|
|
108
|
+
base_url: http://localhost:3000
|
|
109
|
+
api_token: <your local Canvas API token>
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Entries under `settings` that aren't in the `settings_structure` are ignored.
|
|
113
|
+
|
|
114
|
+
## LTI 1.3 checklist.
|
|
115
|
+
|
|
116
|
+
1. Configure a private key. See [configuration.md](configuration.md#lti-13-private-key).
|
|
117
|
+
This is optional if you're only interested in LTI launching / Canvas initiates all LTI messages
|
|
118
|
+
2. In production, use a `Rails.cache` store that every app process shares, such as Redis. LTI 1.3
|
|
119
|
+
replay protection and the JWKS cache depend on it. See
|
|
120
|
+
[architecture.md](architecture.md#shared-state-that-needs-a-shared-cache).
|
|
121
|
+
3. If you run Canvas locally, make sure `config/redis.yml`, `config/dynamic_settings.yml`, and
|
|
122
|
+
`config/security.yml` exist in Canvas, and set `development.lti_iss` in `security.yml` to
|
|
123
|
+
`'http://localhost:3000'` (your Canvas port).
|
|
124
|
+
4. Install with `install_lti`, or set it up by hand. When you set it up by hand, the Organization's
|
|
125
|
+
`key` is `CLIENT_ID/DEPLOYMENT_ID` (both are in Canvas). Leave off `/DEPLOYMENT_ID` to accept every
|
|
126
|
+
deployment of the key (the most common approach for CDev).
|
|
127
|
+
|
|
128
|
+
The tool configuration is served at `<mount>/v1p3/config` (LTI 1.3 JSON) and `<mount>/v1p0/config`
|
|
129
|
+
(LTI 1.0 XML). `<mount>/config` is a legacy alias for the LTI 1.0 XML.
|