241 lines
8.6 KiB
Ruby
241 lines
8.6 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
#
|
|
# Copyright (C) 2013 - 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/>.
|
|
#
|
|
|
|
class Feature
|
|
ATTRS = [:feature, :display_name, :description, :applies_to, :state,
|
|
:root_opt_in, :enable_at, :beta,
|
|
:release_notes_url, :custom_transition_proc, :visible_on,
|
|
:after_state_change_proc, :autoexpand, :touch_context].freeze
|
|
attr_reader *ATTRS
|
|
|
|
def initialize(opts = {})
|
|
@state = 'allowed'
|
|
opts.each do |key, val|
|
|
next unless ATTRS.include?(key)
|
|
next if key == :state && !%w(hidden off allowed on).include?(val)
|
|
instance_variable_set "@#{key}", val
|
|
end
|
|
# for RootAccount features, "allowed" state is redundant; show "off" instead
|
|
@root_opt_in = true if @applies_to == 'RootAccount'
|
|
end
|
|
|
|
def clone_for_cache
|
|
Feature.new(feature: @feature, state: @state)
|
|
end
|
|
|
|
def default?
|
|
true
|
|
end
|
|
|
|
def locked?(query_context)
|
|
query_context.blank? || !allowed? && !hidden?
|
|
end
|
|
|
|
def enabled?
|
|
@state == 'on'
|
|
end
|
|
|
|
def allowed?
|
|
@state == 'allowed'
|
|
end
|
|
|
|
def hidden?
|
|
@state == 'hidden'
|
|
end
|
|
|
|
def self.environment
|
|
if Rails.env.development?
|
|
:development
|
|
elsif Rails.env.test?
|
|
:ci
|
|
elsif ApplicationController.test_cluster_name == 'beta'
|
|
:beta
|
|
elsif ApplicationController.test_cluster_name == 'test'
|
|
:test
|
|
else
|
|
:production
|
|
end
|
|
end
|
|
|
|
def self.production_environment?
|
|
self.environment == :production
|
|
end
|
|
|
|
# Register one or more features. Must be done during application initialization.
|
|
# NOTE: there is refactoring going on for feature flags: ADMIN-2538
|
|
# if you need to add/modify/delete a FF, they have been moved to ./lib/feature_flags/*yml
|
|
# The feature_hash is as follows:
|
|
# automatic_essay_grading: {
|
|
# display_name: -> { I18n.t('features.automatic_essay_grading', 'Automatic Essay Grading') },
|
|
# description: -> { I18n.t('features.automatic_essay_grading_description, 'Popup text describing the feature goes here') },
|
|
# applies_to: 'Course', # or 'RootAccount' or 'Account' or 'User'
|
|
# state: 'allowed', # or 'on', 'hidden', or 'disabled'
|
|
# # - 'hidden' means the feature must be set by a site admin before it will be visible
|
|
# # (in that context and below) to other users
|
|
# # - 'disabled' means the feature will not appear in the feature list and
|
|
# # cannot be turned on. It is intended for use in environment state overrides.
|
|
# root_opt_in: false, # if true, 'allowed' features in source or site admin
|
|
# # will be inherited in "off" state by root accounts
|
|
# enable_at: Date.new(2014, 1, 1), # estimated release date shown in UI
|
|
# beta: false, # 'beta' tag shown in UI
|
|
# release_notes_url: 'http://example.com/',
|
|
#
|
|
# # allow overriding feature definitions on a per-environment basis
|
|
# # valid environments are development, production, beta, test, ci
|
|
# environments: {
|
|
# production: {
|
|
# state: 'disabled'
|
|
# }
|
|
# }
|
|
#
|
|
# # optional: you can supply a Proc to attach warning messages to and/or forbid certain transitions
|
|
# # see lib/feature/draft_state.rb for example usage
|
|
# custom_transition_proc: ->(user, context, from_state, transitions) do
|
|
# if from_state == 'off' && context.is_a?(Course) && context.has_submitted_essays?
|
|
# transitions['on']['warning'] = I18n.t('features.automatic_essay_grading.enable_warning',
|
|
# 'Enabling this feature after some students have submitted essays may yield inconsistent grades.')
|
|
# end
|
|
# end,
|
|
#
|
|
# # optional hook to be called before after a feature flag change
|
|
# # queue a delayed_job to perform any nontrivial processing
|
|
# after_state_change_proc: ->(user, context, old_state, new_state) { ... }
|
|
# }
|
|
VALID_STATES = %w(on allowed hidden disabled).freeze
|
|
VALID_APPLIES_TO = %w(Course Account RootAccount User SiteAdmin).freeze
|
|
VALID_ENVS = %i(development ci beta test production).freeze
|
|
|
|
DISABLED_FEATURE = Feature.new.freeze
|
|
|
|
def self.register(feature_hash)
|
|
@features ||= {}
|
|
feature_hash.each do |feature_name, attrs|
|
|
apply_environment_overrides!(feature_name, attrs)
|
|
feature = feature_name.to_s
|
|
validate_attrs(attrs, feature)
|
|
if attrs[:state] == 'disabled'
|
|
@features[feature] = DISABLED_FEATURE
|
|
else
|
|
@features[feature] = Feature.new({ feature: feature }.merge(attrs))
|
|
end
|
|
end
|
|
end
|
|
|
|
def self.validate_attrs(attrs, feature)
|
|
raise "state is required for feature #{feature}" unless attrs[:state]
|
|
raise "applies_to is required for feature #{feature}" unless attrs[:applies_to]
|
|
raise "invalid 'state' for feature #{feature}: must be one of #{VALID_STATES}, is #{attrs[:state]}" unless VALID_STATES.include? attrs[:state]
|
|
raise "invalid 'applies_to' for feature #{feature}: must be one of #{VALID_APPLIES_TO}, is #{attrs[:applies_to]}" unless VALID_APPLIES_TO.include? attrs[:applies_to]
|
|
end
|
|
|
|
def self.definitions
|
|
@features ||= {}
|
|
@features.freeze unless @features.frozen?
|
|
@features
|
|
end
|
|
|
|
def self.apply_environment_overrides!(feature_name, feature_hash)
|
|
environments = feature_hash.delete(:environments)
|
|
if environments
|
|
raise "invalid environment tag for feature #{feature_name}: must be one of #{VALID_ENVS}" unless (environments.keys - VALID_ENVS).empty?
|
|
env = self.environment
|
|
if environments.key?(env)
|
|
feature_hash.merge!(environments[env])
|
|
end
|
|
end
|
|
end
|
|
|
|
def applies_to_object(object)
|
|
case @applies_to
|
|
when 'SiteAdmin'
|
|
object.is_a?(Account) && object.site_admin?
|
|
when 'RootAccount'
|
|
object.is_a?(Account) && object.root_account?
|
|
when 'Account'
|
|
object.is_a?(Account)
|
|
when 'Course'
|
|
object.is_a?(Course) || object.is_a?(Account)
|
|
when 'User'
|
|
object.is_a?(User) || object.is_a?(Account) && object.site_admin?
|
|
else
|
|
false
|
|
end
|
|
end
|
|
|
|
def self.exists?(feature)
|
|
definitions.key?(feature.to_s)
|
|
end
|
|
|
|
def self.feature_applies_to_object(feature, object)
|
|
feature_def = definitions[feature.to_s]
|
|
return false unless feature_def
|
|
feature_def.applies_to_object(object)
|
|
end
|
|
|
|
def self.applicable_features(object)
|
|
applicable_types = []
|
|
if object.is_a?(Account)
|
|
applicable_types << 'Account'
|
|
applicable_types << 'Course'
|
|
applicable_types << 'RootAccount' if object.root_account?
|
|
applicable_types << 'User' if object.site_admin?
|
|
applicable_types << 'SiteAdmin' if object.site_admin?
|
|
elsif object.is_a?(Course)
|
|
applicable_types << 'Course'
|
|
elsif object.is_a?(User)
|
|
applicable_types << 'User'
|
|
end
|
|
definitions.values.select { |fd| applicable_types.include?(fd.applies_to) }
|
|
end
|
|
|
|
def default_transitions(context, orig_state)
|
|
valid_states = %w(off on)
|
|
valid_states << 'allowed' if context.is_a?(Account)
|
|
(valid_states - [orig_state]).inject({}) do |transitions, state|
|
|
transitions[state] = { 'locked' => (state == 'allowed' && (@applies_to == 'RootAccount' &&
|
|
context.is_a?(Account) && context.root_account? && !context.site_admin? || @applies_to == "SiteAdmin")) }
|
|
transitions
|
|
end
|
|
end
|
|
|
|
def transitions(user, context, orig_state)
|
|
h = default_transitions(context, orig_state)
|
|
if @custom_transition_proc.is_a?(Proc)
|
|
@custom_transition_proc.call(user, context, orig_state, h)
|
|
end
|
|
h
|
|
end
|
|
|
|
def self.transitions(feature_name, user, context, orig_state)
|
|
fd = definitions[feature_name.to_s]
|
|
return nil unless fd
|
|
fd.transitions(user, context, orig_state)
|
|
end
|
|
|
|
def self.remove_obsolete_flags
|
|
valid_features = self.definitions.keys
|
|
cutoff = Setting.get('obsolete_feature_flag_cutoff_days', 60).to_i.days.ago
|
|
delete_scope = FeatureFlag.where('updated_at<?', cutoff).where.not(feature: valid_features)
|
|
while delete_scope.limit(1000).delete_all > 0; end
|
|
end
|
|
end
|
|
|
|
FeatureFlags::Loader.load_feature_flags
|