Skip to content

Commit 9d131ea

Browse files
committed
basic lti support
- external tools can be added on the course/account settings page - external tools can be linked to from within modules - clicking a tool in a module will load a new page with the tool embedded in an iframe - see context_external_tools for standard procedures on retrieving settings for a specific link fixes #4013 Change-Id: I8aa1934f8deac9af26d74036162b34fd1c4242e1 Reviewed-on: https://gerrit.instructure.com/2601 Tested-by: Hudson <hudson@instructure.com> Reviewed-by: Bracken Mosbacker <bracken@instructure.com>
1 parent 399e650 commit 9d131ea

34 files changed

Lines changed: 1189 additions & 143 deletions

app/controllers/application_controller.rb

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -771,7 +771,17 @@ def content_tag_redirect(context, tag, error_redirect_symbol)
771771
@tag = tag
772772
@module = tag.context_module
773773
tag.context_module_action(@current_user, :read)
774-
render :action => 'url_show'
774+
render :template => 'context_modules/url_show'
775+
elsif tag.content_type == 'ContextExternalTool'
776+
@tag = tag
777+
@tool = ContextExternalTool.find_external_tool(tag.url, context)
778+
tag.context_module_action(@current_user, :read)
779+
if !@tool
780+
flash[:error] = "Couldn't find valid settings for this this link"
781+
redirect_to named_context_url(context, error_redirect_symbol)
782+
else
783+
render :template => 'external_tools/tool_show'
784+
end
775785
else
776786
flash[:error] = "Didn't recognize the item type for this tag"
777787
redirect_to named_context_url(context, error_redirect_symbol)
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
class ExternalToolsController < ApplicationController
2+
before_filter :require_context, :require_user
3+
4+
def index
5+
if authorized_action(@context, @current_user, :update)
6+
if params[:include_parents]
7+
@tools = ContextExternalTool.all_tools_for(@context)
8+
else
9+
@tools = @context.context_external_tools.active
10+
end
11+
respond_to do |format|
12+
format.json { render :json => @tools.to_json(:include_root => false) }
13+
end
14+
end
15+
end
16+
17+
def finished
18+
@headers = false
19+
if authorized_action(@context, @current_user, :read)
20+
end
21+
end
22+
23+
def create
24+
if authorized_action(@context, @current_user, :update)
25+
@tool = @context.context_external_tools.build(params[:external_tool])
26+
respond_to do |format|
27+
if @tool.save
28+
format.json { render :json => @tool.to_json(:methods => :readable_state, :include_root => false) }
29+
else
30+
format.json { render :json => @tool.errors.to_json, :status => :bad_request }
31+
end
32+
end
33+
end
34+
end
35+
36+
def update
37+
@tool = @context.context_external_tools.find(params[:id])
38+
if authorized_action(@tool, @current_user, :update)
39+
respond_to do |format|
40+
if @tool.update_attributes(params[:external_tool])
41+
format.json { render :json => @tool.to_json(:methods => :readable_state, :include_root => false) }
42+
else
43+
format.json { render :json => @tool.errors.to_json, :status => :bad_request }
44+
end
45+
end
46+
end
47+
end
48+
49+
def destroy
50+
@tool = @context.context_external_tools.find(params[:id])
51+
if authorized_action(@tool, @current_user, :delete)
52+
respond_to do |format|
53+
if @tool.destroy
54+
format.json { render :json => @tool.to_json(:methods => :readable_state, :include_root => false) }
55+
else
56+
format.json { render :json => @tool.errors.to_json, :status => :bad_request }
57+
end
58+
end
59+
end
60+
end
61+
end

app/helpers/application_helper.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ def context_url(context, *opts)
169169
opts.unshift context.id
170170
opts.push({}) unless opts[-1].is_a?(Hash)
171171
ajax = opts[-1].delete :ajax rescue nil
172-
opts[-1][:only_path] = true
172+
opts[-1][:only_path] = true unless opts[-1][:only_path] == false
173173
res = self.send name, *opts
174174
elsif opts[0].is_a? Hash
175175
opts = opts[0]
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
module ExternalToolsHelper
2+
end

app/models/account.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ class Account < ActiveRecord::Base
6262
has_one :account_authorization_config
6363
has_many :account_reports
6464

65+
has_many :context_external_tools, :as => :context, :dependent => :destroy, :order => 'name'
6566
has_many :learning_outcomes, :as => :context
6667
has_many :learning_outcome_groups, :as => :context
6768
has_many :created_learning_outcomes, :class_name => 'LearningOutcome', :as => :context

app/models/content_tag.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ def context_name
8383
end
8484

8585
def enforce_unique_in_modules
86-
if self.workflow_state != 'deleted' && self.content_id && self.content_id > 0 && self.tag_type == 'context_module'
86+
if self.workflow_state != 'deleted' && self.content_id && self.content_id > 0 && self.tag_type == 'context_module' && self.content_type != 'ContextExternalTool'
8787
tags = ContentTag.find_all_by_content_id_and_content_type_and_tag_type_and_context_id_and_context_type(self.content_id, self.content_type, 'context_module', self.context_id, self.context_type)
8888
tags.select{|t| t != self }.each do |tag|
8989
tag.destroy
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
class ContextExternalTool < ActiveRecord::Base
2+
include Workflow
3+
has_many :content_tags, :as => :content
4+
belongs_to :context, :polymorphic => true
5+
attr_accessible :privacy_level, :domain, :url, :shared_secret, :consumer_key, :name, :description, :custom_fields
6+
validates_presence_of :name
7+
validates_presence_of :consumer_key
8+
validates_presence_of :shared_secret
9+
10+
before_save :infer_defaults
11+
adheres_to_policy
12+
13+
workflow do
14+
state :anonymous
15+
state :name_only
16+
state :public
17+
state :deleted
18+
end
19+
20+
set_policy do
21+
given { |user, session| self.cached_context_grants_right?(user, session, :update) }
22+
set { can :read and can :update and can :delete }
23+
end
24+
25+
def settings
26+
read_attribute(:settings) || write_attribute(:settings, {})
27+
end
28+
29+
def readable_state
30+
workflow_state.titleize
31+
end
32+
33+
def privacy_level=(val)
34+
if ['anonymous', 'name_only', 'public'].include?(val)
35+
self.workflow_state = val
36+
end
37+
end
38+
39+
def custom_fields=(hash)
40+
settings[:custom_fields] ||= {}
41+
hash.each do |key, val|
42+
settings[:custom_fields][key] = val if key.match(/\Acustom_/)
43+
end
44+
end
45+
46+
def shared_secret=(val)
47+
write_attribute(:shared_secret, val) unless val.blank?
48+
end
49+
50+
def infer_defaults
51+
url = nil if url.blank?
52+
domain = nil if domain.blank?
53+
end
54+
55+
def self.standardize_url(url)
56+
return "" if url.empty?
57+
url = "http://" + url unless url.match(/:\/\//)
58+
res = URI.parse(url).normalize
59+
res.query = res.query.split(/&/).sort.join('&') if !res.query.blank?
60+
res.to_s
61+
end
62+
63+
alias_method :destroy!, :destroy
64+
def destroy
65+
self.workflow_state = 'deleted'
66+
save!
67+
end
68+
69+
def include_email?
70+
public?
71+
end
72+
73+
def include_name?
74+
name_only? || public?
75+
end
76+
77+
def precedence
78+
if domain
79+
# Somebody tell me if we should be expecting more than
80+
# 25 dots in a url host...
81+
25 - domain.split(/\./).length
82+
elsif url
83+
25
84+
else
85+
26
86+
end
87+
end
88+
89+
def matches_url?(url)
90+
if !defined?(@standard_url)
91+
@standard_url = !self.url.blank? && ContextExternalTool.standardize_url(self.url)
92+
end
93+
return true if url == @standard_url
94+
host = URI.parse(url).host rescue nil
95+
!!(host && ('.' + host).match(/\.#{domain}\z/))
96+
end
97+
98+
def self.all_tools_for(context)
99+
contexts = []
100+
tools = []
101+
while context
102+
if context.is_a?(Group)
103+
contexts << context
104+
context = context.context || context.account
105+
elsif context.is_a?(Course)
106+
contexts << context
107+
context = context.account
108+
elsif context.is_a?(Account)
109+
contexts << context
110+
context = context.parent_account
111+
else
112+
context = nil
113+
end
114+
end
115+
return nil if contexts.empty?
116+
contexts.each do |context|
117+
tools += context.context_external_tools.active
118+
end
119+
tools.sort_by(&:name)
120+
end
121+
122+
# Order of precedence: Basic LTI defines precedence as first
123+
# checking for a match on domain. Subdomains count as a match
124+
# on less-specific domains, but the most-specific domain will
125+
# match first. So awesome.bob.example.com matches an
126+
# external_tool with example.com as the domain, but only if
127+
# there isn't another external_tool where awesome.bob.example.com
128+
# or bob.example.com is set as the domain.
129+
#
130+
# If there is no domain match then check for an exact url match
131+
# as configured by an admin. If there is still no match
132+
# then check for a match on the current context (configured by
133+
# the teacher).
134+
def self.find_external_tool(url, context)
135+
url = ContextExternalTool.standardize_url(url)
136+
account_contexts = []
137+
other_contexts = []
138+
while context
139+
if context.is_a?(Group)
140+
other_contexts << context
141+
context = context.context || context.account
142+
elsif context.is_a?(Course)
143+
other_contexts << context
144+
context = context.account
145+
elsif context.is_a?(Account)
146+
account_contexts << context
147+
context = context.parent_account
148+
else
149+
context = nil
150+
end
151+
end
152+
return nil if account_contexts.empty? && other_contexts.empty?
153+
account_contexts.each do |context|
154+
res = context.context_external_tools.active.sort_by(&:precedence).detect{|tool| tool.domain && tool.matches_url?(url) }
155+
return res if res
156+
end
157+
account_contexts.each do |context|
158+
res = context.context_external_tools.active.sort_by(&:precedence).detect{|tool| tool.matches_url?(url) }
159+
return res if res
160+
end
161+
other_contexts.reverse.each do |context|
162+
res = context.context_external_tools.active.sort_by(&:precedence).detect{|tool| tool.matches_url?(url) }
163+
return res if res
164+
end
165+
nil
166+
end
167+
168+
named_scope :active, :conditions => ['context_external_tools.workflow_state != ?', 'deleted']
169+
170+
def self.serialization_excludes; [:shared_secret,:settings]; end
171+
end

app/models/context_module.rb

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,27 @@ def add_item(params, added_item=nil)
257257
added_item.workflow_state = 'active'
258258
added_item.save
259259
added_item
260+
elsif params[:type] == 'context_external_tool'
261+
title = params[:title]
262+
added_item ||= self.content_tags.build(
263+
:context_id => self.context_id,
264+
:context_type => self.context_type
265+
)
266+
tool = ContextExternalTool.find_external_tool(params[:url], self.context)
267+
added_item.attributes = {
268+
:content_id => tool ? tool.id : 0,
269+
:content_type => 'ContextExternalTool',
270+
:url => params[:url],
271+
:tag_type => 'context_module',
272+
:title => title,
273+
:indent => params[:indent],
274+
:position => position
275+
}
276+
added_item.context_module_id = self.id
277+
added_item.indent = params[:indent] || 0
278+
added_item.workflow_state = 'active'
279+
added_item.save
280+
added_item
260281
elsif params[:type] == 'context_module_sub_header'
261282
title = params[:title]
262283
added_item ||= self.content_tags.build(

app/models/course.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ class Course < ActiveRecord::Base
8080
has_many :active_folders_with_sub_folders, :class_name => 'Folder', :as => :context, :include => [:active_sub_folders], :conditions => ['folders.workflow_state != ?', 'deleted'], :order => 'folders.name'
8181
has_many :active_folders_detailed, :class_name => 'Folder', :as => :context, :include => [:active_sub_folders, :active_file_attachments], :conditions => ['folders.workflow_state != ?', 'deleted'], :order => 'folders.name'
8282
has_many :messages, :as => :context, :dependent => :destroy
83+
has_many :context_external_tools, :as => :context, :dependent => :destroy, :order => 'name'
8384
belongs_to :wiki
8485
has_many :default_wiki_wiki_pages, :class_name => 'WikiPage', :through => :wiki, :source => :wiki_pages, :conditions => ['wiki_pages.workflow_state != ?', 'deleted'], :order => 'wiki_pages.view_count DESC'
8586
has_many :wiki_namespaces, :as => :context, :dependent => :destroy

app/models/user.rb

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -893,6 +893,26 @@ def avatar_approved?
893893
[:approved, :locked, :re_reported].include?(avatar_state)
894894
end
895895

896+
def lti_role_types
897+
memberships = current_enrollments.uniq + account_users.uniq
898+
memberships.map{|membership|
899+
case membership
900+
when StudentEnrollment
901+
'Student'
902+
when TeacherEnrollment
903+
'Instructor'
904+
when TaEnrollment
905+
'Instructor'
906+
when ObserverEnrollment
907+
'Observer'
908+
when AccountUser
909+
'AccountAdmin'
910+
else
911+
'Observer'
912+
end
913+
}.uniq
914+
end
915+
896916
def avatar_url(size=nil, avatar_setting=nil, fallback=nil)
897917
size ||= 50
898918
avatar_setting ||= 'enabled'

0 commit comments

Comments
 (0)