add functionality to get rules affecting an assignment

refs: CYOE-324

Test plan:
Solely an internal API, though can be tested from the
rails console:

1.  Ensure cyoe config
2.  For an assignment, call
  ConditionalRelease::Service.rule_triggered_by( a, user )
and you should receive back the rule if it exists (nil otherwise)
Call
  ConditionalRelease::Service.rules_assigning( a, user )
and you should get back the rules that assign `a`
Call
  ConditionalRelease::Service.is_trigger/(a, user)
and you should get back a boolean if rule_triggered_by would return a
rule

Change-Id: I877775c2a7b53a0cd786734bed41ab7398fcdb67
Reviewed-on: https://gerrit.instructure.com/91091
Reviewed-by: Christian Prescott <cprescott@instructure.com>
Tested-by: Jenkins
QA-Review: Jahnavi Yetukuri <jyetukuri@instructure.com>
Product-Review: Michael Brewer-Davis <mbd@instructure.com>
This commit is contained in:
Michael Brewer-Davis 2016-09-22 14:58:38 -05:00
parent 6b0333cae8
commit 02964503b9
7 changed files with 227 additions and 167 deletions

View File

@ -1,34 +0,0 @@
#
# Copyright (C) 2016 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
class ScoreRangeDefaultsController < ApplicationController
def index
get_context
unless ConditionalRelease::Service.enabled_in_context?(@context)
return render template: 'shared/errors/404_message', status: :not_found
end
conditional_release_js_env
render locals: {
cr_app_url: ConditionalRelease::Service.edit_rule_url,
}
end
end
end

View File

@ -20,26 +20,31 @@ module ConditionalRelease
class Service
private_class_method :new
DEFAULT_CONFIG = {
enabled: false, # required
host: nil, # required
protocol: nil, # defaults to Canvas
DEFAULT_PATHS = {
edit_rule_path: "ui/editor",
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'
}.freeze
def self.env_for(context, user = nil, session: nil, assignment: nil, domain: nil, real_user: nil)
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, include_rule: false)
enabled = self.enabled_in_context?(context)
env = {
CONDITIONAL_RELEASE_SERVICE_ENABLED: enabled
}
if enabled && user
env.merge!({
new_env = {
CONDITIONAL_RELEASE_ENV: {
jwt: jwt_for(context, user, domain, session: session, real_user: real_user),
assignment: assignment_attributes(assignment),
@ -47,7 +52,9 @@ module ConditionalRelease
stats_url: stats_url,
locale: I18n.locale.to_s
}
})
}
new_env[:CONDITIONAL_RELEASE_ENV][:rule] = rule_triggered_by(assignment, user, session) if include_rule
env.merge!(new_env)
end
env
end
@ -73,6 +80,12 @@ module ConditionalRelease
data[:rules]
end
def self.clear_active_rules_cache(course)
return unless course.present?
clear_cache_with_key(active_rules_cache_key(course))
clear_cache_with_key(active_rules_reverse_cache_key(course))
end
def self.clear_submissions_cache_for(user)
return unless user.present?
clear_cache_with_key(submissions_cache_key(user))
@ -99,22 +112,6 @@ module ConditionalRelease
!!(configured? && context.feature_enabled?(:conditional_release))
end
def self.edit_rule_url
build_url edit_rule_path
end
def self.stats_url
build_url stats_path
end
def self.create_account_url
build_url create_account_path
end
def self.rules_summary_url
build_url rules_summary_path
end
def self.protocol
config[:protocol] || HostUrl.protocol
end
@ -127,32 +124,11 @@ module ConditionalRelease
config[:unique_id] || "conditional-release-service@instructure.auth"
end
def self.edit_rule_path
config[:edit_rule_path]
end
def self.stats_path
config[:stats_path]
end
def self.create_account_path
config[:create_account_path]
end
def self.content_exports_url
build_url(config[:content_exports_path])
end
def self.content_imports_url
build_url(config[:content_imports_path])
end
def self.rules_summary_path
config[:rules_summary_path]
end
def self.select_assignment_set_url
build_url(config[:select_assignment_set_path])
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 }
@ -176,6 +152,44 @@ module ConditionalRelease
{ code: request.code, body: JSON.parse(request.body) }
end
def self.triggers_mastery_paths?(assignment, current_user, session = nil)
rule_triggered_by(assignment, current_user, session).present?
end
def self.rule_triggered_by(assignment, current_user, session = nil)
return unless assignment.present?
return unless enabled_in_context?(assignment.context)
rules = active_rules(assignment.context, current_user, session)
return nil unless rules
rules.find {|r| r['trigger_assignment'] == assignment.id.to_s}
end
def self.rules_assigning(assignment, current_user, session = nil)
reverse_lookup = Rails.cache.fetch(active_rules_reverse_cache_key(assignment.context)) do
all_rules = active_rules(assignment.context, current_user, session)
return nil unless all_rules
lookup = {}
all_rules.each do |rule|
(rule['scoring_ranges'] || []).each do |sr|
(sr['assignment_sets'] || []).each do |as|
(as['assignments'] || []).each do |a|
if a['assignment_id'].present?
lookup[a['assignment_id']] ||= []
lookup[a['assignment_id']] << rule
end
end
end
end
end
lookup.each {|_id, rules| rules.uniq!}
lookup
end
reverse_lookup[assignment.id.to_s]
end
class << self
private
def config_file
@ -312,6 +326,17 @@ module ConditionalRelease
context_type updated_at context_code)
end
def active_rules(course, current_user, session)
return unless enabled_in_context?(course)
return unless course.grants_any_right?(current_user, session, :read, :manage_assignments)
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)
JSON.parse(request.body)
end
end
def rules_cache_key(context, student)
['conditional_release_rules', context.global_id, student.global_id].cache_key
end
@ -320,6 +345,14 @@ module ConditionalRelease
['conditional_release_submissions', student.global_id].cache_key
end
def active_rules_cache_key(course)
['conditional_release', 'active_rules', course.global_id].cache_key
end
def active_rules_reverse_cache_key(course)
['conditional_release', 'active_rules_reverse', course.global_id].cache_key
end
def clear_cache_with_key(key)
return if key.blank?
Rails.cache.delete(key)

View File

@ -1,24 +1,30 @@
class ConditionalReleaseObserver < ActiveRecord::Observer
observe :submission
observe :submission,
:assignment
def after_update(submission)
clear_caches_for submission
def after_update(record)
clear_caches_for record
end
def after_create(submission)
clear_caches_for submission
def after_create(record)
clear_caches_for record
end
def after_save(submission)
def after_save(record)
end
def after_destroy(submission)
clear_caches_for submission
def after_destroy(record)
clear_caches_for record
end
private
def clear_caches_for(submission)
ConditionalRelease::Service.clear_submissions_cache_for(submission.student)
ConditionalRelease::Service.clear_rules_cache_for(submission.context, submission.student)
def clear_caches_for(record)
case record
when Submission
ConditionalRelease::Service.clear_submissions_cache_for(record.student)
ConditionalRelease::Service.clear_rules_cache_for(record.context, record.student)
when Assignment
ConditionalRelease::Service.clear_active_rules_cache(record.context)
end
end
end

View File

@ -7,25 +7,3 @@ development:
# optional. default matches Canvas
protocol: https
# optional. default is 'ui/edit_rule'
edit_rule_path: 'ui/edit_rule'
# optional. default is 'api/accounts'
create_account_path: 'api/accounts'
# optional. default is 'api/content_exports'
content_exports_path: 'api/content_exports'
# optional. default is 'api/content_imports'
content_imports_path: 'api/content_imports'
# optional. default if 'api/rules/summary'
rules_summary_path: 'api/rules/summary'
# optional. email-like id for API user pseudonym.
# default conditional-release-service@instructure.auth
unique_id: conditional-release-service@instructure.auth
# optional. default is 'api/rules/select_assignment_set'
select_assignment_set_path: 'api/rules/select_assignment_set'

View File

@ -1,42 +0,0 @@
#
# Copyright (C) 2011 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')
describe ConditionalRelease::ScoreRangeDefaultsController do
it 'must have service enabled' do
ConditionalRelease::Service.stubs(:enabled_in_context?).returns(false)
course
get :index, course_id: @course
expect(response).to have_http_status(:not_found)
end
context 'enabled' do
before do
course
ConditionalRelease::Service.stubs(:enabled_in_context?).returns(true)
ConditionalRelease::Service.stubs(:env_for).returns({ FOO: :bar })
end
it 'adds the appropriate JS environment vars' do
get :index, course_id: @course
expect(response).to have_http_status(:success)
expect(controller.js_env[:FOO]).to eq :bar
end
end
end

View File

@ -116,6 +116,9 @@ describe ConditionalRelease::Service do
describe 'env_for' do
before do
enable_service
stub_config({
protocol: 'foo', host: 'bar', rules_path: 'rules'
})
course_with_student_logged_in(active_all: true)
end
@ -166,6 +169,14 @@ describe ConditionalRelease::Service do
cr_env = env[:CONDITIONAL_RELEASE_ENV]
expect(cr_env[:assignment][:grading_scheme]).to be_nil
end
it 'includes a relevant rule if include_rule is true' do
assignment_model course: @course
Service.stubs(:rule_triggered_by).returns(nil)
env = Service.env_for(@course, @student, domain: 'foo.bar', assignment: @assignment, include_rule: true)
cr_env = env[:CONDITIONAL_RELEASE_ENV]
expect(cr_env).to have_key :rule
end
end
describe 'jwt_for' do
@ -277,4 +288,94 @@ describe ConditionalRelease::Service do
Service.select_mastery_path(@course, @student, @student, 100, 200, nil)
end
end
describe 'with active_rules' do
before(:each) do
Service.stubs(:enabled_in_context?).returns(true)
Service.stubs(:jwt_for).returns(:jwt)
end
before(:once) do
course_with_teacher
@a, @b, @c, @d = 4.times.map { assignment_model course: @course }
end
let_once(:default_rules) do
[
{id: 1, trigger_assignment: @a.id.to_s, scoring_ranges: [{ assignment_sets: [
{ assignments: [
{ assignment_id: @b.id.to_s },
{ assignment_id: @c.id.to_s }]}]}]},
{id: 2, trigger_assignment: @b.id.to_s, scoring_ranges: [{ assignment_sets: [
{ assignments: [
{ assignment_id: @c.id.to_s }]}]}]},
{id: 3, trigger_assignment: @c.id.to_s}
].as_json
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 response of any http call' do
enable_cache do
CanvasHttp.expects(:get).once.returns stub({ body: default_rules.to_json })
Service.rule_triggered_by(@a, @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(@a, @teacher, nil)['id']).to eq 1
expect(Service.rule_triggered_by(@c, @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(@d, @teacher, nil)).to be nil
end
end
end
it 'returns nil without making request if no assignment is provided' do
CanvasHttp.stubs(:get).raises 'should not generate request'
Service.rule_triggered_by(nil, @teacher, nil)
end
it 'returns nil without making request if service is not enabled' do
Service.stubs(:enabled_in_context?).returns(false)
CanvasHttp.stubs(:get).raises 'should not generate request'
Service.rule_triggered_by(@a, @teacher, nil)
end
end
describe 'rules_assigning' do
before(:each) do
Service.stubs(:active_rules).returns(default_rules)
end
it 'caches the calculation of the reverse index' do
enable_cache do
Service.rules_assigning(@a, @teacher, nil)
Service.stubs(:active_rules).raises 'should not refetch rules'
Service.rules_assigning(@b, @teacher, nil)
end
end
it 'returns all rules which matched assignments' do
expect(Service.rules_assigning(@b, @teacher, nil).map{|r| r['id']}).to eq [1]
expect(Service.rules_assigning(@c, @teacher, nil).map{|r| r['id']}).to eq [1, 2]
end
it 'returns nil if no rules matched assignments' do
expect(Service.rules_assigning(@a, @teacher, nil)).to eq nil
expect(Service.rules_assigning(@d, @teacher, nil)).to eq nil
end
end
end
end

View File

@ -19,16 +19,16 @@
require File.expand_path(File.dirname(__FILE__) + '/../spec_helper')
describe ConditionalReleaseObserver do
before :once do
course.offer!
@module = @course.context_modules.create!(:name => "cyoe module")
@assignment = @course.assignments.create!(:name => "cyoe asgn", :submission_types => ["online_text_entry"], :points_possible => 100)
@assignment.publish! if @assignment.unpublished?
@assignment_tag = @module.add_item(:id => @assignment.id, :type => 'assignment')
end
describe "submission" do
before :once do
course.offer!
@module = @course.context_modules.create!(:name => "cyoe module")
@assignment = @course.assignments.create!(:name => "cyoe asgn", :submission_types => ["online_text_entry"], :points_possible => 100)
@assignment.publish! if @assignment.unpublished?
@assignment_tag = @module.add_item(:id => @assignment.id, :type => 'assignment')
end
it "clears cache on create" do
ConditionalRelease::Service.expects(:clear_submissions_cache_for).at_least(1)
ConditionalRelease::Service.expects(:clear_rules_cache_for).at_least(1)
@ -50,4 +50,22 @@ describe ConditionalReleaseObserver do
@submission.destroy
end
end
describe "assignment" do
it "clears cache on create" do
ConditionalRelease::Service.expects(:clear_active_rules_cache).at_least(1)
assignment_model
end
it "clears cache on update" do
ConditionalRelease::Service.expects(:clear_active_rules_cache).at_least(1)
@assignment.name = "different name"
@assignment.save!
end
it "clears cache on delete" do
ConditionalRelease::Service.expects(:clear_active_rules_cache).at_least(1)
@assignment.destroy
end
end
end