LTI Use Case: Adding OpenDSA Visualizations and Exercises to an LMS


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.
Exercises


Within the resource endpoint, the first thing that OpenDSA checks is that this is a known LMS instance by calling the ensure_lms_instance method. For example, Virginia Tech's Canvas instance (canvas.vt.edu) is different from Instructure's public Canvas instance (canvas.instructure.com). This information is used to create the correct URL that will be embedded in the exercises at the end.

    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
    end
    
Once 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
    end
    
Then 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.

Within the lti_authorize! endpoint, OpenDSA creates a tool provider object from the authentication object created above, throws an error, and returns in case the key and secret do not match.

    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
      end
  
If 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
    end
  
Once 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 @user
  
Finally, 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'
  end
  
When 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:
Info
This is implemented in the "views/lti/resource.html.haml".

    :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.

Most of the work here is done through the JavaScript apps/assests/javascripts/lti_resource.js). In the code below, OpenDSA first checks if the course offering exists or not. If the course offering is present, then OpenDSA shows a tree view of exercises directly.

      (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:

Submission tool url
The complete URL looks something like https://opendsa-server.cs.vt.edu/lti/launch?ex_short_name=IntroSumm.

This URL is generated in initializeJsTree() in JavaScript.

    $('#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.

Once Submission Tool URL is added, instructors can publish the exercise to make that exercise available for students.