400 lines
19 KiB
Ruby
400 lines
19 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
#
|
|
# Copyright (C) 2016 - present Instructure, Inc.
|
|
#
|
|
# This file is part of Canvas.
|
|
#
|
|
# Canvas is free software: you can redistribute it and/or modify it under
|
|
# the terms of the GNU Affero General Public License as published by the Free
|
|
# Software Foundation, version 3 of the License.
|
|
#
|
|
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
|
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
|
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
|
# details.
|
|
#
|
|
# You should have received a copy of the GNU Affero General Public License along
|
|
# with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
#
|
|
|
|
require 'vericite_client'
|
|
require 'digest/sha1'
|
|
require 'date'
|
|
|
|
module VeriCite
|
|
def self.state_from_similarity_score(similarity_score)
|
|
return 'none' if similarity_score == 0
|
|
return 'acceptable' if similarity_score < 25
|
|
return 'warning' if similarity_score < 50
|
|
return 'problem' if similarity_score < 75
|
|
|
|
'failure'
|
|
end
|
|
|
|
class Client
|
|
attr_accessor :account_id, :shared_secret, :host, :testing, :show_preliminary_score
|
|
|
|
def initialize(testing = false)
|
|
@host = Canvas::Plugin.find(:vericite).settings[:host] || "api.vericite.com"
|
|
account_id = Canvas::Plugin.find(:vericite).settings[:account_id]
|
|
shared_secret = Canvas::Plugin.find(:vericite).settings[:shared_secret]
|
|
show_preliminary_score = Canvas::Plugin.find(:vericite).settings[:show_preliminary_score] || false
|
|
raise "Account ID required" unless account_id
|
|
raise "Shared secret required" unless shared_secret
|
|
|
|
@account_id = account_id
|
|
@shared_secret = shared_secret
|
|
@show_preliminary_score = show_preliminary_score
|
|
@testing = testing
|
|
end
|
|
|
|
def id(obj)
|
|
if @testing
|
|
"test_#{obj.asset_string}"
|
|
else
|
|
"#{account_id}_#{obj.asset_string}"
|
|
end
|
|
end
|
|
|
|
def email(item)
|
|
# emails @example.com are, guaranteed by RFCs, to be like /dev/null :)
|
|
email = if item.is_a?(User)
|
|
item.email
|
|
end
|
|
email || "#{item.asset_string}@null.instructure.example.com"
|
|
end
|
|
|
|
def self.default_assignment_vericite_settings
|
|
{
|
|
:originality_report_visibility => Canvas::Plugin.find(:vericite).settings[:release_to_students] || 'immediate',
|
|
:exclude_quoted => Canvas::Plugin.find(:vericite).settings[:exclude_quotes],
|
|
:exclude_self_plag => Canvas::Plugin.find(:vericite).settings[:exclude_self_plag],
|
|
:store_in_index => Canvas::Plugin.find(:vericite).settings[:store_in_index],
|
|
:vericite => true
|
|
}
|
|
end
|
|
|
|
def self.normalize_assignment_vericite_settings(settings)
|
|
unless settings.nil?
|
|
valid_keys = VeriCite::Client.default_assignment_vericite_settings.keys
|
|
valid_keys << :created
|
|
settings = settings.slice(*valid_keys)
|
|
|
|
settings[:originality_report_visibility] = 'immediate' unless ['immediate', 'after_grading', 'after_due_date', 'never'].include?(settings[:originality_report_visibility])
|
|
|
|
[:exclude_quoted, :exclude_self_plag, :store_in_index].each do |key|
|
|
bool = Canvas::Plugin.value_to_boolean(settings[key])
|
|
settings[key] = bool ? '1' : '0'
|
|
end
|
|
end
|
|
settings
|
|
end
|
|
|
|
def createOrUpdateAssignment(assignment, settings)
|
|
course = assignment.context
|
|
today = course.time_zone.today
|
|
settings = VeriCite::Client.normalize_assignment_vericite_settings(settings)
|
|
|
|
response = sendRequest(:create_assignment, settings.merge!({
|
|
:user => course,
|
|
:course => course,
|
|
:assignment => assignment,
|
|
:utp => '2',
|
|
:dtstart => "#{today.strftime} 00:00:00",
|
|
:dtdue => "#{today.strftime} 00:00:00",
|
|
:dtpost => "#{today.strftime} 00:00:00",
|
|
:late_accept_flag => '1',
|
|
:post => true
|
|
}))
|
|
|
|
is_response_success?(response) ? { assignment_id: response[:assignment_id] } : response_error_hash(response)
|
|
end
|
|
|
|
# if asset_string is passed in, only submit that attachment
|
|
def submitPaper(submission, asset_string = nil)
|
|
student = submission.user
|
|
assignment = submission.assignment
|
|
course = assignment.context
|
|
opts = {
|
|
:post => true,
|
|
:utp => '1',
|
|
:user => student,
|
|
:course => course,
|
|
:assignment => assignment,
|
|
:tem => email(course),
|
|
:role => submission.grants_right?(student, :grade) ? "Instructor" : "Learner"
|
|
}
|
|
responses = {}
|
|
if submission.submission_type == 'online_upload'
|
|
attachments = submission.attachments.select { |a| a.vericiteable? && (asset_string.nil? || a.asset_string == asset_string) }
|
|
attachments.each do |a|
|
|
# do not resubmit if the score already exists
|
|
if submission.vericite_data_hash[a.asset_string][:similarity_score].blank?
|
|
paper_id = a.id
|
|
paper_title = File.basename(a.display_name, ".*")
|
|
paper_ext = a.extension
|
|
paper_type = a.content_type
|
|
if paper_ext == nil
|
|
paper_ext = ""
|
|
end
|
|
paper_size = 100 # File.size(
|
|
responses[a.asset_string] = sendRequest(:submit_paper, { :pid => paper_id, :ptl => paper_title, :pext => paper_ext, :ptype => paper_type, :psize => paper_size, :pdata => a.open() }.merge!(opts))
|
|
end
|
|
end
|
|
elsif submission.submission_type == 'online_text_entry' && (asset_string.nil? || submission.asset_string == asset_string)
|
|
paper_id = Digest::SHA1.hexdigest submission.plaintext_body
|
|
paper_ext = "html"
|
|
paper_title = "InlineSubmission"
|
|
plain_text = "<html>#{submission.plaintext_body}</html>"
|
|
paper_type = "text/html"
|
|
paper_size = plain_text.bytesize
|
|
|
|
responses[submission.asset_string] = sendRequest(:submit_paper, { :pid => paper_id, :ptl => paper_title, :pext => paper_ext, :ptype => paper_type, :psize => paper_size, :pdata => plain_text }.merge!(opts))
|
|
else
|
|
raise "Unsupported submission type for VeriCite integration: #{submission.submission_type}"
|
|
end
|
|
|
|
responses.keys.each do |asset_string|
|
|
res = responses[asset_string]
|
|
responses[asset_string] = is_response_success?(res) ? { object_id: res[:returned_object_id] } : response_error_hash(res)
|
|
end
|
|
|
|
responses
|
|
end
|
|
|
|
def generateReport(submission, asset_string)
|
|
user = submission.user
|
|
assignment = submission.assignment
|
|
course = assignment.context
|
|
object_id = submission.vericite_data_hash[asset_string][:object_id] rescue nil
|
|
res = nil
|
|
res = sendRequest(:get_scores, :oid => object_id, :utp => '2', :user => user, :course => course, :assignment => assignment) if object_id
|
|
data = {}
|
|
if res
|
|
data[:similarity_score] = res[:similarity_score]
|
|
end
|
|
data
|
|
end
|
|
|
|
def submissionReportUrl(submission, current_user, asset_string)
|
|
user = submission.user
|
|
assignment = submission.assignment
|
|
course = assignment.context
|
|
object_id = submission.vericite_data_hash[asset_string][:object_id] rescue nil
|
|
response = sendRequest(:generate_report, :oid => object_id, :utp => '2', :current_user => current_user, :user => user, :course => course, :assignment => assignment)
|
|
if response != nil
|
|
response[:report_url]
|
|
else
|
|
nil
|
|
end
|
|
end
|
|
|
|
def submissionStudentReportUrl(submission, current_user, asset_string)
|
|
user = submission.user
|
|
assignment = submission.assignment
|
|
course = assignment.context
|
|
object_id = submission.vericite_data_hash[asset_string][:object_id] rescue nil
|
|
response = sendRequest(:generate_report, :oid => object_id, :utp => '1', :current_user => current_user, :user => user, :course => course, :assignment => assignment, :tem => email(course))
|
|
if response != nil
|
|
response[:report_url]
|
|
else
|
|
nil
|
|
end
|
|
end
|
|
|
|
def sendRequest(command, args)
|
|
# default response is "ok" since VeriCite doesn't implement all functions
|
|
response = {}
|
|
begin
|
|
vericite_config = VeriCiteClient::Configuration.new()
|
|
vericite_config.host = @host
|
|
vericite_config.base_path = '/lms/v1'
|
|
api_client = VeriCiteClient::ApiClient.new(vericite_config)
|
|
vericite_client = VeriCiteClient::DefaultApi.new(api_client)
|
|
|
|
user = args.delete :user
|
|
course = args.delete :course
|
|
assignment = args.delete :assignment
|
|
|
|
consumer = @account_id
|
|
consumer_secret = @shared_secret
|
|
if command == :create_assignment
|
|
context_id = course.id
|
|
assignment_id = assignment.id
|
|
assignment_data = VeriCiteClient::AssignmentData.new()
|
|
assignment_data.assignment_title = assignment.title != nil ? assignment.title : assignment_id
|
|
assignment_data.assignment_instructions = assignment.description != nil ? assignment.description : ""
|
|
assignment_data.assignment_exclude_quotes = args["exclude_quoted"] != nil && args["exclude_quoted"] == '1' ? true : false
|
|
assignment_data.assignment_exclude_self_plag = args["exclude_self_plag"] != nil && args["exclude_self_plag"] == '1' ? true : false
|
|
assignment_data.assignment_store_in_index = args["store_in_index"] != nil && args["store_in_index"] == '1' ? true : false
|
|
assignment_data.assignment_due_date = 0
|
|
if assignment.due_at != nil
|
|
# convert to epoch time in milli
|
|
assignment_data.assignment_due_date = assignment.due_at.to_time.utc.to_i * 1000
|
|
end
|
|
assignment_data.assignment_grade = assignment.points_possible != nil ? assignment.points_possible : -1
|
|
_data, status_code, _headers = vericite_client.assignments_context_id_assignment_id_post(context_id, assignment_id, consumer, consumer_secret, assignment_data)
|
|
# check status code
|
|
response[:return_code] = status_code
|
|
if !is_response_success?(response)
|
|
response[:return_message] = "An error has occurred while creating the VeriCite assignment."
|
|
response[:public_error_message] = response[:return_message]
|
|
fail "Failed to create assignment: #{assignment_id}, site #{context_id}"
|
|
end
|
|
# this is a flag to signal success
|
|
response[:assignment_id] = assignment.id
|
|
elsif command == :submit_paper
|
|
context_id = course.id
|
|
assignment_id = assignment.id
|
|
user_id = user.id
|
|
report_meta_data = VeriCiteClient::ReportMetaData.new()
|
|
report_meta_data.user_first_name = user.first_name
|
|
report_meta_data.user_last_name = user.last_name
|
|
report_meta_data.user_email = email(user)
|
|
report_meta_data.user_role = args[:role]
|
|
if assignment
|
|
report_meta_data.assignment_title = assignment.title != nil ? assignment.title : assignment_id
|
|
end
|
|
if course
|
|
report_meta_data.context_title = course.name != nil ? course.name : context_id
|
|
end
|
|
external_content_data = VeriCiteClient::ExternalContentData.new()
|
|
external_content_data.external_content_id = "#{consumer}/#{context_id}/#{assignment_id}/#{user_id}/#{args[:pid]}"
|
|
external_content_data.file_name = args[:ptl]
|
|
external_content_data.upload_content_type = args[:pext]
|
|
external_content_data.upload_content_length = args[:psize]
|
|
report_meta_data.external_content_data = external_content_data
|
|
# @return [Array<ExternalContentUploadInfo>]
|
|
data, status_code, _headers = vericite_client.reports_submit_request_context_id_assignment_id_user_id_post(context_id, assignment_id, user_id, consumer, consumer_secret, report_meta_data)
|
|
# check status code
|
|
response[:return_code] = status_code
|
|
if !is_response_success?(response)
|
|
response[:return_message] = "An error has occurred while submitting the paper to VeriCite."
|
|
response[:public_error_message] = response[:return_message]
|
|
fail "Failed to submit paper: #{external_content_data.external_content_id}"
|
|
end
|
|
data.each do |externalContentUploadInfo|
|
|
# API will return an upload URL to store the submission (throws an exception if it fails)
|
|
api_client.uploadfile(externalContentUploadInfo.url_post, args[:pdata], externalContentUploadInfo.headers)
|
|
end
|
|
# this is a flag to signal success
|
|
response[:returned_object_id] = external_content_data.external_content_id
|
|
elsif command == :get_scores
|
|
context_id = course.id
|
|
assignment_id = assignment.id
|
|
user_id = user.id
|
|
user_score_cache_key_prefix = "vericite_scores/#{consumer}/#{context_id}/#{assignment_id}/"
|
|
users_score_map = {}
|
|
# first check if the cache already has the user's score and if we haven't looked up this assignment lately:
|
|
users_score_map["#{user_id}"] = Rails.cache.read("#{user_score_cache_key_prefix}#{user_id}")
|
|
if users_score_map["#{user_id}"].nil? && Rails.cache.read(user_score_cache_key_prefix) == nil
|
|
# we already looked up this user in Redis, don't bother again (by setting {})
|
|
users_score_map["#{user_id}"] ||= {}
|
|
# we need to look up the user scores in VeriCite for this course
|
|
# @return [Array<ReportScoreReponse>]
|
|
data, status_code, _headers = vericite_client.reports_scores_context_id_get(context_id, consumer, consumer_secret, { :assignment_id => assignment_id })
|
|
# keep track of the assignment lookup api call
|
|
Rails.cache.write(user_score_cache_key_prefix, true, :expires_in => 5.minutes)
|
|
# check status code
|
|
response[:return_code] = status_code
|
|
if !is_response_success?(response)
|
|
response[:return_message] = "An error has occurred while getting scores from VeriCite."
|
|
response[:public_error_message] = response[:return_message]
|
|
fail "Failed to get scores for site: #{context_id}, assignment: #{assignment_id}, user: #{user_id}, exId: #{args[:oid]}"
|
|
end
|
|
# create the user scores map and cache it
|
|
data.each do |reportScoreReponse|
|
|
if reportScoreReponse.score.is_a?(Integer) && reportScoreReponse.score >= 0 &&
|
|
(@show_preliminary_score || reportScoreReponse.preliminary.nil? || !reportScoreReponse.preliminary)
|
|
# keep track of this user's report scores
|
|
users_score_map[reportScoreReponse.user] ||= {}
|
|
users_score_map[reportScoreReponse.user][reportScoreReponse.external_content_id] = Float(reportScoreReponse.score)
|
|
end
|
|
end
|
|
# cache the user score map for a short period of time
|
|
users_score_map.keys.each do |key|
|
|
Rails.cache.write("#{user_score_cache_key_prefix}#{key}", users_score_map[key], :expires_in => 5.minutes)
|
|
end
|
|
else
|
|
# since we didn't have to consult VeriCite, set response status to 200
|
|
response[:return_code] = 200
|
|
end
|
|
|
|
# the user score map shouldn't be empty now (either grabbed from the cache or VeriCite)
|
|
unless users_score_map["#{user_id}"].nil?
|
|
users_score_map["#{user_id}"].each do |key, score|
|
|
if key == args[:oid] && score >= 0
|
|
response[:similarity_score] = score
|
|
end
|
|
end
|
|
end
|
|
elsif command == :generate_report
|
|
context_id = course.id
|
|
assignment_id_filter = assignment.id
|
|
user_id = user.id
|
|
current_user = args.delete :current_user
|
|
token_user = current_user.id
|
|
token_user_role = 'Learner'
|
|
if args[:utp] == '2'
|
|
# instructor
|
|
token_user_role = 'Instructor'
|
|
end
|
|
# @return [Array<ReportURLLinkReponse>]
|
|
data, status_code, _headers = vericite_client.reports_urls_context_id_get(context_id, assignment_id_filter, consumer, consumer_secret, token_user, token_user_role, { user_id_filter => user_id, external_content_id_filter => args[:oid] })
|
|
# check status code
|
|
response[:return_code] = status_code
|
|
if !is_response_success?(response)
|
|
response[:return_message] = "An error has occurred while getting the report URL from VeriCite."
|
|
response[:public_error_message] = response[:return_message]
|
|
fail "Failed to get the report url for site: #{context_id}, assignment: #{assignment_id}, user: #{user_id}, exId: #{args[:oid]}, token_user: #{token_user}, token_user_role: #{token_user_role}"
|
|
end
|
|
data.each do |reportURLLinkReponse|
|
|
# should only be 1 url
|
|
if reportURLLinkReponse.external_content_id == args[:oid]
|
|
# setting response URL is a signal for success
|
|
response[:report_url] = reportURLLinkReponse.url
|
|
end
|
|
end
|
|
end
|
|
rescue => e
|
|
Rails.logger.error("VeriCite: account_id: #{@account_id}, code: #{response[:return_code]}, error: #{e}")
|
|
if is_response_success?(response)
|
|
# we do not want to return a success code if there was an error
|
|
response[:return_code] = 100
|
|
end
|
|
if !response.key?(:return_message)
|
|
# we want a generic error message at a minimum
|
|
response[:return_message] = "VeriCite error during #{command} command, error: #{e}"
|
|
response[:public_error_message] = response[:return_message]
|
|
end
|
|
end # begin
|
|
|
|
return nil if @testing
|
|
|
|
response
|
|
end
|
|
|
|
private
|
|
|
|
SUCCESSFUL_RETURN_CODES = (200..299)
|
|
def is_response_success?(response)
|
|
begin
|
|
response && response.key?(:return_code) && SUCCESSFUL_RETURN_CODES.cover?(Integer(response[:return_code]))
|
|
rescue
|
|
false
|
|
end
|
|
end
|
|
|
|
def response_error_hash(response)
|
|
return {} unless !is_response_success?(response)
|
|
|
|
{
|
|
error_code: response[:return_code],
|
|
error_message: response[:return_message],
|
|
public_error_message: response[:public_error_message],
|
|
}
|
|
end
|
|
end
|
|
end
|