rip out conditional_release service
this commit removes Canvas's ability to talk to the conditional_release service. before applying this patch set, if you have data stored in a conditional_release service, run the following to import its data into your canvas database: ConditionalRelease::Assimilator.run(root_account) test plan: - smoke test all mastery paths functionality (editing, unlocking, path selection, mastery path stats) closes LS-1071 Change-Id: I2e33129a5af50c1b92ba8ba7a233e0a3ad66ecc4 Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/242961 Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com> Reviewed-by: James Williams <jamesw@instructure.com> QA-Review: Robin Kuss <rkuss@instructure.com> Product-Review: Jeremy Stanley <jeremy@instructure.com>
This commit is contained in:
parent
b7124d052b
commit
5566323f4f
|
@ -272,8 +272,6 @@ class ApplicationController < ActionController::Base
|
|||
@current_user,
|
||||
session: session,
|
||||
assignment: assignment,
|
||||
domain: request.env['HTTP_HOST'],
|
||||
real_user: @real_current_user,
|
||||
includes: includes
|
||||
)
|
||||
js_env(cr_env)
|
||||
|
|
|
@ -541,48 +541,25 @@ class ContextModuleItemsApiController < ApplicationController
|
|||
get_module_item
|
||||
assignment = @item.assignment
|
||||
return render json: { message: 'requested item is not an assignment' }, status: :bad_request unless assignment
|
||||
assignment_ids = ConditionalRelease::OverrideHandler.handle_assignment_set_selection(@student, assignment, params[:assignment_set_id])
|
||||
|
||||
if ConditionalRelease::Assimilator.assimilation_in_progress?(@context.root_account)
|
||||
return render json: { message: 'Mastery paths selection has been temporarily disabled for maintenance' }, status: :service_unavailable
|
||||
end
|
||||
# assignment occurs in delayed job, may not be fully visible to user until job completes
|
||||
assignments = @context.assignments.published.where(id: assignment_ids).
|
||||
preload(Api::V1::Assignment::PRELOADS)
|
||||
|
||||
if ConditionalRelease::Service.natively_enabled_for_account?(@context.root_account)
|
||||
assignment_ids = ConditionalRelease::OverrideHandler.handle_assignment_set_selection(@student, assignment, params[:assignment_set_id])
|
||||
request_failed = false
|
||||
else
|
||||
response = ConditionalRelease::Service.select_mastery_path(
|
||||
@context,
|
||||
@current_user,
|
||||
@student,
|
||||
assignment,
|
||||
params[:assignment_set_id],
|
||||
session)
|
||||
request_failed = response[:code] != '200'
|
||||
assignment_ids = response[:body]['assignments'].map {|a| a['assignment_id'].try(&:to_i) } unless request_failed
|
||||
end
|
||||
Assignment.preload_context_module_tags(assignments)
|
||||
|
||||
if request_failed
|
||||
render json: response[:body], status: response[:code]
|
||||
else
|
||||
# match cyoe order, omit unpublished or deleted assignments
|
||||
assignments = assignments.index_by(&:id).values_at(*assignment_ids).compact
|
||||
|
||||
# assignment occurs in delayed job, may not be fully visible to user until job completes
|
||||
assignments = @context.assignments.published.where(id: assignment_ids).
|
||||
preload(Api::V1::Assignment::PRELOADS)
|
||||
# grab locally relevant module items
|
||||
items = assignments.map(&:all_context_module_tags).flatten.select{|a| a.context_module_id == @module.id}
|
||||
|
||||
Assignment.preload_context_module_tags(assignments)
|
||||
|
||||
# match cyoe order, omit unpublished or deleted assignments
|
||||
assignments = assignments.index_by(&:id).values_at(*assignment_ids).compact
|
||||
|
||||
# grab locally relevant module items
|
||||
items = assignments.map(&:all_context_module_tags).flatten.select{|a| a.context_module_id == @module.id}
|
||||
|
||||
render json: {
|
||||
meta: { primaryCollection: 'assignments' },
|
||||
items: items.map { |item| module_item_json(item, @student || @current_user, session, @module) },
|
||||
assignments: assignments_json(assignments, @current_user, session)
|
||||
}
|
||||
end
|
||||
render json: {
|
||||
meta: { primaryCollection: 'assignments' },
|
||||
items: items.map { |item| module_item_json(item, @student || @current_user, session, @module) },
|
||||
assignments: assignments_json(assignments, @current_user, session)
|
||||
}
|
||||
end
|
||||
|
||||
# @API Delete module item
|
||||
|
|
|
@ -145,10 +145,7 @@ class ContextModulesController < ApplicationController
|
|||
# locked assignments always have 0 sets, so this check makes it not return 404 if locked
|
||||
# but instead progress forward and return a warning message if is locked later on
|
||||
if rule.present? && (rule[:locked] || !rule[:selected_set_id] || rule[:assignment_sets].length > 1)
|
||||
if ConditionalRelease::Assimilator.assimilation_in_progress?(@context.root_account)
|
||||
flash[:warning] = t('Mastery paths selection has been temporarily disabled for maintenance.')
|
||||
return redirect_to named_context_url(@context, :context_context_modules_url)
|
||||
elsif !rule[:locked]
|
||||
if !rule[:locked]
|
||||
options = rule[:assignment_sets].map { |set|
|
||||
option = {
|
||||
setId: set[:id]
|
||||
|
|
|
@ -57,7 +57,7 @@ module CyoeHelper
|
|||
result = conditional_release_rule_for_module_item(content_tag, opts)
|
||||
return if result.blank?
|
||||
result[:assignment_sets].each do |as|
|
||||
associations = as[:assignments] || as[:assignment_set_associations]
|
||||
associations = as[:assignment_set_associations]
|
||||
next if associations.blank?
|
||||
associations.each do |a|
|
||||
a[:model] = assignment_json(a[:model], user, nil) if a[:model]
|
||||
|
|
|
@ -29,7 +29,7 @@ const CyoeStats = {
|
|||
ENV.CONDITIONAL_RELEASE_SERVICE_ENABLED &&
|
||||
ENV.CONDITIONAL_RELEASE_ENV.rule != null
|
||||
) {
|
||||
const {assignment, jwt, stats_url} = ENV.CONDITIONAL_RELEASE_ENV
|
||||
const {assignment, stats_url} = ENV.CONDITIONAL_RELEASE_ENV
|
||||
|
||||
const detailsRoot = document.createElement('div')
|
||||
detailsRoot.setAttribute('id', 'crs-details')
|
||||
|
@ -40,7 +40,6 @@ const CyoeStats = {
|
|||
: [assignment.submission_types]
|
||||
const initState = {
|
||||
assignment,
|
||||
jwt,
|
||||
apiUrl: stats_url
|
||||
}
|
||||
|
||||
|
|
|
@ -21,7 +21,6 @@ import React from 'react'
|
|||
import PropTypes from 'prop-types'
|
||||
import ReactDOM from 'react-dom'
|
||||
import I18n from 'i18n!conditional_release'
|
||||
import numberHelper from '../helpers/numberHelper'
|
||||
import 'jquery.instructure_forms'
|
||||
|
||||
const SAVE_TIMEOUT = 15000
|
||||
|
@ -52,7 +51,7 @@ class Editor extends React.Component {
|
|||
errors.push({message: errorRecord.error})
|
||||
})
|
||||
}
|
||||
return errors.length == 0 ? null : errors
|
||||
return errors.length === 0 ? null : errors
|
||||
}
|
||||
|
||||
focusOnError = () => {
|
||||
|
@ -100,69 +99,38 @@ class Editor extends React.Component {
|
|||
return saveObject.promise()
|
||||
}
|
||||
|
||||
loadOldEditor = () => {
|
||||
const url = this.props.env.editor_url
|
||||
$.ajax({
|
||||
url,
|
||||
dataType: 'script',
|
||||
cache: true,
|
||||
success: this.createOldEditor
|
||||
})
|
||||
}
|
||||
|
||||
createOldEditor = () => {
|
||||
const env = this.props.env
|
||||
const editor = new conditional_release_module.ConditionalReleaseEditor({
|
||||
jwt: env.jwt,
|
||||
assignment: env.assignment,
|
||||
courseId: env.context_id,
|
||||
locale: {
|
||||
locale: env.locale,
|
||||
parseNumber: numberHelper.parse,
|
||||
formatNumber: I18n.n
|
||||
},
|
||||
gradingType: env.grading_type,
|
||||
baseUrl: env.base_url
|
||||
})
|
||||
editor.attach(
|
||||
document.getElementById('canvas-conditional-release-editor'),
|
||||
document.getElementById('application')
|
||||
)
|
||||
this.setState({editor})
|
||||
loadEditor = () => {
|
||||
if (window.conditional_release_module) {
|
||||
// spec hook
|
||||
return new Promise(resolve =>
|
||||
resolve({default: window.conditional_release_module.ConditionalReleaseEditor})
|
||||
)
|
||||
} else {
|
||||
return import('jsx/conditional_release_editor/conditional-release-editor')
|
||||
}
|
||||
}
|
||||
|
||||
createNativeEditor = () => {
|
||||
const env = this.props.env
|
||||
return import('jsx/conditional_release_editor/conditional-release-editor').then(
|
||||
({default: ConditionalReleaseEditor}) => {
|
||||
const editor = new ConditionalReleaseEditor({
|
||||
assignment: env.assignment,
|
||||
courseId: env.course_id
|
||||
})
|
||||
editor.attach(
|
||||
document.getElementById('canvas-conditional-release-editor'),
|
||||
document.getElementById('application')
|
||||
)
|
||||
this.setState({editor})
|
||||
}
|
||||
)
|
||||
return this.loadEditor().then(({default: ConditionalReleaseEditor}) => {
|
||||
const editor = new ConditionalReleaseEditor({
|
||||
assignment: env.assignment,
|
||||
courseId: env.course_id
|
||||
})
|
||||
editor.attach(
|
||||
document.getElementById('canvas-conditional-release-editor'),
|
||||
document.getElementById('application')
|
||||
)
|
||||
this.setState({editor})
|
||||
})
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.env.native) {
|
||||
this.createNativeEditor()
|
||||
} else if (!this.props.env.disable_editing) {
|
||||
this.loadOldEditor()
|
||||
}
|
||||
this.createNativeEditor()
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div id="canvas-conditional-release-editor">
|
||||
{this.props.env.disable_editing &&
|
||||
I18n.t('Mastery Paths editing has been temporarily disabled for maintenance.')}
|
||||
</div>
|
||||
)
|
||||
return <div id="canvas-conditional-release-editor" />
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -25,17 +25,17 @@ const parseEnvData = () => {
|
|||
(ENV.CONDITIONAL_RELEASE_ENV && ENV.CONDITIONAL_RELEASE_ENV.active_rules) || []
|
||||
return {
|
||||
triggerAssignments: activeRules.reduce((triggers, rule) => {
|
||||
triggers[rule.trigger_assignment || rule.trigger_assignment_id] = rule
|
||||
triggers[rule.trigger_assignment_id] = rule
|
||||
return triggers
|
||||
}, {}),
|
||||
releasedAssignments: activeRules.reduce((released, rule) => {
|
||||
rule.scoring_ranges.forEach(range => {
|
||||
range.assignment_sets.forEach(set => {
|
||||
;(set.assignments || set.assignment_set_associations).forEach(asg => {
|
||||
set.assignment_set_associations.forEach(asg => {
|
||||
const id = asg.assignment_id
|
||||
released[id] = released[id] || []
|
||||
released[id].push({
|
||||
assignment_id: rule.trigger_assignment || rule.trigger_assignment_id,
|
||||
assignment_id: rule.trigger_assignment_id,
|
||||
assignment: rule.trigger_assignment_model,
|
||||
upper_bound: range.upper_bound,
|
||||
lower_bound: range.lower_bound
|
||||
|
|
|
@ -1,140 +0,0 @@
|
|||
#
|
||||
# Copyright (C) 2020 - 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/>.
|
||||
#
|
||||
|
||||
module ConditionalRelease
|
||||
module Assimilator
|
||||
def self.run(root_account)
|
||||
# you will be assimilated into the monolith
|
||||
# resistance is futile
|
||||
|
||||
raise "not a root account" unless root_account.root_account? # sanity checks
|
||||
raise "already enabled natively" if ConditionalRelease::Service.natively_enabled_for_account?(root_account)
|
||||
|
||||
root_account.settings[:conditional_release_assimilation_started_at] ||= Time.now.utc
|
||||
root_account.settings.delete(:conditional_release_assimilation_failed_at)
|
||||
root_account.save!
|
||||
|
||||
rules_data = retrieve_rules_data_from_service(root_account)
|
||||
trigger_assignment_ids = []
|
||||
rules_data.each_slice(200) do |batched_rules|
|
||||
all_assignment_ids = batched_rules.map{|r| r["trigger_assignment"]} +
|
||||
batched_rules.map{|r| r["scoring_ranges"].map{|s| s["assignment_sets"].map{|as| as["assignments"].map{|a| a["assignment_id"]}}}}.flatten
|
||||
prefetched_assignments = Assignment.active.where(:id => all_assignment_ids).index_by(&:id)
|
||||
batched_rules.each do |rule_hash|
|
||||
course_id = rule_hash["course_id"].to_i
|
||||
trigger_assignment = prefetched_assignments[rule_hash["trigger_assignment"].to_i]
|
||||
next unless trigger_assignment && trigger_assignment.context_id == course_id && trigger_assignment.root_account_id == root_account.id
|
||||
trigger_assignment_ids << trigger_assignment.id
|
||||
|
||||
rule = trigger_assignment.conditional_release_rules.new(:course_id => course_id, :root_account_id => trigger_assignment.root_account_id)
|
||||
ranges = rule_hash['scoring_ranges'].map do |range_hash|
|
||||
range_hash.except!('id', 'rule_id') # don't save these
|
||||
range_hash["assignment_sets_attributes"] = range_hash.delete("assignment_sets").map do |set_hash|
|
||||
set_hash['service_id'] = set_hash.delete('id') # hold onto the old id as a instance variable
|
||||
set_hash.delete('scoring_range_id')
|
||||
associations = []
|
||||
set_hash.delete('assignments').each do |assoc_hash|
|
||||
released_assignment = prefetched_assignments[assoc_hash["assignment_id"].to_i]
|
||||
next unless released_assignment && released_assignment.context_id == course_id
|
||||
associations << {'assignment_id' => released_assignment.id}.merge(assoc_hash.slice('created_at', 'updated_at'))
|
||||
end
|
||||
set_hash["assignment_set_associations_attributes"] = associations
|
||||
set_hash
|
||||
end
|
||||
range_hash
|
||||
end
|
||||
if rule.update(scoring_ranges_attributes: ranges)
|
||||
service_to_native_set_id_map = {}
|
||||
rule.scoring_ranges.each{|range| range.assignment_sets.each{|set| service_to_native_set_id_map[set.service_id] = set.id}}
|
||||
assignment_set_inserts = []
|
||||
rule_hash['assignment_set_actions'].each do |action_hash|
|
||||
native_set_id = service_to_native_set_id_map[action_hash.delete('assignment_set_id')]
|
||||
next unless native_set_id
|
||||
action_hash['assignment_set_id'] = native_set_id
|
||||
action_hash['root_account_id'] = rule.root_account_id
|
||||
action_hash.delete('id')
|
||||
['student_id', 'actor_id'].each{|k| action_hash[k] = action_hash[k].to_i}
|
||||
assignment_set_inserts << action_hash
|
||||
end
|
||||
assignment_set_inserts.each_slice(1000) do |sliced_inserts|
|
||||
ConditionalRelease::AssignmentSetAction.bulk_insert(sliced_inserts)
|
||||
end
|
||||
else
|
||||
::Rails.logger.warn("Rule #{rule_hash["id"]} from service for account #{root_account.global_id} could not be migrated: #{rule.errors.full_messages}")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
root_account.reload
|
||||
root_account.settings[:conditional_release_assimilation_ended_at] = Time.now.utc
|
||||
root_account.settings[:use_native_conditional_release] = true
|
||||
root_account.save!
|
||||
|
||||
# resync submissions that may have been graded in the meantime
|
||||
trigger_assignment_ids.each_slice(50) do |sliced_assignment_ids|
|
||||
Submission.where(:assignment_id => sliced_assignment_ids).
|
||||
where("updated_at > ?", root_account.settings[:conditional_release_assimilation_started_at]).
|
||||
find_each(&:queue_conditional_release_grade_change_handler)
|
||||
end
|
||||
rescue
|
||||
::Rails.logger.error("Conditional Release assimilation failed for account #{root_account.global_id}: #{$!.message}")
|
||||
root_account.reload
|
||||
root_account.settings[:conditional_release_assimilation_failed_at] = Time.now.utc
|
||||
root_account.save!
|
||||
raise
|
||||
end
|
||||
|
||||
def self.retrieve_rules_data_from_service(root_account)
|
||||
user = Pseudonym.active.where(account_id: root_account.id, unique_id: ConditionalRelease::Service.unique_id).first&.user
|
||||
raise "can't find Conditional Release API user for root account: #{root_account.id}" unless user
|
||||
|
||||
jwt = ConditionalRelease::Service.jwt_for(root_account, user, root_account.domain)
|
||||
start_request = CanvasHttp.post(ConditionalRelease::Service.start_export_url, {"Authorization" => "Bearer #{jwt}"})
|
||||
raise "could not start export on service-side" unless start_request.code == '200'
|
||||
|
||||
sleep 5 # give the service a little time to process before we start hitting it - most should finish quickly
|
||||
|
||||
waiting_for_export = true
|
||||
retry_count = 0
|
||||
while waiting_for_export
|
||||
jwt = ConditionalRelease::Service.jwt_for(root_account, user, root_account.domain)
|
||||
status_request = CanvasHttp.get(ConditionalRelease::Service.export_status_url, {"Authorization" => "Bearer #{jwt}"})
|
||||
raise "could not retrieve export status" unless status_request.code == '200'
|
||||
body = JSON.parse(status_request.body)
|
||||
raise "export failed on service side" if body['migration_failed']
|
||||
if body['migrated']
|
||||
waiting_for_export = false
|
||||
else
|
||||
retry_count += 1
|
||||
raise "giving up waiting for service export" if retry_count > 30 # if we've been waiting for 15 min something probably went wrong
|
||||
sleep 30 # okay this is probably a big shard - let's wait
|
||||
end
|
||||
end
|
||||
|
||||
jwt = ConditionalRelease::Service.jwt_for(root_account, user, root_account.domain)
|
||||
download_request = CanvasHttp.get(ConditionalRelease::Service.download_export_url, {"Authorization" => "Bearer #{jwt}"})
|
||||
raise "could not retrieve export status" unless download_request.code == '200'
|
||||
JSON.parse(Zlib::Inflate.inflate(download_request.body))
|
||||
end
|
||||
|
||||
def self.assimilation_in_progress?(root_account)
|
||||
!!root_account.settings[:conditional_release_assimilation_started_at] &&
|
||||
!(root_account.settings[:conditional_release_assimilation_ended_at] || root_account.settings[:conditional_release_assimilation_failed_at])
|
||||
end
|
||||
end
|
||||
end
|
|
@ -29,54 +29,16 @@ module ConditionalRelease
|
|||
return unless assignment_ids.any?
|
||||
end
|
||||
|
||||
if ConditionalRelease::Service.natively_enabled_for_account?(course.root_account)
|
||||
# just pretend like we started an export even if we're not actually hitting a service anymore
|
||||
return {:native => true, :course => course, :assignment_ids => assignment_ids}
|
||||
end
|
||||
|
||||
data = nil
|
||||
if opts[:selective]
|
||||
data = {:export_settings => {:selective => '1', :exported_assignment_ids => assignment_ids}}.to_param
|
||||
end
|
||||
response = CanvasHttp.post(ConditionalRelease::Service.content_exports_url, headers_for(course), form_data: data)
|
||||
if response.code =~ /^2/
|
||||
json = JSON.parse(response.body)
|
||||
{:export_id => json['id'], :course => course}
|
||||
else
|
||||
raise "Error queueing export for Conditional Release: #{response.body}"
|
||||
end
|
||||
# just pretend like we started an export even if we're not actually hitting a service anymore
|
||||
return {:native => true, :course => course, :assignment_ids => assignment_ids}
|
||||
end
|
||||
|
||||
def export_completed?(export_data)
|
||||
return true if export_data[:native]
|
||||
response = CanvasHttp.get("#{ConditionalRelease::Service.content_exports_url}/#{export_data[:export_id]}", headers_for(export_data[:course]))
|
||||
if response.code =~ /^2/
|
||||
json = JSON.parse(response.body)
|
||||
case json['state']
|
||||
when 'completed'
|
||||
true
|
||||
when 'failed'
|
||||
raise "Content Export for Conditional Release failed"
|
||||
else
|
||||
false
|
||||
end
|
||||
else
|
||||
raise "Error retrieving export state for Conditional Release: #{response.body}"
|
||||
end
|
||||
export_data[:native]
|
||||
end
|
||||
|
||||
def retrieve_export(export_data)
|
||||
return generate_native_export(export_data[:course], export_data[:assignment_ids]) if export_data[:native]
|
||||
|
||||
response = CanvasHttp.get("#{ConditionalRelease::Service.content_exports_url}/#{export_data[:export_id]}/download", headers_for(export_data[:course]))
|
||||
if response.code =~ /^2/
|
||||
json = JSON.parse(response.body)
|
||||
unless json.values.all?(&:empty?) # don't bother saving if there's nothing to import
|
||||
return json
|
||||
end
|
||||
else
|
||||
raise "Error retrieving export for Conditional Release: #{response.body}"
|
||||
end
|
||||
generate_native_export(export_data[:course], export_data[:assignment_ids])
|
||||
end
|
||||
|
||||
def generate_native_export(course, assignment_ids)
|
||||
|
@ -108,35 +70,6 @@ module ConditionalRelease
|
|||
end
|
||||
|
||||
def send_imported_content(course, _cm, imported_content)
|
||||
if ConditionalRelease::Service.natively_enabled_for_account?(course.root_account)
|
||||
return import_content_natively(course, imported_content)
|
||||
end
|
||||
if imported_content['native']
|
||||
# translate to old format
|
||||
imported_content['rules'].each do |rule_hash|
|
||||
rule_hash['trigger_assignment'] = rule_hash.delete('trigger_assignment_id')
|
||||
rule_hash['scoring_ranges'].each do |range_hash|
|
||||
range_hash['assignment_sets'].each do |set_hash|
|
||||
set_hash['assignments'] = set_hash.delete('assignment_set_associations')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
send_imported_content_to_service(course, imported_content)
|
||||
end
|
||||
|
||||
def send_imported_content_to_service(course, imported_content)
|
||||
data = {:file => StringIO.new(imported_content.to_json)}
|
||||
response = CanvasHttp.post(ConditionalRelease::Service.content_imports_url, headers_for(course), form_data: data, multipart: true)
|
||||
if response.code =~ /^2/
|
||||
json = JSON.parse(response.body)
|
||||
{:import_id => json['id'], :course => course}
|
||||
else
|
||||
raise "Error sending import for Conditional Release: #{response.body}"
|
||||
end
|
||||
end
|
||||
|
||||
def import_content_natively(course, imported_content)
|
||||
all_successful = true
|
||||
is_native = imported_content['native']
|
||||
imported_content['rules']&.each do |rule_hash|
|
||||
|
@ -180,35 +113,7 @@ module ConditionalRelease
|
|||
end
|
||||
|
||||
def import_completed?(import_data)
|
||||
return true if import_data[:native]
|
||||
response = CanvasHttp.get("#{ConditionalRelease::Service.content_imports_url}/#{import_data[:import_id]}", headers_for(import_data[:course]))
|
||||
if response.code =~ /^2/
|
||||
json = JSON.parse(response.body)
|
||||
case json['state']
|
||||
when 'completed'
|
||||
true
|
||||
when 'failed'
|
||||
raise "Content Import for Conditional Release failed"
|
||||
else
|
||||
false
|
||||
end
|
||||
else
|
||||
raise "Error retrieving import state for Conditional Release: #{response.body}"
|
||||
end
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def headers_for(course)
|
||||
token = Canvas::Security::ServicesJwt.generate({
|
||||
sub: 'MIGRATION_SERVICE',
|
||||
role: 'admin',
|
||||
account_id: Context.get_account(course).root_account.lti_guid.to_s,
|
||||
context_type: 'Course',
|
||||
context_id: course.id.to_s,
|
||||
workflows: ['conditonal-release-api']
|
||||
})
|
||||
{"Authorization" => "Bearer #{token}"}
|
||||
import_data[:native]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -22,74 +22,25 @@ module ConditionalRelease
|
|||
class Service
|
||||
private_class_method :new
|
||||
|
||||
DEFAULT_PATHS = {
|
||||
base_path: '',
|
||||
stats_path: "stats",
|
||||
create_account_path: 'api/accounts',
|
||||
content_exports_path: 'api/content_exports',
|
||||
content_imports_path: 'api/content_imports',
|
||||
rules_path: 'api/rules?include[]=all&active=true',
|
||||
rules_summary_path: 'api/rules/summary',
|
||||
select_assignment_set_path: 'api/rules/select_assignment_set',
|
||||
editor_path: 'javascripts/generated/conditional_release_editor.bundle.js',
|
||||
start_export_path: 'api/start_export',
|
||||
export_status_path: 'api/export_status',
|
||||
download_export_path: 'api/download_export',
|
||||
}.freeze
|
||||
|
||||
DEFAULT_CONFIG = {
|
||||
enabled: false, # required
|
||||
host: nil, # required
|
||||
protocol: nil, # defaults to Canvas
|
||||
}.merge(DEFAULT_PATHS).freeze
|
||||
|
||||
def self.env_for(context, user = nil, session: nil, assignment: nil, domain: nil,
|
||||
real_user: nil, includes: [])
|
||||
includes = Array.wrap(includes)
|
||||
def self.env_for(context, user = nil, session: nil, assignment: nil, includes: [])
|
||||
enabled = self.enabled_in_context?(context)
|
||||
env = {
|
||||
CONDITIONAL_RELEASE_SERVICE_ENABLED: enabled
|
||||
}
|
||||
return env unless enabled && user
|
||||
|
||||
if self.natively_enabled_for_account?(context.root_account)
|
||||
cyoe_env = {native: true}
|
||||
cyoe_env[:assignment] = assignment_attributes(assignment) if assignment
|
||||
if context.is_a?(Course)
|
||||
cyoe_env[:course_id] = context.id
|
||||
cyoe_env[:stats_url] = "/api/v1/courses/#{context.id}/mastery_paths/stats"
|
||||
end
|
||||
else
|
||||
cyoe_env = {
|
||||
jwt: jwt_for(context, user, domain, session: session, real_user: real_user),
|
||||
disable_editing: !!ConditionalRelease::Assimilator.assimilation_in_progress?(context.root_account),
|
||||
assignment: assignment_attributes(assignment),
|
||||
stats_url: stats_url,
|
||||
locale: I18n.locale.to_s,
|
||||
editor_url: editor_url,
|
||||
base_url: base_url,
|
||||
context_id: context.id
|
||||
}
|
||||
cyoe_env = {}
|
||||
cyoe_env[:assignment] = assignment_attributes(assignment) if assignment
|
||||
if context.is_a?(Course)
|
||||
cyoe_env[:course_id] = context.id
|
||||
cyoe_env[:stats_url] = "/api/v1/courses/#{context.id}/mastery_paths/stats"
|
||||
end
|
||||
|
||||
includes = Array.wrap(includes)
|
||||
cyoe_env[:rule] = rule_triggered_by(assignment, user, session) if includes.include? :rule
|
||||
cyoe_env[:active_rules] = active_rules(context, user, session) if includes.include? :active_rules
|
||||
env.merge(CONDITIONAL_RELEASE_ENV: cyoe_env)
|
||||
end
|
||||
|
||||
def self.jwt_for(context, user, domain, claims: {}, session: nil, real_user: nil)
|
||||
Canvas::Security::ServicesJwt.generate(
|
||||
claims.merge({
|
||||
sub: user.id.to_s,
|
||||
domain: domain,
|
||||
account_id: Context.get_account(context).root_account.lti_guid.to_s,
|
||||
context_type: context.class.name,
|
||||
context_id: context.id.to_s,
|
||||
role: find_role(user, session, context),
|
||||
workflows: ['conditonal-release-api'],
|
||||
canvas_token: Canvas::Security::ServicesJwt.for_user(domain, user, real_user: real_user, workflows: ['conditional-release'])
|
||||
})
|
||||
)
|
||||
env.merge(CONDITIONAL_RELEASE_ENV: cyoe_env)
|
||||
end
|
||||
|
||||
def self.rules_for(context, student, session)
|
||||
|
@ -122,75 +73,8 @@ module ConditionalRelease
|
|||
@config = nil
|
||||
end
|
||||
|
||||
def self.config
|
||||
@config ||= DEFAULT_CONFIG.merge(config_file)
|
||||
end
|
||||
|
||||
# whether new accounts will use the ported canvas db and UI instead of provisioning onto the service
|
||||
# can flip this setting on when canvas-side is code-complete but the migration is still pending
|
||||
def self.prefer_native?
|
||||
Setting.get("conditional_release_prefer_native", "false") == "true"
|
||||
end
|
||||
|
||||
# TODO: can remove when all accounts are migrated
|
||||
def self.natively_enabled_for_account?(root_account)
|
||||
!!root_account&.settings&.[](:use_native_conditional_release)
|
||||
end
|
||||
|
||||
def self.service_configured?
|
||||
!!(config[:enabled] && config[:host])
|
||||
end
|
||||
|
||||
def self.enabled_in_context?(context)
|
||||
!!((service_configured? || natively_enabled_for_account?(context&.root_account)) && context&.feature_enabled?(:conditional_release))
|
||||
end
|
||||
|
||||
def self.protocol
|
||||
config[:protocol] || HostUrl.protocol
|
||||
end
|
||||
|
||||
def self.host
|
||||
config[:host]
|
||||
end
|
||||
|
||||
def self.unique_id
|
||||
config[:unique_id] || "conditional-release-service@instructure.auth"
|
||||
end
|
||||
|
||||
DEFAULT_PATHS.each do |path_name, _path|
|
||||
method_name = path_name.to_s.sub(/_path$/, '_url')
|
||||
Service.define_singleton_method method_name do
|
||||
build_url config[path_name]
|
||||
end
|
||||
end
|
||||
|
||||
# Returns an http response-like hash { code: string, body: string or object }
|
||||
def self.select_mastery_path(context, current_user, student, trigger_assignment, assignment_set_id, session)
|
||||
return unless enabled_in_context?(context)
|
||||
if context.blank? || student.blank? || trigger_assignment.blank? || assignment_set_id.blank?
|
||||
return { code: '400', body: { message: 'invalid request' } }
|
||||
end
|
||||
|
||||
trigger_submission = trigger_assignment.submission_for_student(student)
|
||||
submission_hidden = context.post_policies_enabled? ? !trigger_submission&.posted? : trigger_assignment.muted?
|
||||
if trigger_submission.blank? || !trigger_submission.graded? || submission_hidden
|
||||
return { code: '400', body: { message: 'invalid submission state' } }
|
||||
end
|
||||
|
||||
request_data = {
|
||||
trigger_assignment: trigger_assignment.id,
|
||||
trigger_assignment_score: trigger_submission.score,
|
||||
trigger_assignment_points_possible: trigger_assignment.points_possible,
|
||||
student_id: student.id,
|
||||
assignment_set_id: assignment_set_id
|
||||
}
|
||||
headers = headers_for(context, current_user, domain_for(context), session)
|
||||
request = CanvasHttp.post(select_assignment_set_url, headers, form_data: request_data.to_param)
|
||||
|
||||
# either assignments have changed (req success) or unknown state (req error)
|
||||
clear_rules_cache_for(context, student)
|
||||
|
||||
{ code: request.code, body: JSON.parse(request.body) }
|
||||
Feature.definitions.key?('conditional_release') && context&.feature_enabled?(:conditional_release)
|
||||
end
|
||||
|
||||
def self.triggers_mastery_paths?(assignment, current_user, session = nil)
|
||||
|
@ -210,40 +94,7 @@ module ConditionalRelease
|
|||
def self.active_rules(course, current_user, session)
|
||||
return unless enabled_in_context?(course)
|
||||
return unless course.grants_any_right?(current_user, session, :read, :manage_assignments)
|
||||
return native_active_rules(course) if natively_enabled_for_account?(course.root_account)
|
||||
|
||||
Rails.cache.fetch(active_rules_cache_key(course)) do
|
||||
headers = headers_for(course, current_user, domain_for(course), session)
|
||||
request = CanvasHttp.get(rules_url, headers)
|
||||
unless request && request.code == '200'
|
||||
InstStatsd::Statsd.increment("conditional_release_service_error",
|
||||
short_stat: 'conditional_release_service_error',
|
||||
tags: { type: 'active_rules' })
|
||||
raise ServiceError, "error fetching active rules #{request}"
|
||||
end
|
||||
rules = JSON.parse(request.body)
|
||||
|
||||
trigger_ids = rules.map { |rule| rule['trigger_assignment'] }
|
||||
trigger_assgs = Assignment.preload(:grading_standard).where(id: trigger_ids).each_with_object({}) do |a, assgs|
|
||||
assgs[a.id.to_s] = {
|
||||
points_possible: a.points_possible,
|
||||
grading_type: a.grading_type,
|
||||
grading_scheme: a.uses_grading_standard ? a.grading_scheme : nil,
|
||||
}
|
||||
end
|
||||
|
||||
rules.each do |rule|
|
||||
rule['trigger_assignment_model'] = trigger_assgs[rule['trigger_assignment']]
|
||||
end
|
||||
|
||||
rules
|
||||
end
|
||||
rescue => e
|
||||
Canvas::Errors.capture(e, course_id: course.global_id, user_id: current_user.global_id)
|
||||
[]
|
||||
end
|
||||
|
||||
def self.native_active_rules(course)
|
||||
rules_data = Rails.cache.fetch_with_batched_keys('conditional_release_active_rules', batch_object: course, batched_keys: :conditional_release) do
|
||||
rules = course.conditional_release_rules.active.with_assignments.to_a
|
||||
rules.as_json(include: Rule.includes_for_json, include_root: false, except: [:root_account_id, :deleted_at])
|
||||
|
@ -264,23 +115,6 @@ module ConditionalRelease
|
|||
|
||||
class << self
|
||||
private
|
||||
def config_file
|
||||
ConfigFile.load('conditional_release').try(:symbolize_keys) || {}
|
||||
end
|
||||
|
||||
def build_url(path)
|
||||
"#{protocol}://#{host}/#{path}"
|
||||
end
|
||||
|
||||
def find_role(user, session, context)
|
||||
if Context.get_account(context).grants_right? user, session, :manage
|
||||
'admin'
|
||||
elsif context.is_a?(Course) && context.grants_right?(user, session, :manage_assignments)
|
||||
'teacher'
|
||||
elsif context.grants_right? user, session, :read
|
||||
'student'
|
||||
end
|
||||
end
|
||||
|
||||
def assignment_attributes(assignment)
|
||||
return nil unless assignment.present?
|
||||
|
@ -295,15 +129,6 @@ module ConditionalRelease
|
|||
}
|
||||
end
|
||||
|
||||
def headers_for(context, user, domain, session)
|
||||
jwt = jwt_for(context, user, domain, session: session)
|
||||
{"Authorization" => "Bearer #{jwt}"}
|
||||
end
|
||||
|
||||
def domain_for(context)
|
||||
Context.get_account(context).root_account.domain
|
||||
end
|
||||
|
||||
def submissions_for(student, context, force: false)
|
||||
return [] unless student.present?
|
||||
Rails.cache.fetch(submissions_cache_key(student), force: force) do
|
||||
|
@ -327,32 +152,8 @@ module ConditionalRelease
|
|||
end
|
||||
end
|
||||
|
||||
def rules_data(context, student, session = {})
|
||||
return [] if context.blank? || student.blank?
|
||||
if natively_enabled_for_account?(context.root_account)
|
||||
return native_rules_data_for_student(context, student)
|
||||
end
|
||||
|
||||
cached = rules_cache(context, student)
|
||||
assignments = assignments_for(cached[:rules]) if cached
|
||||
force_cache = rules_cache_expired?(context, cached)
|
||||
rules_data = rules_cache(context, student, force: force_cache) do
|
||||
data = { submissions: submissions_for(student, context, force: force_cache) }
|
||||
headers = headers_for(context, student, domain_for(context), session)
|
||||
req = request_rules(headers, data)
|
||||
{rules: req, updated_at: Time.zone.now}
|
||||
end
|
||||
rules_data[:rules] = merge_assignment_data(rules_data[:rules], assignments)
|
||||
rules_data[:rules]
|
||||
rescue ConditionalRelease::ServiceError => e
|
||||
InstStatsd::Statsd.increment("conditional_release_service_error",
|
||||
short_stat: 'conditional_release_service_error',
|
||||
tags: { type: 'rules_data' })
|
||||
Canvas::Errors.capture(e, course_id: context.global_id, user_id: student.global_id)
|
||||
[]
|
||||
end
|
||||
|
||||
def native_rules_data_for_student(course, student)
|
||||
def rules_data(course, student, session = {})
|
||||
return [] if course.blank? || student.blank?
|
||||
rules_data =
|
||||
::Rails.cache.fetch(['conditional_release_rules_for_student', student.cache_key(:submissions), course.cache_key(:conditional_release)].cache_key) do
|
||||
rules = course.conditional_release_rules.active.preload(Rule.preload_associations).to_a
|
||||
|
@ -420,23 +221,6 @@ module ConditionalRelease
|
|||
end
|
||||
end
|
||||
|
||||
def request_rules(headers, data)
|
||||
req = CanvasHttp.post(rules_summary_url, headers, form_data: data.to_param)
|
||||
|
||||
if req && req.code == '200'
|
||||
JSON.parse(req.body)
|
||||
else
|
||||
InstStatsd::Statsd.increment("conditional_release_service_error",
|
||||
short_stat: 'conditional_release_service_error',
|
||||
tags: { type: 'applied_rules' })
|
||||
message = "error fetching applied rules #{req}"
|
||||
raise ServiceError, message
|
||||
end
|
||||
rescue => e
|
||||
raise if e.is_a? ServiceError
|
||||
raise ServiceError, e.inspect
|
||||
end
|
||||
|
||||
def assignments_for(response)
|
||||
rules = response.map(&:deep_symbolize_keys)
|
||||
|
||||
|
|
|
@ -1,110 +0,0 @@
|
|||
#
|
||||
# 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 'canvas_http'
|
||||
|
||||
module ConditionalRelease
|
||||
class ServiceRequestError < StandardError; end
|
||||
|
||||
TOKEN_PURPOSE = "Conditional Release Service API Token"
|
||||
API_USER_NAME = "Conditional Release API"
|
||||
API_SORTABLE_NAME = "API, Conditional Release"
|
||||
|
||||
class Setup
|
||||
def initialize(account_id, user_id)
|
||||
@account = Account.find(account_id)
|
||||
@root_account = @account.root_account
|
||||
@domain = @account.domain
|
||||
@user = User.find(user_id)
|
||||
|
||||
# Fetch any existing API user account via unique pseudonym
|
||||
@pseudonym = Pseudonym.active.where(account_id: @root_account.id, unique_id: ConditionalRelease::Service.unique_id).first
|
||||
@api_user = @pseudonym.user if @pseudonym.present?
|
||||
@token = @api_user.access_tokens.find_by(purpose: TOKEN_PURPOSE) if @api_user.present?
|
||||
end
|
||||
|
||||
def activate!
|
||||
return unless ConditionalRelease::Service.service_configured?
|
||||
|
||||
if @pseudonym.blank? || @token.blank?
|
||||
@token = create_token!
|
||||
|
||||
@payload = {
|
||||
external_account_id: @root_account.lti_guid.to_s,
|
||||
auth_token: @token,
|
||||
account_domains: [{ host: @domain }],
|
||||
}
|
||||
|
||||
@jwt = ConditionalRelease::Service.jwt_for(@account, @user, @domain)
|
||||
|
||||
self.send_later_enqueue_args(:post_to_service, max_attempts: 1)
|
||||
end
|
||||
|
||||
rescue => e
|
||||
Rails.logger.error e
|
||||
undo_changes!
|
||||
raise e
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_token!
|
||||
# Creates an API user for the Conditional Release service
|
||||
# auth token is needed to make requests between
|
||||
# the service and Canvas.
|
||||
unless @pseudonym.present?
|
||||
@api_user = User.new(name: API_USER_NAME, sortable_name: API_SORTABLE_NAME)
|
||||
@api_user.workflow_state = "registered"
|
||||
|
||||
@pseudonym = @api_user.pseudonyms.build(account: @root_account, unique_id: ConditionalRelease::Service.unique_id)
|
||||
@pseudonym.workflow_state = "active"
|
||||
@pseudonym.user = @api_user
|
||||
@api_user.save!
|
||||
|
||||
# Make it an admin
|
||||
admin = @root_account.account_users.build
|
||||
admin.user = @api_user
|
||||
admin.save! if admin.valid?
|
||||
end
|
||||
|
||||
# Generate the token to send to the Conditional Release service
|
||||
unless @token.present?
|
||||
@token = @api_user.access_tokens.build(purpose: TOKEN_PURPOSE)
|
||||
@token.save!
|
||||
end
|
||||
|
||||
@token.full_token
|
||||
end
|
||||
|
||||
def post_to_service
|
||||
res = CanvasHttp.post(ConditionalRelease::Service.create_account_url, {
|
||||
"Authorization" => "Bearer #{@jwt}"
|
||||
}, form_data: @payload.to_param)
|
||||
raise ConditionalRelease::ServiceRequestError, res unless res.kind_of?(Net::HTTPSuccess)
|
||||
rescue => e
|
||||
Rails.logger.error e
|
||||
undo_changes!
|
||||
raise e
|
||||
end
|
||||
|
||||
def undo_changes!
|
||||
@account.disable_feature! :conditional_release
|
||||
@pseudonym.destroy! if @pseudonym
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1898,9 +1898,6 @@ class Submission < ActiveRecord::Base
|
|||
self.shard.activate do
|
||||
return unless self.graded? && self.posted?
|
||||
# use request caches to handle n+1's when updating a lot of submissions in the same course in one request
|
||||
return unless RequestCache.cache('conditional_release_native', self.root_account_id) do
|
||||
ConditionalRelease::Service.natively_enabled_for_account?(self.root_account)
|
||||
end
|
||||
return unless RequestCache.cache('conditional_release_feature_enabled', self.course_id) do
|
||||
self.course.feature_enabled?(:conditional_release)
|
||||
end
|
||||
|
|
|
@ -1,9 +0,0 @@
|
|||
development:
|
||||
# required to enable the feature. default is false.
|
||||
enabled: false
|
||||
|
||||
# required. host of the url to the conditional release service. no default.
|
||||
host: conditional_release.docker
|
||||
|
||||
# optional. default matches Canvas
|
||||
protocol: https
|
|
@ -268,7 +268,6 @@ conditional_release:
|
|||
results.
|
||||
applies_to: Course
|
||||
root_opt_in: true
|
||||
after_state_change_proc: conditional_release_after_state_change_hook
|
||||
wrap_calendar_event_titles:
|
||||
state: allowed
|
||||
display_name: Wrap event titles in Calendar month view
|
||||
|
|
|
@ -62,20 +62,6 @@ module FeatureFlags
|
|||
root_account.settings&.dig(:provision, 'lti').present?
|
||||
end
|
||||
|
||||
def self.conditional_release_after_state_change_hook(user, context, _old_state, new_state)
|
||||
if %w(on allowed).include?(new_state) && context.is_a?(Account)
|
||||
if ConditionalRelease::Service.prefer_native? || ConditionalRelease::Service.natively_enabled_for_account?(context.root_account)
|
||||
context.root_account.tap do |ra|
|
||||
ra.settings[:use_native_conditional_release] = true
|
||||
ra.save!
|
||||
end
|
||||
else
|
||||
@service_account = ConditionalRelease::Setup.new(context.id, user.id)
|
||||
@service_account.activate!
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def self.analytics_2_after_state_change_hook(_user, context, _old_state, _new_state)
|
||||
# if we clear the nav cache before HAStore clears, it can be recached with stale FF data
|
||||
nav_cache = Lti::NavigationCache.new(context.root_account)
|
||||
|
|
|
@ -993,7 +993,6 @@ describe "Module Items API", type: :request do
|
|||
describe 'POST select_mastery_path' do
|
||||
before do
|
||||
allow(ConditionalRelease::Service).to receive(:enabled_in_context?).and_return(true)
|
||||
allow(ConditionalRelease::Service).to receive(:select_mastery_path).and_return({ code: '200', body: {} })
|
||||
student_in_course(course: @course)
|
||||
end
|
||||
|
||||
|
@ -1025,32 +1024,14 @@ describe "Module Items API", type: :request do
|
|||
expect(json['message']).to match(/assignment/)
|
||||
end
|
||||
|
||||
it 'should return the CYOE error if the action is unsuccessful' do
|
||||
allow(ConditionalRelease::Service).to receive(:select_mastery_path).and_return({ code: '909', body: { 'foo' => 'bar' } })
|
||||
json = call_select_mastery_path @assignment_tag, 100, @student.id, expected_status: 909
|
||||
expect(json).to eq({ 'foo' => 'bar' })
|
||||
end
|
||||
|
||||
it 'should not allow unpublished items' do
|
||||
@assignment.unpublish!
|
||||
call_select_mastery_path @assignment_tag, 100, @student.id, expected_status: 404
|
||||
end
|
||||
|
||||
it 'should call the native selection path if configured' do
|
||||
expect(ConditionalRelease::Service).to receive(:natively_enabled_for_account?).and_return(true)
|
||||
|
||||
assignment_ids = create_assignments([@course.id], 2)
|
||||
expect(ConditionalRelease::OverrideHandler).to receive(:handle_assignment_set_selection).
|
||||
with(@student, @assignment, "100").and_return(assignment_ids)
|
||||
json = call_select_mastery_path @assignment_tag, "100", @student.id, expected_status: 200
|
||||
expect(json['assignments'].map {|a| a['id']}).to eq assignment_ids
|
||||
end
|
||||
|
||||
context 'successful' do
|
||||
def cyoe_returns(assignment_ids)
|
||||
cyoe_ids = assignment_ids.map {|id| { 'assignment_id' => "#{id}" }} # cyoe ids in strings
|
||||
cyoe_response = { 'assignments' => cyoe_ids }
|
||||
allow(ConditionalRelease::Service).to receive(:select_mastery_path).and_return({ code: '200', body: cyoe_response })
|
||||
expect(ConditionalRelease::OverrideHandler).to receive(:handle_assignment_set_selection).and_return(assignment_ids)
|
||||
end
|
||||
|
||||
it 'should return a list of assignments if the action is successful' do
|
||||
|
@ -1213,7 +1194,7 @@ describe "Module Items API", type: :request do
|
|||
rules = item.deep_symbolize_keys
|
||||
return false unless rules[:mastery_paths].present?
|
||||
rules[:mastery_paths][:assignment_sets].find do |set|
|
||||
set[:assignments].find do |asg|
|
||||
set[:assignment_set_associations].find do |asg|
|
||||
asg.key? :model
|
||||
end
|
||||
end
|
||||
|
@ -1234,42 +1215,16 @@ describe "Module Items API", type: :request do
|
|||
:indent => 1, :updated_at => nil).publish!
|
||||
mod.publish
|
||||
end
|
||||
end
|
||||
|
||||
before :each do
|
||||
@resp = [{
|
||||
locked: false,
|
||||
trigger_assignment: @quiz.assignment_id,
|
||||
assignment_sets: [{
|
||||
id: 1,
|
||||
scoring_range_id: 1,
|
||||
created_at: @assignment.created_at,
|
||||
updated_at: @assignment.updated_at,
|
||||
position: 1,
|
||||
assignments: [{
|
||||
id: 1,
|
||||
assignment_id: @assignment.id,
|
||||
created_at: @assignment.created_at,
|
||||
updated_at: @assignment.updated_at,
|
||||
assignment_set_id: 1,
|
||||
position: 1
|
||||
}]
|
||||
}]
|
||||
}]
|
||||
allow(ConditionalRelease::Service).to receive_messages(headers_for: {}, submissions_for: [],
|
||||
domain_for: "canvas.xyz", "enabled_in_context?" => true,
|
||||
rules_summary_url: "cyoe.abc/rules", request_rules: @resp)
|
||||
end
|
||||
range = ConditionalRelease::ScoringRange.new(:lower_bound => 0.0, :upper_bound => 1.0, :assignment_sets => [
|
||||
ConditionalRelease::AssignmentSet.new(:assignment_set_associations => [
|
||||
ConditionalRelease::AssignmentSetAssociation.new(:assignment_id => @assignment.id)
|
||||
])
|
||||
])
|
||||
@cyoe_rule = @course.conditional_release_rules.create!(:trigger_assignment_id => @quiz.assignment_id, :scoring_ranges => [range])
|
||||
@course.enable_feature!(:conditional_release)
|
||||
|
||||
describe "CYOE interaction" do
|
||||
it "makes a request to the CYOE service when included" do
|
||||
expect(ConditionalRelease::Service).to receive(:request_rules).once
|
||||
|
||||
api_call(:get, "/api/v1/courses/#{@course.id}/modules/#{@cyoe_module1.id}/items?include[]=mastery_paths",
|
||||
:controller => "context_module_items_api", :action => "index", :format => "json",
|
||||
:course_id => @course.id.to_s, :module_id => @cyoe_module1.id.to_s,
|
||||
:include => ['mastery_paths'])
|
||||
end
|
||||
graded_submission(@quiz, @student)
|
||||
end
|
||||
|
||||
describe "module item list response data" do
|
||||
|
@ -1669,7 +1624,7 @@ describe "Module Items API", type: :request do
|
|||
describe 'POST select_mastery_path' do
|
||||
before do
|
||||
allow(ConditionalRelease::Service).to receive(:enabled_in_context?).and_return(true)
|
||||
allow(ConditionalRelease::Service).to receive(:select_mastery_path).and_return({ code: '200', body: { 'assignments' => [] } })
|
||||
allow(ConditionalRelease::OverrideHandler).to receive(:handle_assignment_set_selection).and_return([])
|
||||
end
|
||||
|
||||
it 'should allow a mastery path' do
|
||||
|
|
|
@ -377,8 +377,7 @@ QUnit.module('EditView - ConditionalRelease', {
|
|||
fakeENV.setup()
|
||||
ENV.CONDITIONAL_RELEASE_SERVICE_ENABLED = true
|
||||
ENV.CONDITIONAL_RELEASE_ENV = {
|
||||
assignment: {id: 1},
|
||||
jwt: 'foo'
|
||||
assignment: {id: 1}
|
||||
}
|
||||
$(document).on('submit', () => false)
|
||||
this.server = sinon.fakeServer.create({respondImmediately: true})
|
||||
|
|
|
@ -1283,8 +1283,10 @@ QUnit.module('AssignListItemViewSpec - mastery paths link', {
|
|||
CONDITIONAL_RELEASE_ENV: {
|
||||
active_rules: [
|
||||
{
|
||||
trigger_assignment: '1',
|
||||
scoring_ranges: [{assignment_sets: [{assignments: [{assignment_id: '2'}]}]}]
|
||||
trigger_assignment_id: '1',
|
||||
scoring_ranges: [
|
||||
{assignment_sets: [{assignment_set_associations: [{assignment_id: '2'}]}]}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
@ -1339,8 +1341,10 @@ QUnit.module('AssignListItemViewSpec - mastery paths icon', {
|
|||
CONDITIONAL_RELEASE_ENV: {
|
||||
active_rules: [
|
||||
{
|
||||
trigger_assignment: '1',
|
||||
scoring_ranges: [{assignment_sets: [{assignments: [{assignment_id: '2'}]}]}]
|
||||
trigger_assignment_id: '1',
|
||||
scoring_ranges: [
|
||||
{assignment_sets: [{assignment_set_associations: [{assignment_id: '2'}]}]}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
@ -111,8 +111,7 @@ QUnit.module('EditHeaderView - try deleting assignment', {
|
|||
fakeENV.setup()
|
||||
ENV.CONDITIONAL_RELEASE_SERVICE_ENABLED = true
|
||||
ENV.CONDITIONAL_RELEASE_ENV = {
|
||||
assignment: {id: 1},
|
||||
jwt: 'foo'
|
||||
assignment: {id: 1}
|
||||
}
|
||||
},
|
||||
teardown() {
|
||||
|
@ -141,8 +140,7 @@ QUnit.module('EditHeaderView - ConditionalRelease', {
|
|||
fakeENV.setup()
|
||||
ENV.CONDITIONAL_RELEASE_SERVICE_ENABLED = true
|
||||
ENV.CONDITIONAL_RELEASE_ENV = {
|
||||
assignment: {id: 1},
|
||||
jwt: 'foo'
|
||||
assignment: {id: 1}
|
||||
}
|
||||
},
|
||||
teardown() {
|
||||
|
|
|
@ -1075,7 +1075,7 @@ QUnit.module('EditView: Conditional Release', {
|
|||
fakeENV.setup({
|
||||
AVAILABLE_MODERATORS: [],
|
||||
current_user_roles: ['teacher'],
|
||||
CONDITIONAL_RELEASE_ENV: {assignment: {id: 1}, jwt: 'foo'},
|
||||
CONDITIONAL_RELEASE_ENV: {assignment: {id: 1}},
|
||||
CONDITIONAL_RELEASE_SERVICE_ENABLED: true,
|
||||
HAS_GRADED_SUBMISSIONS: false,
|
||||
LOCALE: 'en',
|
||||
|
@ -1600,7 +1600,10 @@ QUnit.module('EditView#validateGraderCount', hooks => {
|
|||
QUnit.module('EditView#renderModeratedGradingFormFieldGroup', suiteHooks => {
|
||||
let view
|
||||
let server
|
||||
const availableModerators = [{name: 'John Doe', id: '21'}, {name: 'Jane Doe', id: '89'}]
|
||||
const availableModerators = [
|
||||
{name: 'John Doe', id: '21'},
|
||||
{name: 'Jane Doe', id: '89'}
|
||||
]
|
||||
|
||||
suiteHooks.beforeEach(() => {
|
||||
fixtures.innerHTML = `
|
||||
|
|
|
@ -72,10 +72,10 @@ QUnit.module('QuizItemView', {
|
|||
CONDITIONAL_RELEASE_ENV: {
|
||||
active_rules: [
|
||||
{
|
||||
trigger_assignment: '1',
|
||||
trigger_assignment_id: '1',
|
||||
scoring_ranges: [
|
||||
{
|
||||
assignment_sets: [{assignments: [{assignment_id: '2'}]}]
|
||||
assignment_sets: [{assignment_set_associations: [{assignment_id: '2'}]}]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
@ -47,10 +47,6 @@ end
|
|||
module ConditionalRelease
|
||||
module SpecHelper
|
||||
def setup_course_with_native_conditional_release(course: nil)
|
||||
Account.default.tap do |ra|
|
||||
ra.settings[:use_native_conditional_release] = true
|
||||
ra.save!
|
||||
end
|
||||
# set up a trigger assignment with rules and whatnot
|
||||
course ||= course_with_student(:active_all => true) && @course
|
||||
@trigger_assmt = course.assignments.create!(:points_possible => 10, submission_types: "online_text_entry")
|
||||
|
|
|
@ -44,7 +44,7 @@ const createComponent = submitCallback => {
|
|||
component = TestUtils.renderIntoDocument(
|
||||
<ConditionalRelease.Editor env={assignmentEnv} type="foo" />
|
||||
)
|
||||
component.createOldEditor()
|
||||
component.createNativeEditor()
|
||||
}
|
||||
|
||||
const makePromise = () => {
|
||||
|
@ -55,9 +55,9 @@ const makePromise = () => {
|
|||
}
|
||||
|
||||
let ajax = null
|
||||
const assignmentEnv = {assignment: {id: 1}, editor_url: 'editorurl', jwt: 'foo'}
|
||||
const noAssignmentEnv = {edit_rule_url: 'about:blank', jwt: 'foo'}
|
||||
const assignmentNoIdEnv = {assignment: {foo: 'bar'}, edit_rule_url: 'about:blank', jwt: 'foo'}
|
||||
const assignmentEnv = {assignment: {id: 1}, course_id: 1}
|
||||
const noAssignmentEnv = {edit_rule_url: 'about:blank'}
|
||||
const assignmentNoIdEnv = {assignment: {foo: 'bar'}, course_id: 1}
|
||||
|
||||
QUnit.module('Conditional Release component', {
|
||||
setup: () => {
|
||||
|
@ -78,11 +78,6 @@ QUnit.module('Conditional Release component', {
|
|||
}
|
||||
})
|
||||
|
||||
test('it loads a cyoe editor on mount', () => {
|
||||
ok(ajax.calledOnce)
|
||||
ok(ajax.calledWithMatch({url: 'editorurl'}))
|
||||
})
|
||||
|
||||
test('it creates a cyoe editor', () => {
|
||||
ok(editor.attach.calledOnce)
|
||||
})
|
||||
|
|
|
@ -25,7 +25,7 @@ const cyoeEnv = () => ({
|
|||
CONDITIONAL_RELEASE_ENV: {
|
||||
active_rules: [
|
||||
{
|
||||
trigger_assignment: '1',
|
||||
trigger_assignment_id: '1',
|
||||
trigger_assignment_model: {
|
||||
grading_type: 'percentage'
|
||||
},
|
||||
|
@ -33,7 +33,7 @@ const cyoeEnv = () => ({
|
|||
{
|
||||
upper_bound: 1,
|
||||
lower_bound: 0.7,
|
||||
assignment_sets: [{assignments: [{assignment_id: '2'}]}]
|
||||
assignment_sets: [{assignment_set_associations: [{assignment_id: '2'}]}]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
@ -138,7 +138,7 @@ QUnit.module('CYOE Helper', () => {
|
|||
let itemData = CyoeHelper.getItemData('1')
|
||||
ok(itemData.isTrigger)
|
||||
|
||||
env.CONDITIONAL_RELEASE_ENV.active_rules[0].trigger_assignment = '2'
|
||||
env.CONDITIONAL_RELEASE_ENV.active_rules[0].trigger_assignment_id = '2'
|
||||
setEnv(env)
|
||||
|
||||
itemData = CyoeHelper.getItemData('1')
|
||||
|
|
|
@ -1,195 +0,0 @@
|
|||
#
|
||||
# Copyright (C) 2020 - 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_relative '../../conditional_release_spec_helper'
|
||||
require_dependency "conditional_release/assimilator"
|
||||
|
||||
module ConditionalRelease
|
||||
describe Assimilator do
|
||||
it "should import all the stuff" do
|
||||
Account.default.enable_feature!(:conditional_release)
|
||||
|
||||
course_factory(:active_all => true)
|
||||
students = n_students_in_course(3, :course => @course)
|
||||
trigger_assmt = @course.assignments.create!(:points_possible => 10, submission_types: "online_text_entry")
|
||||
subs = students.map{|s| trigger_assmt.submit_homework(s, body: "hi")}
|
||||
|
||||
set1_assmt = @course.assignments.create!(:only_visible_to_overrides => true) # one in one set
|
||||
set2_assmt = @course.assignments.create!(:only_visible_to_overrides => true)
|
||||
set3a_assmt = @course.assignments.create!(:only_visible_to_overrides => true) # two sets in one range - will have to choose
|
||||
set3b_assmt = @course.assignments.create!(:only_visible_to_overrides => true)
|
||||
|
||||
# service ids are arbitary
|
||||
set1_id = 9001
|
||||
set3a_id = 42
|
||||
service_data = [{
|
||||
"id" => 3,
|
||||
"account_id" => Account.default.global_id,
|
||||
"course_id" => @course.id.to_s,
|
||||
"trigger_assignment" => trigger_assmt.id.to_s,
|
||||
"created_at" => "2020-06-30T14:12:05.132Z",
|
||||
"updated_at" => "2020-06-30T14:12:05.132Z",
|
||||
"scoring_ranges" =>
|
||||
[{"id" => 7,
|
||||
"rule_id" => 3,
|
||||
"lower_bound" => "0.8",
|
||||
"upper_bound" => nil,
|
||||
"created_at" => "2020-06-30T14:12:05.134Z",
|
||||
"updated_at" => "2020-06-30T14:12:05.134Z",
|
||||
"position" => 1,
|
||||
"assignment_sets" =>
|
||||
[{"id" => set1_id,
|
||||
"scoring_range_id" => 7,
|
||||
"created_at" => "2020-06-30T14:12:05.138Z",
|
||||
"updated_at" => "2020-06-30T14:12:05.138Z",
|
||||
"position" => 1,
|
||||
"deleted_at" => nil,
|
||||
"assignments" =>
|
||||
[{"id" => 1,
|
||||
"assignment_id" => set1_assmt.id.to_s,
|
||||
"created_at" => "2020-06-30T14:12:05.142Z",
|
||||
"updated_at" => "2020-06-30T14:12:05.142Z",
|
||||
"override_id" => nil,
|
||||
"assignment_set_id" => 7,
|
||||
"position" => 1,
|
||||
"deleted_at" => nil}]}]},
|
||||
{"id" => 8,
|
||||
"rule_id" => 3,
|
||||
"lower_bound" => "0.3",
|
||||
"upper_bound" => "0.8",
|
||||
"created_at" => "2020-06-30T14:12:05.157Z",
|
||||
"updated_at" => "2020-06-30T14:12:05.157Z",
|
||||
"position" => 2,
|
||||
"deleted_at" => nil,
|
||||
"assignment_sets" =>
|
||||
[{"id" => 9,
|
||||
"scoring_range_id" => 8,
|
||||
"created_at" => "2020-06-30T14:12:05.162Z",
|
||||
"updated_at" => "2020-06-30T14:12:05.162Z",
|
||||
"position" => 1,
|
||||
"deleted_at" => nil,
|
||||
"assignments" =>
|
||||
[{"id" => 3,
|
||||
"assignment_id" => set2_assmt.id.to_s,
|
||||
"created_at" => "2020-06-30T14:12:05.168Z",
|
||||
"updated_at" => "2020-06-30T14:12:05.168Z",
|
||||
"override_id" => 17,
|
||||
"assignment_set_id" => 9,
|
||||
"position" => 1,
|
||||
"deleted_at" => nil}]}]},
|
||||
{"id" => 9,
|
||||
"rule_id" => 3,
|
||||
"lower_bound" => nil,
|
||||
"upper_bound" => "0.3",
|
||||
"created_at" => "2020-06-30T14:12:05.174Z",
|
||||
"updated_at" => "2020-06-30T14:12:05.174Z",
|
||||
"position" => 3,
|
||||
"deleted_at" => nil,
|
||||
"assignment_sets" =>
|
||||
[{"id" => set3a_id,
|
||||
"scoring_range_id" => 9,
|
||||
"created_at" => "2020-06-30T14:12:05.183Z",
|
||||
"updated_at" => "2020-06-30T14:12:05.183Z",
|
||||
"position" => 1,
|
||||
"deleted_at" => nil,
|
||||
"assignments" =>
|
||||
[{
|
||||
"id" => 3,
|
||||
"assignment_id" => set3a_assmt.id.to_s,
|
||||
"created_at" => "2020-06-30T14:12:05.168Z",
|
||||
"updated_at" => "2020-06-30T14:12:05.168Z",
|
||||
"override_id" => 17,
|
||||
"assignment_set_id" => 9,
|
||||
"position" => 1,
|
||||
"deleted_at" => nil
|
||||
}]},
|
||||
{"id" => 9,
|
||||
"scoring_range_id" => 9,
|
||||
"created_at" => "2020-06-30T14:12:05.183Z",
|
||||
"updated_at" => "2020-06-30T14:12:05.183Z",
|
||||
"position" => 2,
|
||||
"deleted_at" => nil,
|
||||
"assignments" =>
|
||||
[{
|
||||
"id" => 3,
|
||||
"assignment_id" => set3b_assmt.id.to_s,
|
||||
"created_at" => "2020-06-30T14:12:05.168Z",
|
||||
"updated_at" => "2020-06-30T14:12:05.168Z",
|
||||
"override_id" => 17,
|
||||
"assignment_set_id" => 9,
|
||||
"position" => 1,
|
||||
"deleted_at" => nil
|
||||
}]}
|
||||
]}],
|
||||
"assignment_set_actions" =>
|
||||
[{
|
||||
"id" => 1,
|
||||
"action" => "assign",
|
||||
"source" => "grade_change",
|
||||
"student_id" => students[0].id.to_s,
|
||||
"actor_id" => @teacher.id.to_s,
|
||||
"assignment_set_id" => set1_id,
|
||||
"created_at" => "2020-06-30T14:46:34.286Z",
|
||||
"updated_at" => "2020-06-30T14:46:34.286Z",
|
||||
"deleted_at" => nil
|
||||
},
|
||||
{
|
||||
"id" => 2,
|
||||
"action" => "assign",
|
||||
"source" => "select_assignment_set",
|
||||
"student_id" => students[1].id.to_s,
|
||||
"actor_id" => @teacher.id.to_s,
|
||||
"assignment_set_id" => set3a_id,
|
||||
"created_at" => "2020-06-30T14:46:34.286Z",
|
||||
"updated_at" => "2020-06-30T14:46:34.286Z",
|
||||
"deleted_at" => nil
|
||||
}]
|
||||
}]
|
||||
expect(ConditionalRelease::Assimilator).to receive(:retrieve_rules_data_from_service).with(Account.default).and_return(service_data)
|
||||
|
||||
trigger_assmt.grade_student(students[2], grade: 5, grader: @teacher)
|
||||
Submission.where(:id => subs[2]).update_all(:updated_at => 1.minute.from_now) # make sure it gets pulled in the post-migrate resync query
|
||||
|
||||
ConditionalRelease::Assimilator.run(Account.default)
|
||||
|
||||
rule = trigger_assmt.conditional_release_rules.first
|
||||
expect(rule.scoring_ranges.count).to eq 3
|
||||
expect(rule.scoring_ranges.map{|r| r.upper_bound}).to eq [nil, 0.8, 0.3]
|
||||
expect(rule.scoring_ranges.map{|r| r.lower_bound}).to eq [0.8, 0.3, nil]
|
||||
range_assmts = rule.scoring_ranges.map{|r| r.assignment_sets.map{|s| s.assignment_set_associations.map(&:assignment_id)}}
|
||||
expect(range_assmts[0]).to eq [[set1_assmt.id]]
|
||||
expect(range_assmts[1]).to eq [[set2_assmt.id]]
|
||||
expect(range_assmts[2]).to eq [[set3a_assmt.id], [set3b_assmt.id]]
|
||||
set_actions = students.map{|s| AssignmentSetAction.where(:student_id => s).first}
|
||||
expect(set_actions[0].assignment_set.assignment_set_associations.map(&:assignment_id)).to eq [set1_assmt.id] # top set
|
||||
expect(set_actions[0].source).to eq "grade_change"
|
||||
expect(set_actions[1].assignment_set.assignment_set_associations.map(&:assignment_id)).to eq [set3a_assmt.id] # bottom row first set selection
|
||||
expect(set_actions[1].source).to eq "select_assignment_set"
|
||||
expect(set_actions[2].assignment_set.assignment_set_associations.map(&:assignment_id)).to eq [set2_assmt.id] # middle set - got picked up in post import sync
|
||||
end
|
||||
|
||||
it "should rescue from errors" do
|
||||
expect(ConditionalRelease::Assimilator).to receive(:retrieve_rules_data_from_service).and_raise("aaaaa")
|
||||
|
||||
expect {
|
||||
ConditionalRelease::Assimilator.run(Account.default)
|
||||
}.to raise_error("aaaaa")
|
||||
expect(Account.default.reload.settings[:conditional_release_assimilation_failed_at]).to be_present
|
||||
expect(ConditionalRelease::Assimilator.assimilation_in_progress?(Account.default)).to eq false
|
||||
end
|
||||
end
|
||||
end
|
|
@ -22,17 +22,11 @@ require File.expand_path(File.dirname(__FILE__) + '/../../sharding_spec_helper')
|
|||
describe ConditionalRelease::Service do
|
||||
Service = ConditionalRelease::Service
|
||||
|
||||
def stub_config(*configs)
|
||||
allow(ConfigFile).to receive(:load).and_return(*configs)
|
||||
end
|
||||
|
||||
def clear_config
|
||||
Service.reset_config_cache
|
||||
end
|
||||
|
||||
def enable_service
|
||||
allow(Canvas::Security::ServicesJwt).to receive(:encryption_secret).and_return('setecastronomy92' * 2)
|
||||
allow(Canvas::Security::ServicesJwt).to receive(:signing_secret).and_return('donttell' * 10)
|
||||
allow(Service).to receive(:enabled_in_context?).and_return(true)
|
||||
end
|
||||
|
||||
|
@ -41,129 +35,45 @@ describe ConditionalRelease::Service do
|
|||
end
|
||||
|
||||
context 'configuration' do
|
||||
it 'is not configured by default' do
|
||||
stub_config(nil)
|
||||
expect(Service.service_configured?).to eq false
|
||||
end
|
||||
|
||||
it 'requires host to be configured' do
|
||||
stub_config({enabled: true})
|
||||
expect(Service.service_configured?).to eq false
|
||||
end
|
||||
|
||||
it 'is configured when enabled with host' do
|
||||
stub_config({enabled: true, host: 'foo'})
|
||||
expect(Service.service_configured?).to eq true
|
||||
end
|
||||
|
||||
it 'has a default config' do
|
||||
stub_config(nil)
|
||||
config = Service.config
|
||||
expect(config).not_to be_nil
|
||||
expect(config.size).to be > 0
|
||||
end
|
||||
|
||||
it 'defaults protocol to canvas protocol' do
|
||||
allow(HostUrl).to receive(:protocol).and_return('foo')
|
||||
stub_config(nil)
|
||||
expect(Service.protocol).to eq('foo')
|
||||
end
|
||||
|
||||
it 'overrides defaults with config file' do
|
||||
stub_config(nil, {protocol: 'foo'})
|
||||
expect(Service.config[:protocol]).not_to eql('foo')
|
||||
clear_config
|
||||
expect(Service.config[:protocol]).to eql('foo')
|
||||
end
|
||||
|
||||
it 'creates urls' do
|
||||
stub_config({
|
||||
protocol: 'foo', host: 'bar',
|
||||
create_account_path: 'some/path',
|
||||
editor_path: 'some/other/path'
|
||||
})
|
||||
expect(Service.create_account_url).to eq 'foo://bar/some/path'
|
||||
expect(Service.editor_url).to eq 'foo://bar/some/other/path'
|
||||
end
|
||||
|
||||
it 'requires feature flag to be enabled' do
|
||||
context = double({feature_enabled?: true})
|
||||
stub_config({enabled: true, host: 'foo'})
|
||||
expect(Service.enabled_in_context?(context)).to eq true
|
||||
end
|
||||
|
||||
it 'reports enabled as true when enabled' do
|
||||
context = double({feature_enabled?: true})
|
||||
stub_config({enabled: true, host: 'foo'})
|
||||
env = Service.env_for(context)
|
||||
expect(env[:CONDITIONAL_RELEASE_SERVICE_ENABLED]).to eq true
|
||||
end
|
||||
|
||||
it 'reports enabled as false if feature flag is off' do
|
||||
context = double({feature_enabled?: false})
|
||||
stub_config({enabled: true, host: 'foo'})
|
||||
env = Service.env_for(context)
|
||||
expect(env[:CONDITIONAL_RELEASE_SERVICE_ENABLED]).to eq false
|
||||
end
|
||||
|
||||
it 'reports enabled as false if service is disabled (and the root account has native config disabled)' do
|
||||
context = course_factory
|
||||
context.enable_feature!(:conditional_release)
|
||||
stub_config({enabled: false})
|
||||
env = Service.env_for(context)
|
||||
expect(env[:CONDITIONAL_RELEASE_SERVICE_ENABLED]).to eq false
|
||||
end
|
||||
|
||||
it 'reports enabled as true if service is disabled (but the root account has native config enabled)' do
|
||||
context = course_factory
|
||||
context.enable_feature!(:conditional_release)
|
||||
context.root_account.tap do |a|
|
||||
a.settings[:use_native_conditional_release] = true
|
||||
a.save!
|
||||
end
|
||||
stub_config({enabled: false})
|
||||
env = Service.env_for(context, User.new)
|
||||
expect(env[:CONDITIONAL_RELEASE_SERVICE_ENABLED]).to eq true
|
||||
expect(env[:CONDITIONAL_RELEASE_ENV][:native]).to eq true
|
||||
end
|
||||
end
|
||||
|
||||
describe 'env_for' do
|
||||
before do
|
||||
enable_service
|
||||
stub_config({
|
||||
protocol: 'foo', host: 'bar', rules_path: 'rules'
|
||||
})
|
||||
allow(Service).to receive(:active_rules).and_return([])
|
||||
course_with_student(active_all: true)
|
||||
end
|
||||
|
||||
it 'returns no jwt or env if not enabled' do
|
||||
it 'returns no env if not enabled' do
|
||||
allow(Service).to receive(:enabled_in_context?).and_return(false)
|
||||
env = Service.env_for(@course, @student, domain: 'foo.bar')
|
||||
env = Service.env_for(@course, @student)
|
||||
expect(env).not_to have_key :CONDITIONAL_RELEASE_ENV
|
||||
end
|
||||
|
||||
it 'returns no jwt or env if user not specified' do
|
||||
it 'returns no env if user not specified' do
|
||||
env = Service.env_for(@course)
|
||||
expect(env).not_to have_key :CONDITIONAL_RELEASE_ENV
|
||||
end
|
||||
|
||||
it 'returns an env with jwt if everything enabled' do
|
||||
allow(Service).to receive(:jwt_for).and_return(:jwt)
|
||||
env = Service.env_for(@course, @student, domain: 'foo.bar')
|
||||
expect(env[:CONDITIONAL_RELEASE_ENV][:jwt]).to eq :jwt
|
||||
end
|
||||
|
||||
it 'returns an env with current locale' do
|
||||
allow(I18n).to receive(:locale).and_return('en-PI')
|
||||
env = Service.env_for(@course, @student, domain: 'foo.bar')
|
||||
expect(env[:CONDITIONAL_RELEASE_ENV][:locale]).to eq 'en-PI'
|
||||
it 'returns an env if everything enabled' do
|
||||
env = Service.env_for(@course, @student)
|
||||
expect(env[:CONDITIONAL_RELEASE_ENV][:stats_url]).to eq "/api/v1/courses/#{@course.id}/mastery_paths/stats"
|
||||
end
|
||||
|
||||
it 'includes assignment data when an assignment is specified' do
|
||||
assignment_model course: @course
|
||||
env = Service.env_for(@course, @student, domain: 'foo.bar', assignment: @assignment)
|
||||
env = Service.env_for(@course, @student, assignment: @assignment)
|
||||
cr_env = env[:CONDITIONAL_RELEASE_ENV]
|
||||
expect(cr_env[:assignment][:id]).to eq @assignment.id
|
||||
expect(cr_env[:assignment][:title]).to eq @assignment.title
|
||||
|
@ -174,14 +84,14 @@ describe ConditionalRelease::Service do
|
|||
|
||||
it 'includes a grading scheme when assignment uses it' do
|
||||
assignment_model course: @course, grading_type: 'letter_grade'
|
||||
env = Service.env_for(@course, @student, domain: 'foo.bar', assignment: @assignment)
|
||||
env = Service.env_for(@course, @student, assignment: @assignment)
|
||||
cr_env = env[:CONDITIONAL_RELEASE_ENV]
|
||||
expect(cr_env[:assignment][:grading_scheme]).not_to be_nil
|
||||
end
|
||||
|
||||
it 'does not include a grading scheme when the assignment does not use it' do
|
||||
assignment_model course: @course, grading_type: 'points'
|
||||
env = Service.env_for(@course, @student, domain: 'foo.bar', assignment: @assignment)
|
||||
env = Service.env_for(@course, @student, assignment: @assignment)
|
||||
cr_env = env[:CONDITIONAL_RELEASE_ENV]
|
||||
expect(cr_env[:assignment][:grading_scheme]).to be_nil
|
||||
end
|
||||
|
@ -189,7 +99,7 @@ describe ConditionalRelease::Service do
|
|||
it 'includes a relevant rule if includes :rule' do
|
||||
assignment_model course: @course
|
||||
allow(Service).to receive(:rule_triggered_by).and_return(nil)
|
||||
env = Service.env_for(@course, @student, domain: 'foo.bar', assignment: @assignment, includes: [:rule])
|
||||
env = Service.env_for(@course, @student, assignment: @assignment, includes: [:rule])
|
||||
cr_env = env[:CONDITIONAL_RELEASE_ENV]
|
||||
expect(cr_env).to have_key :rule
|
||||
end
|
||||
|
@ -197,541 +107,12 @@ describe ConditionalRelease::Service do
|
|||
it 'includes a active rules if includes :active_rules' do
|
||||
assignment_model course: @course
|
||||
allow(Service).to receive(:rule_triggered_by).and_return(nil)
|
||||
env = Service.env_for(@course, @student, domain: 'foo.bar', assignment: @assignment, includes: [:active_rules])
|
||||
env = Service.env_for(@course, @student, assignment: @assignment, includes: [:active_rules])
|
||||
cr_env = env[:CONDITIONAL_RELEASE_ENV]
|
||||
expect(cr_env).to have_key :active_rules
|
||||
end
|
||||
end
|
||||
|
||||
describe 'jwt_for' do
|
||||
before do
|
||||
enable_service
|
||||
end
|
||||
|
||||
def get_claims(jwt)
|
||||
Canvas::Security::ServicesJwt.new(jwt, false).original_token
|
||||
end
|
||||
|
||||
it 'returns a student jwt for a student viewing a course' do
|
||||
course_with_student(active_all: true)
|
||||
jwt = Service.jwt_for(@course, @student, 'foo.bar')
|
||||
claims = get_claims jwt
|
||||
expect(claims[:sub]).to eq @student.id.to_s
|
||||
expect(claims[:role]).to eq 'student'
|
||||
expect(claims[:context_id]).to eq @course.id.to_s
|
||||
expect(claims[:context_type]).to eq 'Course'
|
||||
expect(claims[:account_id]).to eq Account.default.lti_guid.to_s
|
||||
expect(claims[:domain]).to eq 'foo.bar'
|
||||
end
|
||||
|
||||
it 'returns a teacher jwt for a teacher viewing a course' do
|
||||
course_with_teacher(active_all: true)
|
||||
jwt = Service.jwt_for(@course, @user, 'foo.bar')
|
||||
claims = get_claims jwt
|
||||
expect(claims[:sub]).to eq @user.id.to_s
|
||||
expect(claims[:role]).to eq 'teacher'
|
||||
end
|
||||
|
||||
it 'returns an admin jwt for an admin viewing a course' do
|
||||
course_factory
|
||||
account_admin_user
|
||||
jwt = Service.jwt_for(@course, @admin, 'foo.bar')
|
||||
claims = get_claims jwt
|
||||
expect(claims[:sub]).to eq @admin.id.to_s
|
||||
expect(claims[:role]).to eq 'admin'
|
||||
end
|
||||
|
||||
it 'returns a no-role jwt for a non-associated user viewing a course' do
|
||||
teacher_in_course
|
||||
course_factory # redefines @course
|
||||
jwt = Service.jwt_for(@course, @user, 'foo.bar')
|
||||
claims = get_claims jwt
|
||||
expect(claims[:sub]).to eq @user.id.to_s
|
||||
expect(claims[:role]).to be_nil
|
||||
end
|
||||
|
||||
it 'returns an admin jwt for an admin viewing an account' do
|
||||
account_admin_user
|
||||
jwt = Service.jwt_for(Account.default, @admin, 'foo.bar')
|
||||
claims = get_claims jwt
|
||||
expect(claims[:sub]).to eq @admin.id.to_s
|
||||
expect(claims[:context_id]).to eq Account.default.id.to_s
|
||||
expect(claims[:context_type]).to eq 'Account'
|
||||
expect(claims[:role]).to eq 'admin'
|
||||
end
|
||||
|
||||
it 'returns a no-role jwt for an admin viewing a different account' do
|
||||
account_admin_user
|
||||
account_model # redefines @account
|
||||
jwt = Service.jwt_for(@account, @admin, 'foo.bar')
|
||||
claims = get_claims jwt
|
||||
expect(claims[:role]).to be_nil
|
||||
end
|
||||
|
||||
it 'succeeds when a session is specified' do
|
||||
course_with_student(active_all: true)
|
||||
session = { permissions_key: 'foobar' }
|
||||
jwt = Service.jwt_for(@course, @student, 'foo.bar', session: session)
|
||||
claims = get_claims jwt
|
||||
expect(claims[:role]).to eq 'student'
|
||||
end
|
||||
|
||||
it 'includes a canvas auth jwt' do
|
||||
course_with_student(active_all: true)
|
||||
jwt = Service.jwt_for(@course, @student, 'foo.bar')
|
||||
claims = get_claims jwt
|
||||
expect(claims[:canvas_token]).not_to be nil
|
||||
canvas_claims = get_claims claims[:canvas_token]
|
||||
expect(canvas_claims[:workflows]).to eq ['conditional-release']
|
||||
expect(canvas_claims[:sub]).to eq @student.global_id
|
||||
expect(canvas_claims[:domain]).to eq 'foo.bar'
|
||||
end
|
||||
end
|
||||
|
||||
describe 'select_mastery_path' do
|
||||
before do
|
||||
enable_service
|
||||
course_with_student_submissions(active_all: true)
|
||||
|
||||
@assignment = Assignment.first
|
||||
@assignment.unmute!
|
||||
|
||||
@submission = @assignment.submission_for_student(@student)
|
||||
@submission.workflow_state = 'graded'
|
||||
@submission.score = 10
|
||||
@submission.save!
|
||||
end
|
||||
|
||||
def expect_select_mastery_path_request(expected_params = {})
|
||||
expect(CanvasHttp).to receive(:post) do |url, _headers, body|
|
||||
expect(url).to eq Service.select_assignment_set_url
|
||||
parsed = Rack::Utils.parse_query(body[:form_data])
|
||||
expect(expected_params.all?{|k,v| parsed[k] == v}).to be_truthy
|
||||
double(code: '200', body: { key: 'value' }.to_json)
|
||||
end
|
||||
end
|
||||
|
||||
it 'make http request to service' do
|
||||
expect_select_mastery_path_request
|
||||
result = Service.select_mastery_path(@course, @student, @student, @assignment, 200, nil)
|
||||
expect(result).to eq({ code: '200', body: { 'key' => 'value' } })
|
||||
end
|
||||
|
||||
it 'includes assignment info in service request' do
|
||||
@assignment.points_possible = 99
|
||||
@assignment.save!
|
||||
@submission.score = 20
|
||||
@submission.save!
|
||||
expect_select_mastery_path_request({
|
||||
trigger_assignment: @assignment.id.to_s,
|
||||
trigger_assignment_score: "20.0",
|
||||
trigger_assignment_points_possible: "99.0"
|
||||
}.stringify_keys)
|
||||
result = Service.select_mastery_path(@course, @student, @student, @assignment, 200, nil)
|
||||
expect(result).to eq({ code: '200', body: { 'key' => 'value' } })
|
||||
end
|
||||
|
||||
it 'clears rules cache' do
|
||||
expect_select_mastery_path_request
|
||||
expect(Service).to receive(:clear_rules_cache_for).with(@course, @student)
|
||||
Service.select_mastery_path(@course, @student, @student, @assignment, 200, nil)
|
||||
end
|
||||
|
||||
it 'fails for muted assignments' do
|
||||
@assignment.mute!
|
||||
expect(CanvasHttp).to receive(:post).never
|
||||
response = Service.select_mastery_path(@course, @student, @student, @assignment, 200, nil)
|
||||
expect(response[:code]).to eq '400'
|
||||
end
|
||||
|
||||
it 'fails for partially graded assignments' do
|
||||
@submission.workflow_state = :pending_review
|
||||
@submission.save!
|
||||
expect(CanvasHttp).to receive(:post).never
|
||||
response = Service.select_mastery_path(@course, @student, @student, @assignment, 200, nil)
|
||||
expect(response[:code]).to eq '400'
|
||||
end
|
||||
|
||||
it 'fails if student has no submission' do
|
||||
student_in_course
|
||||
expect(CanvasHttp).to receive(:post).never
|
||||
response = Service.select_mastery_path(@course, @student, @student, @assignment, 200, nil)
|
||||
expect(response[:code]).to eq '400'
|
||||
end
|
||||
|
||||
it "fails for assignments for which the student's submission is not posted" do
|
||||
@assignment.hide_submissions
|
||||
expect(CanvasHttp).to receive(:post).never
|
||||
response = Service.select_mastery_path(@course, @student, @student, @assignment, 200, nil)
|
||||
expect(response[:code]).to eq '400'
|
||||
end
|
||||
end
|
||||
|
||||
describe 'active_rules' do
|
||||
before do
|
||||
enable_service
|
||||
end
|
||||
|
||||
it 'caches a successful http response' do
|
||||
enable_cache do
|
||||
course_with_teacher
|
||||
expect(CanvasHttp).to receive(:get).once.and_return(double({ code: '200', body: [].to_json }))
|
||||
expect(Canvas::Errors).to receive(:capture).never
|
||||
Service.active_rules @course, @user, nil
|
||||
rules = Service.active_rules @course, @user, nil
|
||||
expect(rules).to eq []
|
||||
end
|
||||
end
|
||||
|
||||
it 'does not cache an error http response' do
|
||||
course_with_teacher
|
||||
enable_cache do
|
||||
expect(CanvasHttp).to receive(:get).twice.and_return(double({ code: '500' }))
|
||||
expect(Canvas::Errors).to receive(:capture).twice.
|
||||
with(instance_of(ConditionalRelease::ServiceError), anything)
|
||||
Service.active_rules @course, @user, nil
|
||||
Service.active_rules @course, @user, nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with active_rules' do
|
||||
before(:each) do
|
||||
allow(Service).to receive(:enabled_in_context?).and_return(true)
|
||||
allow(Service).to receive(:jwt_for).and_return(:jwt)
|
||||
end
|
||||
|
||||
before(:once) do
|
||||
course_with_teacher
|
||||
@a1 = assignment_model course: @course, grading_type: 'points', points_possible: 20
|
||||
@a2 = assignment_model course: @course, grading_type: 'letter_grade', points_possible: 30
|
||||
@a3 = assignment_model course: @course, grading_type: 'percent', points_possible: 25
|
||||
@a4 = assignment_model course: @course, grading_type: 'points', points_possible: 35
|
||||
end
|
||||
|
||||
let_once(:default_rules) do
|
||||
[
|
||||
{id: 1, trigger_assignment: @a1.id.to_s, scoring_ranges: [{ assignment_sets: [
|
||||
{ assignments: [
|
||||
{ assignment_id: @a2.id.to_s },
|
||||
{ assignment_id: @a3.id.to_s }]}]}]},
|
||||
{id: 2, trigger_assignment: @a2.id.to_s, scoring_ranges: [{ assignment_sets: [
|
||||
{ assignments: [
|
||||
{ assignment_id: @a3.id.to_s }]}]}]},
|
||||
{id: 3, trigger_assignment: @a3.id.to_s}
|
||||
].as_json
|
||||
end
|
||||
|
||||
context 'assignment data' do
|
||||
before(:each) do
|
||||
allow(Service).to receive(:enabled_in_context?).and_return(true)
|
||||
allow(CanvasHttp).to receive(:get).once.
|
||||
and_return(double({ code: '200', body: default_rules.to_json }))
|
||||
end
|
||||
|
||||
let(:rules) do
|
||||
Service.active_rules(@course, @teacher, @session)
|
||||
end
|
||||
|
||||
it 'includes correct points' do
|
||||
expect(rules[0]['trigger_assignment_model'][:points_possible]).to be 20.0
|
||||
expect(rules[1]['trigger_assignment_model'][:points_possible]).to be 30.0
|
||||
end
|
||||
|
||||
it 'includes correct grading type' do
|
||||
expect(rules[0]['trigger_assignment_model'][:grading_type]).to eq 'points'
|
||||
expect(rules[1]['trigger_assignment_model'][:grading_type]).to eq 'letter_grade'
|
||||
end
|
||||
|
||||
it 'includes grading scheme only for correct grading type' do
|
||||
expect(rules[0]['trigger_assignment_model'][:grading_scheme]).to be nil
|
||||
expect(rules[1]['trigger_assignment_model'][:grading_scheme]).to eq({
|
||||
"A"=>0.94, "A-"=>0.9, "B+"=>0.87, "B"=>0.84, "B-"=>0.8, "C+"=>0.77,
|
||||
"C"=>0.74, "C-"=>0.7, "D+"=>0.67, "D"=>0.64, "D-"=>0.61, "F"=>0.0
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
describe 'rule_triggered_by' do
|
||||
def cache_active_rules(rules = default_rules)
|
||||
Rails.cache.write ['conditional_release', 'active_rules', @course.global_id].cache_key, rules
|
||||
end
|
||||
|
||||
it 'caches the result of a successful http call' do
|
||||
enable_cache do
|
||||
expect(CanvasHttp).to receive(:get).once.
|
||||
and_return(double({ code: '200', body: default_rules.to_json }))
|
||||
Service.rule_triggered_by(@a1, @teacher, nil)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with cached rules' do
|
||||
it 'returns a matching rule' do
|
||||
enable_cache do
|
||||
cache_active_rules
|
||||
expect(Service.rule_triggered_by(@a1, @teacher, nil)['id']).to eq 1
|
||||
expect(Service.rule_triggered_by(@a3, @teacher, nil)['id']).to eq 3
|
||||
end
|
||||
end
|
||||
|
||||
it 'returns nil if no rules are matching' do
|
||||
enable_cache do
|
||||
cache_active_rules
|
||||
expect(Service.rule_triggered_by(@a4, @teacher, nil)).to be nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'returns nil without making request if no assignment is provided' do
|
||||
expect(CanvasHttp).to receive(:get).never
|
||||
Service.rule_triggered_by(nil, @teacher, nil)
|
||||
end
|
||||
|
||||
it 'returns nil without making request if service is not enabled' do
|
||||
allow(Service).to receive(:enabled_in_context?).and_return(false)
|
||||
expect(CanvasHttp).to receive(:get).never
|
||||
Service.rule_triggered_by(@a1, @teacher, nil)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'rules_for' do
|
||||
before do
|
||||
enable_service
|
||||
end
|
||||
|
||||
before(:once) do
|
||||
course_with_student
|
||||
@a1, @a2, @a3 = 3.times.map { assignment_model course: @course }
|
||||
end
|
||||
|
||||
def expect_cyoe_request(code, assignments = nil)
|
||||
a3 = @a3
|
||||
response = double()
|
||||
expect(response).to receive(:code).and_return(code)
|
||||
unless assignments.nil?
|
||||
assignments = Array.wrap(assignments)
|
||||
assignments_json = assignments.map do |a|
|
||||
{ id: a.id, assignment_id: a.id }
|
||||
end
|
||||
expect(response).to receive(:body).and_return([
|
||||
{ id: 1, trigger_assignment: 2, assignment_sets: [
|
||||
{ id: 11, assignments: assignments_json },
|
||||
{ id: 12, assignments: [{ id: a3.id, assignment_id: a3.id }]}
|
||||
]}
|
||||
].to_json)
|
||||
end
|
||||
expect(CanvasHttp).to receive(:post).once.and_return(response)
|
||||
end
|
||||
|
||||
let(:rules) { Service.rules_for(@course, @student, nil) }
|
||||
let(:assignments0) { rules[0][:assignment_sets][0][:assignments] }
|
||||
let(:models0) { assignments0.map{|a| a[:model]} }
|
||||
|
||||
it 'returns a list of rules' do
|
||||
expect_cyoe_request '200', @a1
|
||||
expect(rules.length).to be > 0
|
||||
expect(models0).to eq [@a1]
|
||||
end
|
||||
|
||||
it 'filters missing assignments from an assignment set' do
|
||||
expect_cyoe_request '200', [@a1, @a2, @a3]
|
||||
@a1.destroy!
|
||||
expect(models0).to eq [@a2, @a3]
|
||||
end
|
||||
|
||||
it 'filters assignment sets with no assignments' do
|
||||
expect_cyoe_request '200', [@a1, @a2]
|
||||
@a1.destroy!
|
||||
@a2.destroy!
|
||||
expect(rules[0][:assignment_sets].length).to eq 1
|
||||
expect(models0).to eq [@a3]
|
||||
end
|
||||
|
||||
it 'does not filter rules with no follow on assignments' do
|
||||
expect_cyoe_request '200', [@a1, @a2]
|
||||
@a1.destroy!
|
||||
@a2.destroy!
|
||||
@a3.destroy!
|
||||
expect(rules.length).to eq 1
|
||||
expect(rules[0][:assignment_sets].length).to eq 0
|
||||
end
|
||||
|
||||
it 'handles an http error with logging and defaults' do
|
||||
expect_cyoe_request '404'
|
||||
expect(Canvas::Errors).to receive(:capture).
|
||||
with(instance_of(ConditionalRelease::ServiceError), anything)
|
||||
expect(rules).to eq []
|
||||
end
|
||||
|
||||
it 'handles a network exception with logging and defaults' do
|
||||
expect(CanvasHttp).to receive(:post).and_raise('something terrible') #throws?
|
||||
expect(Canvas::Errors).to receive(:capture).
|
||||
with(instance_of(ConditionalRelease::ServiceError), anything)
|
||||
expect(rules).to eq []
|
||||
end
|
||||
|
||||
context 'caching' do
|
||||
it 'uses the cache' do
|
||||
enable_cache do
|
||||
expect_cyoe_request '200', @a1
|
||||
Service.rules_for(@course, @student, nil)
|
||||
Service.rules_for(@course, @student, nil)
|
||||
end
|
||||
end
|
||||
|
||||
it 'does not use the cache if cache cleared manually' do
|
||||
enable_cache do
|
||||
expect_cyoe_request '200', @a1
|
||||
Service.rules_for(@course, @student, nil)
|
||||
|
||||
Service.clear_rules_cache_for(@course, @student)
|
||||
|
||||
expect_cyoe_request '200', @a1
|
||||
Service.rules_for(@course, @student, nil)
|
||||
end
|
||||
end
|
||||
|
||||
it 'does not use the cache if assignments updated' do
|
||||
enable_cache do
|
||||
expect_cyoe_request '200', @a1
|
||||
Service.rules_for(@course, @student, nil)
|
||||
|
||||
@a1.title = 'updated'
|
||||
@a1.save!
|
||||
|
||||
expect_cyoe_request '200', @a1
|
||||
Service.rules_for(@course, @student, nil)
|
||||
end
|
||||
end
|
||||
|
||||
it 'does not use the cache if assignments are saved' do
|
||||
enable_cache do
|
||||
expect_cyoe_request '200', @a1
|
||||
Service.rules_for(@course, @student, nil)
|
||||
|
||||
@a1.save!
|
||||
|
||||
expect_cyoe_request '200', @a1
|
||||
Service.rules_for(@course, @student, nil)
|
||||
end
|
||||
end
|
||||
|
||||
it 'does not store an error response in the cache' do
|
||||
enable_cache do
|
||||
expect_cyoe_request '404'
|
||||
Service.rules_for(@course, @student, nil)
|
||||
|
||||
expect_cyoe_request '404'
|
||||
Service.rules_for(@course, @student, nil)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'submissions' do
|
||||
def submissions_hash_for(submissions)
|
||||
all_the_submissions = Array.wrap(submissions).map do |submission|
|
||||
submission.slice(:id, :assignment_id, :score)
|
||||
.merge(points_possible: submission.assignment.points_possible)
|
||||
.symbolize_keys
|
||||
end
|
||||
|
||||
{ submissions: all_the_submissions }
|
||||
end
|
||||
|
||||
def expect_request_rules(submissions)
|
||||
expect(Service).to receive(:request_rules)
|
||||
.with(anything, submissions_hash_for(submissions))
|
||||
.and_return([])
|
||||
end
|
||||
|
||||
before do
|
||||
course_with_student(active_all: true)
|
||||
@course.default_post_policy.update!(post_manually: false)
|
||||
end
|
||||
|
||||
context 'for cross-shard users' do
|
||||
specs_require_sharding
|
||||
|
||||
it 'selects submissions' do
|
||||
@shard1.activate do
|
||||
course_with_student(account: Account.create!, user: @student)
|
||||
@course.enroll_teacher(@teacher, active_all: true).accept!
|
||||
submission_model(course: @course, user: @student)
|
||||
@submission.assignment.grade_student(@student, grade: 10, grader: @teacher)
|
||||
expect_request_rules(@submission.reload)
|
||||
Service.rules_for(@course, @student, nil)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'includes only submissions for the course' do
|
||||
s1 = submission_model(course: @course, user: @student)
|
||||
s1.assignment.grade_student(@student, grade: 10, grader: @teacher)
|
||||
course_with_student(user: @student)
|
||||
@course.enroll_teacher(@teacher, active_all: true).accept!
|
||||
s2 = graded_submission_model(course: @course, user: @student)
|
||||
s2.assignment.grade_student(@student, grade: 10, grader: @teacher)
|
||||
expect_request_rules(s2.reload)
|
||||
Service.rules_for(@course, @student, nil)
|
||||
end
|
||||
|
||||
it 'includes only completely graded submissions' do
|
||||
s1 = submission_model(course: @course, user: @student)
|
||||
s1.assignment.grade_student(@student, grade: 10, grader: @teacher)
|
||||
_s2 = submission_model(course: @course, user: @student)
|
||||
expect_request_rules(s1.reload)
|
||||
Service.rules_for(@course, @student, nil)
|
||||
end
|
||||
|
||||
it 'includes only non-muted assignments' do
|
||||
graded_submission_model(course: @course, user: @student)
|
||||
enable_cache do
|
||||
@submission.assignment.mute!
|
||||
expect_request_rules([])
|
||||
Service.rules_for(@course, @student, nil)
|
||||
|
||||
@submission.assignment.unmute!
|
||||
expect_request_rules(@submission)
|
||||
Service.rules_for(@course, @student, nil)
|
||||
end
|
||||
end
|
||||
|
||||
it "includes assignments with submissions that have been posted to the student" do
|
||||
submission_model(course: @course, user: @student)
|
||||
@submission.assignment.grade_student(@student, grade: 10, grader: @teacher)
|
||||
|
||||
# Add a second student so we have a meaningful distinction between
|
||||
# assignment muted and submission posted
|
||||
@course.enroll_student(User.create!, enrollment_state: :active)
|
||||
@submission.assignment.hide_submissions
|
||||
|
||||
enable_cache do
|
||||
@submission.assignment.post_submissions(submission_ids: [@submission.id])
|
||||
expect_request_rules(@submission.reload)
|
||||
Service.rules_for(@course, @student, nil)
|
||||
end
|
||||
end
|
||||
|
||||
it "excludes assignments with submissions that have not been posted to the student" do
|
||||
submission_model(course: @course, user: @student)
|
||||
@submission.assignment.grade_student(@student, grade: 10, grader: @teacher)
|
||||
|
||||
# Add a second student so we have a meaningful distinction between
|
||||
# assignment muted and submission posted
|
||||
@course.enroll_student(User.create!, enrollment_state: :active)
|
||||
@submission.assignment.hide_submissions
|
||||
|
||||
enable_cache do
|
||||
@submission.assignment.post_submissions
|
||||
@submission.assignment.hide_submissions(submission_ids: [@submission.id])
|
||||
expect_request_rules([])
|
||||
Service.rules_for(@course, @student, nil)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "native conditional release" do
|
||||
before :once do
|
||||
setup_course_with_native_conditional_release
|
||||
|
|
|
@ -1,235 +0,0 @@
|
|||
#
|
||||
# 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 File.expand_path(File.dirname(__FILE__) + '/../../spec_helper.rb')
|
||||
|
||||
describe ConditionalRelease::Setup do
|
||||
let(:service) { ConditionalRelease::Service }
|
||||
|
||||
def stub_config
|
||||
allow(ConfigFile).to receive(:load).and_return({
|
||||
protocol: 'foo', host: 'bar',
|
||||
create_account_path: 'some/path',
|
||||
edit_rule_path: 'some/other/path',
|
||||
unique_id: 'unique@cyoe.id'
|
||||
})
|
||||
end
|
||||
|
||||
def setup_cr_user(base_account)
|
||||
account = base_account.root_account
|
||||
user = User.new(name: 'Conditional Release API', sortable_name: 'API, Conditional Release')
|
||||
user.workflow_state = "registered"
|
||||
|
||||
pseudo = user.pseudonyms.build(account: account, unique_id: service.unique_id)
|
||||
pseudo.workflow_state = "active"
|
||||
pseudo.user = user
|
||||
user.save!
|
||||
|
||||
admin = account.account_users.build
|
||||
admin.user = user
|
||||
admin.save! if admin.valid?
|
||||
user
|
||||
end
|
||||
|
||||
def setup_cr_token_for_user(user)
|
||||
token = user.access_tokens.build(purpose: "Conditional Release Service API Token")
|
||||
token.save!
|
||||
token.full_token
|
||||
end
|
||||
|
||||
|
||||
def get_api_user_details(account)
|
||||
pseudo = Pseudonym.active.where(account: account.root_account, unique_id: service.unique_id)
|
||||
user = pseudo.first.user if pseudo.present?
|
||||
token = user.access_tokens.where(purpose: "Conditional Release Service API Token") if user.present?
|
||||
|
||||
{ pseudonym: pseudo, user: user, token: token }
|
||||
end
|
||||
|
||||
before :each do
|
||||
stub_config
|
||||
@root_account = account_model
|
||||
@sub_account = account_model parent_account: @root_account
|
||||
@course = course_factory account: @sub_account, active_all: true
|
||||
@user = user_with_pseudonym account: @root_account
|
||||
allow(service).to receive(:jwt_for).and_return("some.jwt.thing")
|
||||
allow(service).to receive(:unique_id).and_return("unique@cyoe.id")
|
||||
allow_any_instance_of(User).to receive(:set_default_feature_flags)
|
||||
allow(Feature).to receive(:definitions).and_return({
|
||||
'conditional_release' => Feature.new(feature: 'conditional_release', applies_to: 'Account')
|
||||
})
|
||||
@cyoe_feature = Feature.definitions['conditional_release']
|
||||
allow(service).to receive(:service_configured?).and_return(true)
|
||||
@setup = ConditionalRelease::Setup.new(@account.id, @user.id)
|
||||
end
|
||||
|
||||
describe "#activate!" do
|
||||
it "should not run if the Conditional Release service isn't configured" do
|
||||
allow(service).to receive(:service_configured?).and_return(false)
|
||||
expect(@setup).to receive(:create_token!).never
|
||||
expect(@setup).to receive(:send_later_enqueue_args).never
|
||||
@setup.activate!
|
||||
end
|
||||
|
||||
it "should not create a new Conditional Release user for an API access token if one exists" do
|
||||
user = setup_cr_user(@root_account)
|
||||
setup_cr_token_for_user(user)
|
||||
init = ConditionalRelease::Setup.new(@account.id, @user.id)
|
||||
expect(init).to receive(:send_later_enqueue_args).never
|
||||
init.activate!
|
||||
end
|
||||
|
||||
it "should create a new Conditional Release user if not present" do
|
||||
expect_any_instance_of(AccountUser).to receive(:save!).once
|
||||
@setup.activate!
|
||||
end
|
||||
|
||||
it "should create a new Conditional Release API access token if not present" do
|
||||
expect_any_instance_of(AccessToken).to receive(:save!).once
|
||||
@setup.activate!
|
||||
end
|
||||
|
||||
it "should enqueue a job to POST the account data to the Conditional Release service" do
|
||||
expect(@setup).to receive(:send_later_enqueue_args).once
|
||||
@setup.activate!
|
||||
end
|
||||
|
||||
# End-to-end
|
||||
it "should activate the Conditional Release service" do
|
||||
expect(CanvasHttp).to receive(:post).once.and_return(Net::HTTPSuccess.new(1.0, 202, "Accepted"))
|
||||
t_new_account = account_model
|
||||
t_new_user = user_with_pseudonym account: t_new_account
|
||||
|
||||
new_init = ConditionalRelease::Setup.new(t_new_account.id, t_new_user.id)
|
||||
new_init.activate!
|
||||
|
||||
details = get_api_user_details(t_new_account)
|
||||
|
||||
job = Delayed::Job.find_by_tag("ConditionalRelease::Setup#post_to_service")
|
||||
expect(job.max_attempts).to eq 1
|
||||
job.invoke_job
|
||||
|
||||
expect(details[:token].count).to eq 1
|
||||
expect(details[:user].is_a?(User)).to be_truthy
|
||||
expect(details[:pseudonym].count).to eq 1
|
||||
end
|
||||
end
|
||||
|
||||
describe "#post_to_service" do
|
||||
context "successful request" do
|
||||
before do
|
||||
@sub_account.enable_feature! :conditional_release
|
||||
data, jwt = {payload: "data"}, "jwt"
|
||||
@test = ConditionalRelease::Setup.new(@sub_account.id, @user.id)
|
||||
@test.instance_variable_set(:@payload, data)
|
||||
@test.instance_variable_set(:@jwt, jwt)
|
||||
expect(CanvasHttp).to receive(:post).once.with(ConditionalRelease::Service.create_account_url, {
|
||||
"Authorization" => "Bearer #{jwt}"
|
||||
}, form_data: data.to_param).and_return(Net::HTTPSuccess.new(1.0, 202, "Accepted"))
|
||||
end
|
||||
|
||||
it "should send a POST request to the conditional release service" do
|
||||
@test.send :post_to_service
|
||||
end
|
||||
|
||||
it "should not call #undo_changes! if the request succeeds" do
|
||||
expect(@test).to receive(:undo_changes!).never
|
||||
@test.send :post_to_service
|
||||
end
|
||||
end
|
||||
|
||||
context "error handling" do
|
||||
before :each do
|
||||
@sub_account.enable_feature! :conditional_release
|
||||
@test = ConditionalRelease::Setup.new(@sub_account.id, @user.id)
|
||||
expect(CanvasHttp).to receive(:post).once.and_return(Net::HTTPInternalServerError.new(1.0, 500, "Internal Server Error"))
|
||||
end
|
||||
|
||||
it "should disable the conditional release feature flag if the request fails" do
|
||||
expect { @test.send :post_to_service }.to raise_error(ConditionalRelease::ServiceRequestError).and change {
|
||||
@sub_account.feature_flag(:conditional_release).state
|
||||
}.from("on").to("off")
|
||||
end
|
||||
|
||||
it "should destroy the api user for conditional release if the request fails" do
|
||||
@test.send :create_token!
|
||||
|
||||
pseudonym = Pseudonym.active.find_by(account_id: @root_account.id, unique_id: service.unique_id)
|
||||
expect(pseudonym.present?).to be_truthy
|
||||
|
||||
@test.send :post_to_service rescue nil
|
||||
|
||||
pseudonym = Pseudonym.active.find_by(account_id: @root_account.id, unique_id: service.unique_id)
|
||||
expect(pseudonym.present?).to be_falsey
|
||||
end
|
||||
|
||||
it "should call #undo_changes! if the request fails" do
|
||||
expect(@test).to receive(:undo_changes!).once
|
||||
@test.send :post_to_service rescue nil
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
describe "#create_token!" do
|
||||
before :each do
|
||||
@t_new_account = account_model
|
||||
@t_new_course = course_factory account: @t_new_account, active_all: true
|
||||
@t_new_user = user_with_pseudonym account: @t_new_account
|
||||
end
|
||||
|
||||
it "should create a token for a new, unique conditional release API user" do
|
||||
new_init = ConditionalRelease::Setup.new(@t_new_account.id, @t_new_user.id)
|
||||
new_init.send :create_token!
|
||||
|
||||
details = get_api_user_details(@t_new_account)
|
||||
|
||||
expect(details[:token].count).to eq 1
|
||||
expect(details[:user].is_a?(User)).to be_truthy
|
||||
expect(details[:pseudonym].count).to eq 1
|
||||
end
|
||||
|
||||
it "should create a token for an existing conditional release API user that lacks one" do
|
||||
setup_cr_user(@t_new_account)
|
||||
details = get_api_user_details(@t_new_account)
|
||||
|
||||
expect(details[:token].count).to eq 0
|
||||
new_init = ConditionalRelease::Setup.new(@t_new_account.id, @t_new_user.id)
|
||||
new_init.send :create_token!
|
||||
expect(details[:token].reload.count).to eq 1
|
||||
end
|
||||
|
||||
it "should not create new API users if one exists" do
|
||||
setup_cr_user(@t_new_account)
|
||||
details = get_api_user_details(@t_new_account)
|
||||
|
||||
expect(details[:pseudonym].count).to eq 1
|
||||
new_init = ConditionalRelease::Setup.new(@t_new_account.id, @t_new_user.id)
|
||||
new_init.send :create_token!
|
||||
expect(details[:pseudonym].count).to eq 1
|
||||
end
|
||||
|
||||
it "should add the API user to the account as an admin" do
|
||||
new_init = ConditionalRelease::Setup.new(@t_new_account.id, @t_new_user.id)
|
||||
new_init.send :create_token!
|
||||
|
||||
details = get_api_user_details(@t_new_account)
|
||||
expect(@t_new_account.account_users.where(user: details[:user]).count).to eq 1
|
||||
end
|
||||
end
|
||||
end
|
|
@ -26,12 +26,6 @@ module ConditionalRelease
|
|||
end
|
||||
|
||||
context 'handle_grade_change' do
|
||||
it "should require native conditional release" do
|
||||
expect(ConditionalRelease::Service).to receive(:natively_enabled_for_account?).and_return(false).once
|
||||
expect(ConditionalRelease::OverrideHandler).to_not receive(:handle_grade_change)
|
||||
@trigger_assmt.grade_student(@student, grade: 9, grader: @teacher)
|
||||
end
|
||||
|
||||
it "should check that the assignment is actually a trigger assignment" do
|
||||
@rule.destroy!
|
||||
expect(ConditionalRelease::OverrideHandler).to_not receive(:handle_grade_change)
|
||||
|
|
|
@ -114,39 +114,5 @@ describe ContentMigration do
|
|||
released_to = @copy_to.assignments.where(:migration_id => mig_id(@set1_assmt1)).take
|
||||
expect(range_to.assignment_sets.first.assignment_set_associations.first.assignment).to eq released_to
|
||||
end
|
||||
|
||||
it "should translate a native export into the old service format for a yet-to-be-migrated course" do
|
||||
old_account = Account.create!
|
||||
@copy_to.update_attributes(:account => old_account, :root_account => old_account)
|
||||
|
||||
allow(ConditionalRelease::Service).to receive(:service_configured?).and_return(true)
|
||||
allow(ConditionalRelease::MigrationService).to receive(:import_completed?).and_return(true)
|
||||
|
||||
received_data = nil
|
||||
expect(ConditionalRelease::MigrationService).to receive(:send_imported_content_to_service) do |course, imported_content|
|
||||
received_data = imported_content
|
||||
{:mock_data => true}
|
||||
end
|
||||
@rule.scoring_ranges.offset(1).destroy_all # delete all but the first
|
||||
|
||||
run_course_copy
|
||||
|
||||
trigger_assmt_to = @copy_to.assignments.where(:migration_id => mig_id(@trigger_assmt)).take
|
||||
released_to = @copy_to.assignments.where(:migration_id => mig_id(@set1_assmt1)).take
|
||||
|
||||
expected_old_format_data = {
|
||||
"native" => true,
|
||||
"rules" => [{
|
||||
"trigger_assignment" => {"$canvas_assignment_id" => trigger_assmt_to.id},
|
||||
"scoring_ranges" => [{
|
||||
"lower_bound" => 0.7,
|
||||
"upper_bound" => 1.0,
|
||||
"assignment_sets" =>
|
||||
[{"assignments" => [{"$canvas_assignment_id" => released_to.id}]}],
|
||||
}]
|
||||
}]
|
||||
}
|
||||
expect(received_data).to eq expected_old_format_data
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -20,10 +20,9 @@ require File.expand_path(File.dirname(__FILE__) + '/../spec_helper.rb')
|
|||
|
||||
describe ContextExternalTool do
|
||||
before(:once) do
|
||||
course_model
|
||||
@root_account = @course.root_account
|
||||
@root_account = Account.default
|
||||
@account = account_model(:root_account => @root_account, :parent_account => @root_account)
|
||||
@course.update_attribute(:account, @account)
|
||||
course_model(:account => @account)
|
||||
end
|
||||
|
||||
describe 'associations' do
|
||||
|
|
|
@ -20,14 +20,11 @@ require_relative 'page_objects/conditional_release_objects'
|
|||
|
||||
describe "native canvas conditional release" do
|
||||
include_context "in-process server selenium tests"
|
||||
before(:each) do
|
||||
Account.default.tap do |a|
|
||||
a.settings[:use_native_conditional_release] = true
|
||||
a.save!
|
||||
end
|
||||
before(:once) do
|
||||
Account.default.enable_feature! :conditional_release
|
||||
Setting.set("conditional_release_prefer_native", "true")
|
||||
end
|
||||
|
||||
before(:each) do
|
||||
course_with_teacher_logged_in
|
||||
end
|
||||
|
||||
|
@ -35,10 +32,11 @@ describe "native canvas conditional release" do
|
|||
it 'shows Allow in Mastery Paths for a Page when feature enabled' do
|
||||
get "/courses/#{@course.id}/pages/new/edit"
|
||||
expect(ConditionalReleaseObjects.conditional_content_exists?).to eq(true)
|
||||
end
|
||||
|
||||
it 'does not show Allow in Mastery Paths when feature disabled' do
|
||||
Account.default.disable_feature! :conditional_release
|
||||
get "/courses/#{@course.id}/pages/new/edit"
|
||||
|
||||
expect(ConditionalReleaseObjects.conditional_content_exists?).to eq(false)
|
||||
end
|
||||
|
||||
|
@ -117,7 +115,6 @@ describe "native canvas conditional release" do
|
|||
assignment = assignment_model(course: @course, points_possible: 100)
|
||||
get "/courses/#{@course.id}/assignments/#{assignment.id}/edit"
|
||||
ConditionalReleaseObjects.conditional_release_link.click
|
||||
|
||||
expect(ConditionalReleaseObjects.scoring_ranges.count).to eq(3)
|
||||
expect(ConditionalReleaseObjects.top_scoring_boundary.text).to eq("100 pts")
|
||||
end
|
||||
|
@ -133,10 +130,9 @@ describe "native canvas conditional release" do
|
|||
assignment = assignment_model(course: @course, points_possible: 100)
|
||||
get "/courses/#{@course.id}/assignments/#{assignment.id}/edit"
|
||||
ConditionalReleaseObjects.conditional_release_link.click
|
||||
ConditionalReleaseObjects.division_cutoff1.click
|
||||
ConditionalReleaseObjects.division_cutoff1.send_keys [:control, "a"], "72"
|
||||
ConditionalReleaseObjects.division_cutoff2.click
|
||||
ConditionalReleaseObjects.division_cutoff2.send_keys [:control, "a"], "47", :tab
|
||||
replace_content(ConditionalReleaseObjects.division_cutoff1, "72")
|
||||
replace_content(ConditionalReleaseObjects.division_cutoff2, "47")
|
||||
ConditionalReleaseObjects.division_cutoff2.send_keys :tab
|
||||
|
||||
expect(ConditionalReleaseObjects.division_cutoff1.attribute("value")).to eq("72 pts")
|
||||
expect(ConditionalReleaseObjects.division_cutoff2.attribute("value")).to eq("47 pts")
|
||||
|
|
|
@ -84,5 +84,9 @@ class ConditionalReleaseObjects
|
|||
def conditional_release_editor_exists?
|
||||
element_exists?("#canvas-conditional-release-editor")
|
||||
end
|
||||
|
||||
def save_button
|
||||
f(".assignment__action-buttons .btn-primary")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
Loading…
Reference in New Issue