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:
Jeremy Stanley 2020-07-17 17:41:37 -06:00
parent b7124d052b
commit 5566323f4f
32 changed files with 120 additions and 1909 deletions

View File

@ -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)

View File

@ -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

View File

@ -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]

View File

@ -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]

View File

@ -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
}

View File

@ -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" />
}
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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})

View File

@ -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'}]}]}
]
}
]
},

View File

@ -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() {

View File

@ -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 = `

View File

@ -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'}]}]
}
]
}

View File

@ -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")

View File

@ -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)
})

View File

@ -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')

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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")

View File

@ -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