Previously we have described here how OpenDSA implements LTI when a student tries to access an OpenDSA exercise from within an LTI tool consumer such as Canvas or Moodle. This tutorial explains how OpenDSA makes use of LTI to support an instructor who wishes to add individual OpenDSA visualizations or exercises to their course. To better understand this example, you should first try out the instructions that instructors will use to incorporate such materials into their course. You can see this for Canvas here, or for Moodle here.
LTI controller (apps/controllers/lti_controller.rb) in OpenDSA takes care of all the requests related to LTI. An LTI-compliant LMS expects a Tool Provider to open in an iframe. Therefore, at the start of the LTI controller, OpenDSA allows both the launch and the resource endpoints to open in an iframe.after_action :allow_iframe, only: [:launch, :resource]When an instructor wants to add OpenDSA content, he sees a list of exercises that he can add as shown in the image below. OpenDSA handles all of this process, starting from showing the list of exercises to returning the link of the selected exercise back to the tool consumer within the resource endpoint.
def resource lms_instance = ensure_lms_instance()Within "ensure_lms_instance", OpenDSA creates a new database entry for this LMS instance if it does not already exist.
def ensure_lms_instance lms_instance = LmsInstance.find_by(url: params[:custom_canvas_api_base_url]) if lms_instance.blank? lms_instance = LmsInstance.new( url: params[:custom_canvas_api_base_url], lms_type: LmsType.find_by('lower(name) = :name', name: params[:tool_consumer_info_product_family_code]), ) lms_instance.save end return lms_instance endOnce the LMS instance is fetched, OpenDSA fetches the course offering using the LMS instance id and course number received in the request (in custom_canvas_course_id parameter). In OpenDSA, a course offering is a unique way to identify a course.
@lms_course_num = params[:custom_canvas_course_id] @lms_course_code = params[:context_label] @lms_instance_id = lms_instance.id @organization_id = lms_instance.organization_id @course_offering = CourseOffering.find_by(lms_instance_id: lms_instance.id, lms_course_num: @lms_course_num) if @course_offering.blank? if lms_instance.organization_id.blank? @organizations = Organization.all end @terms = Term.on_or_future endThen OpenDSA creates a launch URL for OpenDSA from the request object. The protocol is HTTP or HTTPS, and host_with_port is a complete URL where OpenDSA is hosted.
@launch_url = request.protocol + request.host_with_port + "/lti/launch"OpenDSA then validates if the request received from the Tool Consumer is an authentic LTI request or not.
# must include the oauth proxy object require 'oauth/request_proxy/rack_request' $oauth_creds = LmsAccess.get_oauth_creds(params[:oauth_consumer_key]) render('error') and return unless lti_authorize!Here OpenDSA fetches credentials (secret) from the public authentication key received in the request and calls the "lti_authorize!" method.
def lti_authorize! if key = params['oauth_consumer_key'] if secret = $oauth_creds[key] @tp = IMS::LTI::ToolProvider.new(key, secret, params) else @tp = IMS::LTI::ToolProvider.new(nil, nil, params) @tp.lti_msg = "Your consumer didn't use a recognized key." @tp.lti_errorlog = "You did it wrong!" @message = "Consumer key wasn't recognized" return false end else render("No consumer key") return false endIf the key and secret match, then within the same 'lti_authorize!' endpoint OpenDSA checks if the request is valid or not, or if the timestamp on the request is too old. If so, then OpenDSA returns an error. Otherwise, it returns "true" to the launch_ex endpoint, which means the request is valid.
if !params.has_key?(:selection_directive) if !@tp.valid_request?(request) @message = "The OAuth signature was invalid" return false end if Time.now.utc.to_i - @tp.request_oauth_timestamp.to_i > 60*60 @message = "Your request is too old." return false end if was_nonce_used_in_last_x_minutes?(@tp.request_oauth_nonce, 60) @message = "Why are you reusing the nonce?" return false end end return true endOnce the request has been validated, OpenDSA returns back to the "resource" endpoint and signs in the user in the next step.
@user = User.where(email: params[:lis_person_contact_email_primary]).first sign_in @userFinally, OpenDSA parses RST files and fetches the names of all exercises and shows the "lti_resource" layout, which opens "views/lti/resource.html.haml". The names of these exercises will be later shown to instructors in a tree view from where they can select exercises to add to their courses. Note that, the OpenDSA modules are in ReStructuredText (RST) format and have information about exercises within these modules.
require 'RST/rst_parser' exercises = RstParser.get_exercise_info() @json = exercises.to_json() render layout: 'lti_resource' endWhen an instructor tries to add an OpenDSA exercise, he is asked to provide some information that OpenDSA uses to identify the course offering. OpenDSA shows a form similar to the one shown below:
:javascript window.jsonFile = #{@json.html_safe}; window.return_url = "#{params[:launch_presentation_return_url].html_safe}"; window.odsa_launch_url = "#{@launch_url}"; window.odsa_organizations = #{@organizations.blank? ? 'undefined' : @organiations.to_json.html_safe}; window.organization_id = #{@organization_id.blank? ? 'undefined' : @organization_id}; window.odsa_course_info = { course_offering: { lms_instance_id: #{@lms_instance_id}, lms_course_num: #{@lms_course_num}, lms_course_code: '#{@lms_course_code}', label: '#{@lms_course_code}' } }; window.course_offering_id = #{@course_offering.blank? ? 'undefined' : @course_offering.id}; .row .col-xs-9.alert-msg#alert-box{style: 'display: none;'} .alert.alert-danger.alert-dismissable{role: 'alert'} %button#dismiss-button{type: 'button', class: 'close'} %span × %ul#alert-messages - if @course_offering.blank? .row %form.form-horizontal#course_info_form{method: 'post'} %h3.col-xs-offset-1 Please provide some information about your course - unless @organizations.blank? .form-group#select_organization_input %label.control-label{for: '#select_organization'} Organization: .col-xs-6 %select.form-control#select_organization{required: true} %option{:value => -2, disabled: true, selected: true} - @organizations.each do |org| %option{:value => org.id} #{org.name} %option{:value => -1} Other #other_organization_inputs{style: 'display:none'} .form-group %label.control-label{for: '#organization_name'} Organization name: .col-xs-6 %input.form-control#organization_name{type: 'text', placeholder: 'e.g. Virginia Tech'} .form-group %label.control-label{for: '#organization_abbreviation'} Organization abbreviation: .col-xs-6 %input.form-control#organization_abbreviation{type: 'text', placeholder: 'e.g. VT'} .form-group#select_term_input %label.control-label{for: '#select_term'} Term: .col-xs-6 %select.form-control#select_term{required: true} %option{:value => -2, disabled: true, selected: true} - @terms.each do |term| %option{:value => term.id} #{term.display_name} .form-group#select_course_input %label.control-label{for: '#select_course'} Course: .col-xs-6 %select.form-control#select_course{disabled: true, required: true} #other_course_inputs{style: 'display:none'} .form-group %label.control-label{for: '#course_number'} Course number: .col-xs-6 %input.form-control#course_number{type: 'text', placeholder: 'e.g. CS3114'} .form-group %label.control-label{for: '#course_name'} Course name: .col-xs-6 %input.form-control#course_name{type: 'text', placeholder: 'e.g. Data Structures and Algorithms'} .col-xs-offset-5 = submit_tag 'Submit', class: 'btn btn-primary'The code above will display a form in which OpenDSA checks if the course offering is blank or not. If it is blank then that means that a first exercise is being added for this course and no information of this particular course is present in OpenDSA's database. To get the information about this course, OpenDSA shows a form to track the course and to associate exercises with that course.
(function () { $(document).ready(function () { hasOrg = typeof window.organization_id !== 'undefined'; $('#dismiss-button').on('click', function (event) { $('#alert-box').css('display', 'none'); }); if (window.course_offering_id) { // course offering already exists, let them pick an exercise/visualization initializeJsTree(); }If the course offering does not exist, then OpenDSA asks the instructor to provide some information as shown in the form image above. In the code below, OpenDSA checks if the organization does not exist. If so, then OpenDSA creates the organization, course, and course offering.
else { $('#course_info_form').on('submit', function (event) { event.preventDefault(); orgId = hasOrg ? window.organization_id : $('#select_organization').val(); courseId = $('#select_course').val(); if (orgId == -1) { // need to create organization, course, and course offering $.ajax({ url: '/organizations', type: 'post', data: 'organization_name=' + $('#organization_name').val() + '&organization_abbreviation=' + $('#organization_abbreviation').val() }).done(function (data) { $('#alert-box').css('display', 'none'); var courseNumber = $('#course_number').val(); var courseName = $('#course_name').val(); createCourse(courseNumber, courseName, data.id); }).fail(function (data) { displayErrors(data.responseJSON); }); }If the organization exists but the course does not, then OpenDSA creates a course and a course offering.
else if (courseId == -1) { // need to create course and course offering var courseNumber = $('#course_number').val(); var courseName = $('#course_name').val(); createCourse(courseNumber, courseName, orgId); }If the course exists then OpenDSA only creates a course offering within that course.
else { // just need to create course offering createCourseOffering(orgId, courseId); } });
Once all of this is done and instructor selects an exercise from a tree view, a URL of the selected exercise is returned back to the tool Consumer. In Canvas, the returned URL is added under Submission Tool URL, as shown in the image below:
$('#using_json') // listen for select event .on('select_node.jstree', function (e, data) { var selected = data.instance.get_node(data.selected); if (selected.original.type === 'section') { console.log(getResourceURL(selected.original.url_params)); window.location.href = getResourceURL(selected.original.url_params); } }) .jstree({ 'core': { 'data': [{ 'text': 'OpenDSA Exercises and Visualizations', 'state': { 'opened': true, 'selected': true }, 'children': chapters }] } });The code above is executed on the selection of an exercise. This code calls the getResourceURL method, which generates the URL. Following is code for the getResourceURL method.
var getResourceURL = function (obj) { if (!$.isEmptyObject(obj)) { var odsa_url = odsa_launch_url + '?' + $.param(obj); var urlParams = { 'embed_type': 'basic_lti', 'url': odsa_url }; return return_url + '?' + $.param(urlParams); } return ''; };Here return_url is the URL of the tool consumer to which result has to be returned. urlParams contains the launch URL of the exercise, that tool consumer will add in the Submission Tool URL.