Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

LTIAAS Integration #6201

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions app/controllers/lti_launch_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# frozen_string_literal: true

class LtiLaunchController < ApplicationController
# We need to allow iframe embedding for the LTI launch to work
# We also need it for the related views
after_action :allow_iframe

# The LTI Session can raise two types of exceptions:
# LtiaasClientError: Raised if any of the LTIAAS requests fail.
# LtiGradingServiceUnavailable: Raised if the grading service is
# unavailable for the current LTI context.

def launch
unless current_user
# Redirecting user to page requiring them to log in
# You might want to append the ltik here, depending on whether or not
# you want the user to remain in the iframe for the login process (if it's even possible)
redirect_to root_path
return
end
# Starting LTI Session
ltik = params[:ltik]
ltiaas_domain = ENV['LTIAAS_DOMAIN']
api_key = ENV['LTIAAS_API_KEY']
lti_session = LtiSession.new(ltiaas_domain, api_key, ltik)
# Linking LTI User
lti_session.link_lti_user(current_user)
# Redirecting user to page confirming their account has been linked
redirect_to root_path
end

private

def allow_iframe
response.headers.except! 'X-Frame-Options'
end
end
16 changes: 16 additions & 0 deletions app/models/lti_context.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: lti_contexts
#
# id :integer not null, primary key
# user_id :integer not null, foreign_key
# user_lti_id :string(255) not null
# context_id :string(255) not null
# lms_id :string(255) not null
# lms_family :string(255)
#

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comments defining the last four attributes here would be helpful, including how they translate to fields in a Canvas instance.

class LtiContext < ApplicationRecord
belongs_to :user
end
1 change: 1 addition & 0 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ module Permissions
has_many :uploads, class_name: 'CommonsUpload'
has_many :training_modules_users, class_name: 'TrainingModulesUsers'
has_one :user_profile, dependent: :destroy
has_many :lti_contexts, dependent: :destroy

has_many :assignment_suggestions

Expand Down
134 changes: 134 additions & 0 deletions app/services/lti_session.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
# frozen_string_literal: true

class LtiSession
attr_reader :idtoken

INSTRUCTOR_ROLES = [
'membership#Administrator',
'membership#Instructor',
'membership#Mentor'
].freeze

SCORE_MAXIMUM = 1

private_constant :SCORE_MAXIMUM

def initialize(ltiaas_domain, api_key, ltik)
@ltiaas_domain = ltiaas_domain
@api_key = api_key
@ltik = ltik

@conn = Faraday.new(url: "https://#{@ltiaas_domain}") do |config|
config.headers['Authorization'] = "LTIK-AUTH-V2 #{@api_key}:#{@ltik}"
config.request :json
config.response :json
end

@idtoken = make_get_request('/api/idtoken')
@cached_line_item_id = nil
end

def user_lti_id
@idtoken['user']['id']
end

def user_name
@idtoken['user']['name']
end

def user_email
@idtoken['user']['email']
end

def user_is_teacher?
@idtoken['user']['roles'].any? do |str|
INSTRUCTOR_ROLES.any? { |suffix| str.end_with?(suffix) }
end
end

def lms_id
@idtoken['platform']['id']
end

def lms_family
@idtoken['platform']['productFamilyCode']
end

def context_id
"#{@idtoken['launch']['context']['id']}::#{@idtoken['launch']['resourceLink']['id']}"
end

def line_item_id
@cached_line_item_id ||= determine_line_item_id
end

def link_lti_user(current_user)
# Checking if LTI User already exists.
return unless LtiContext.find_by(user: current_user, user_lti_id:, lms_id:, context_id:).nil?
# Sending account created signal if user is a student.
# You can pass the User Wikipedia ID as parameter to this method to
# generate a comment in the grade.
# Example: lti_session.send_account_created_signal(123)
send_account_created_signal(current_user.username) unless user_is_teacher?
# Creating LTI User
LtiContext.create(user: current_user, user_lti_id:, lms_id:, lms_family:, context_id:)
end

private

def send_account_created_signal(user_wikipedia_id = nil)
score = {
'scoreGiven' => SCORE_MAXIMUM,
'scoreMaximum' => SCORE_MAXIMUM,
'activityProgress' => 'Completed',
'gradingProgress' => 'FullyGraded',
'userId' => @idtoken['user']['id']
}
score['comment'] = "Wikipedia user ID: #{user_wikipedia_id}" unless user_wikipedia_id.nil?
make_post_request("/api/lineitems/#{CGI.escape(line_item_id)}/scores", score)
end

def make_get_request(path)
response = @conn.get(path)
raise LtiaasClientError.new(response.body, response.status) unless response.success?
return response.body
end

def make_post_request(path, body)
response = @conn.post(path, body)
raise LtiaasClientError.new(response.body, response.status) unless response.success?
return response.body
end

def determine_line_item_id
unless @idtoken['services']['assignmentAndGrades']['available']
raise LtiGradingServiceUnavailable
end

line_item_id = @idtoken['services']['assignmentAndGrades']['lineItemId']
return line_item_id unless line_item_id.nil? || line_item_id.empty?

resource_link_id = @idtoken['launch']['resourceLink']['id']
line_items = make_get_request("/api/lineitems?resourceLinkId#{resource_link_id}")['lineItems']
return line_items.first['id'] unless line_items.empty?

line_item = {
'label' => 'WikiEdu Account Creation',
'resourceLinkId' => resource_link_id,
'scoreMaximum' => SCORE_MAXIMUM
}
return make_post_request('/api/lineitems', line_item)['id']
end

class LtiaasClientError < StandardError
attr_reader :response_body, :status_code

def initialize(response_body, status_code)
@response_body = response_body
@status_code = status_code
super("LTIAAS Request failed: #{response_body}")
end
end

class LtiGradingServiceUnavailable < StandardError; end
end
4 changes: 4 additions & 0 deletions config/application.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -129,3 +129,7 @@
# Secret for authentication with Wikimedia Event Center
# Generate one for production or staging via `rails secret`
# WikimediaCampaignsPlatformSecret:

# LTIAAS Configuration
LTIAAS_API_KEY: 'example_api_key'
LTIAAS_DOMAIN: 'example.ltiaas.com'
3 changes: 3 additions & 0 deletions config/application.production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,6 @@ SF_WIKIPEDIA_FELLOWS_PROGRAM_ID: <SECRET>
bcc_to_salesforce_email: <SECRET>

SKYLIGHT_AUTHENTICATON: <SECRET>

LTIAAS_API_KEY: <SECRET>
LTIAAS_DOMAIN: <SECRET>
3 changes: 3 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,9 @@
get 'active_courses' => 'active_courses#index'
get '/courses_by_wiki/:language.:project(.org)' => 'courses_by_wiki#show'

# LTI
get 'lti' => 'lti_launch#launch'

# frequenty asked questions
resources :faq do
member do
Expand Down
13 changes: 13 additions & 0 deletions db/migrate/20250213164123_create_lti_contexts.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
class CreateLtiContexts < ActiveRecord::Migration[7.0]
def change
create_table :lti_contexts do |t|
t.string :user_lti_id, null: false
t.string :context_id, null: false
t.string :lms_id, null: false
t.string :lms_family
t.references :user, null: false, foreign_key: { on_delete: :cascade }, type: :integer

t.timestamps
end
end
end
Loading