LTI Use Case: Exposing OpenDSA Visualizations and Exercises


Previously, we have shown here how OpenDSA implements LTI when a student tries to access a standalone exercise from a tool consumer. In this tutorial, we explain further the launch endpoint for how OpenDSA makes use of LTI when a student accesses an exercise from an OpenDSA book instance (generated through the OpenDSA website). Note that a Launch endpoint is called when a student accesses an OpenDSA exercise. A resource endpoint is called when an instructor adds any OpenDSA content to the Tool Consumer, where he sees a list of exercises that he can add to his course.

Once OpenDSA content is added into a course and a student clicks on an OpenDSA exercise, the Tool Consumer sends an LTI request to the launch endpoint of OpenDSA. Within the launch endpoint, OpenDSA initially validates if "custom_inst_book_id" is available or not. This is a custom parameter, which if available means that the OpenDSA book instance was created on OpenDSA's website and modules were created in Canvas using the Canvas API. If so, this exercise belongs to an OpenDSA book instance. (If the book instance is not present, it means that this is a standalone exercise, and OpenDSA calls the "launch_ex" endpoint to handle the standalone exercises as described here.)

      unless params.key?(:custom_inst_book_id)
        launch_ex
        return
      end
    
If the exercise is from a book instance, then within the launch endpoint, OpenDSA finds the book instance from the custom book instance id received in the request and fetches the course offering this book instance belongs to. In OpenDSA, a course offering is a way to uniquely identify a course.

      # must include the oauth proxy object
      require 'oauth/request_proxy/rack_request'
      @inst_book = InstBook.find_by(id: params[:custom_inst_book_id])
      @course_offering = CourseOffering.find_by(id:
      @inst_book.course_offering_id)
    
Once, the book instance and course offering are fetched, OpenDSA validates if the received request is an authentic LTI request or not. To do so, OpenDSA first fetches the credentials (unique secret) from the public authentication key received in the request and calls the "lti_authorize!" method.

      $oauth_creds = LmsAccess.get_oauth_creds(params[:oauth_consumer_key])
      render('error') and return unless lti_authorize!
    
Within the lti_authorize! endpoint, OpenDSA creates a tool provider object from the authentication object created above and 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 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 "launch" endpoint and calls "ensure_user" method after that.

      ensure_user()
    
Within the "ensure_user" method, OpenDSA checks if a user with the same email exists or not and creates a new user if that email is not found. Finally, OpenDSA signs in that user to start her session.

      def ensure_user
        email = params[:lis_person_contact_email_primary]
        @user = User.where(email: email).first
        if @user.blank?
          # TODO: should mark this as LMS user then prevent this user from
          login to opendsa domain
          @user = User.new(:email => email,
                           :password => email,
                           :password_confirmation => email,
                           :first_name => params[:lis_person_name_given],
                           :last_name => params[:lis_person_name_family])
          @user.save
        end
        sign_in @user
      end
    
After a new user has been created, OpenDSA calls "lti_enroll" method with the course offering object, which enrolls her into this particular course.

      lti_enroll(@course_offering)
    
Within the "lti_enroll" method, OpenDSA checks if the course offering exists and allows users to enroll or not. After that, OpenDSA enrolls the user if she has not already enrolled in this specific course offering.

      def lti_enroll(course_offering, role = CourseRole.student)
        if course_offering &&
          course_offering.can_enroll? &&
          !course_offering.is_enrolled?(current_user)

          CourseEnrollment.create(
            course_offering: course_offering,
            user: current_user,
            course_role: role)
        end
      end
    
Once the request has been validated and a user has been enrolled, OpenDSA reads the HTML content of the exercise, which is shown to the user in an iframe within the tool consumer.

      @section_html = File.read(File.join('public/OpenDSA/Books',
                                params[:custom_book_path],
                                '/lti_html/',
                                "#{params[:custom_section_file_name].to_s}.html"
                                )) and return
    end
  
When a student completes an exercise, OpenDSA sends a new request to the assessment endpoint from JavaScript. The assessment method sends score back to Tool Consumer if this exercise was launched as an assessment.

Within the assessment endpoint, initially, OpenDSA checks if the exercise is from a book instance or not. If the exercise is from a book instance then the request will have "instBookSectionExerciseId" parameter. OpenDSA fetches the number of attempts on this exercise and current progress of a student using the book instance exercise id and user id.

  def assessment
    request_params = JSON.parse(request.body.read.to_s)
    hasBook = request_params.key?('instBookId')

    if hasBook
      inst_section = InstSection.find_by(id: request_params['instSectionId'])

      @odsa_exercise_attempts =
      OdsaExerciseAttempt.where("inst_book_section_exercise_id=? AND user_id=?",
                                  request_params['instBookSectionExerciseId'],
                                  current_user.id).select(
                                  "id, user_id, question_name, request_type,
                                  correct, worth_credit, time_done, time_taken,
                                  earned_proficiency, points_earned,
                                  pe_score, pe_steps_fixed")
      @odsa_exercise_progress =
      OdsaExerciseProgress.where("inst_book_section_exercise_id=? AND
      user_id=?",
                                  request_params['instBookSectionExerciseId'],
                                  current_user.id).select("user_id,
                                  current_score, highest_score,
                                  total_correct, proficient_date,first_done,
                                  last_done")
    
If the exercise is not from a book instance then the request will have the "instCourseOfferingExerciseId" parameter, which means that a standalone exercise was directly added to the course. For the standalone exercises, OpenDSA fetches the number of attempts on this exercise and current progress of the student using the course offering exercise id and user id. See here.

    else
      @odsa_exercise_attempts =
      OdsaExerciseAttempt.where("inst_course_offering_exercise_id=? AND
      user_id=?",
                                  request_params['instCourseOfferingExerciseId']
                                  , current_user.id).select(
                                  "id, user_id, question_name, request_type,
                                  correct, worth_credit, time_done, time_taken,
                                  earned_proficiency, points_earned,
                                  pe_score, pe_steps_fixed")
      @odsa_exercise_progress =
      OdsaExerciseProgress.where("inst_course_offering_exercise_id=? AND
      user_id=?",
                                  request_params['instCourseOfferingExerciseId']
                                  , current_user.id).select("user_id,
                                  current_score, highest_score,
                                  total_correct, proficient_date,first_done,
                                  last_done")
    end
    
Once the progress and the number of attempts have been fetched, OpenDSA sends these statistics back in tabular form to the Tool Consumer along with the results of the exercise, and students can view these stats in the Tool Consumer.

    a = @odsa_exercise_attempts
    b = @odsa_exercise_progress
    TableHelper.arg(a, b)
    f = render_to_string "lti/table.html.erb"
    

Progress and attempts can be checked in Canvas by clicking on Grades -> [Name of Assignment]. The result is shown in tabular form in Canvas as in this image.

Exercise progress

When the above stats and results have been gathered, OpenDSA fetches the launch parameters from the request and finds the authentication credentials associated with the public consumer key in order to send the results back to the tool consumer. These launch parameters are same as the ones received in the initial launch request. These parameters are saved within a JavaScript variable and are passed to the assessment endpoint with the request.

      launch_params = request_params['toParams']['launch_params']
      if launch_params
        key = launch_params['oauth_consumer_key']
        $oauth_creds = LmsAccess.get_oauth_creds(key)
      else
        @message = "The tool never launched"
        render(:error)
      end
    
Once the credentials have been fetched, OpenDSA submits the score back to the tool consumer. To do so, OpenDSA creates a tool provider object using the key and a secret. Then OpenDSA extends the tool provider object with the OutcomeData extension provided by the IMS-LTI gem.

      lti_param = {
        "lis_outcome_service_url" =>
        "#{launch_params['lis_outcome_service_url']}",
        "lis_result_sourcedid" => "#{launch_params['lis_result_sourcedid']}"
      }
      @tp = IMS::LTI::ToolProvider.new(key, $oauth_creds[key], lti_param)

      @tp.extend IMS::LTI::Extensions::OutcomeData::ToolProvider
    
OpenDSA then checks whether the tool was launched as an outcome service or not. The outcome service allows tool providers to manage results in gradebook columns associated with a launch link within the tool consumer. If the tool was not launched as an outcome service means that the results cannot be reported back to the Tool Consumer.

      if !@tp.outcome_service?
        @message = "This tool wasn't launched as an outcome service"
        render(:error)
      end

      # post the given score to the TC
      score = (request_params['toParams']['score'] != '' ?
      request_params['toParams']['score'] : nil)
      #res = @tp.post_replace_result!(score)
      res = @tp.post_extended_replace_result!(score: score, text: f)
    
If the tool was launched as an assignment, then OpenDSA has to send the final results of every assessment back to the tool consumer. Therefore, in the request, the Tool Consumer sends the lis_outcome_service_url and lis_result_sourcedid parameters. The first parameter is a URL used to send the score back, and the second parameter identifies a unique row and column within the Tool Consumer's gradebook. If these two parameters are not present, then OpenDSA throws an error. Otherwise, it sends the result back to the tool consumer. The IMS-LTI gem enables all of this using the "post_extended_replace_result" method.

Once the results have been posted, and if the exercise was launched from a book instance, OpenDSA saves in the database that the result has been posted for this exercise along with current time.

      if res.success?
        if hasBook
          inst_section.lms_posted = true
          inst_section.time_posted = Time.now
          inst_section.save!
        end
        render :json => { :message => 'success', :res => res.to_json }.to_json
    
If there is some error in posting the results, OpenDSA saves in the database that the result posting failed for this instance and throws an error back to the Tool Consumer.

      else
        if hasBook
          inst_section.lms_posted = false
          inst_section.save!
        end
        render :json => { :message => 'failure', :res => res.to_json }.to_json,
        :status => :bad_request
        error = Error.new(:class_name => 'post_replace_result_fail',
            :message => res.inspect, :params => lti_param.to_s)
        error.save!
      end