267 lines
9.1 KiB
Ruby
267 lines
9.1 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 = %i[feature
|
|
display_name
|
|
description
|
|
applies_to
|
|
state
|
|
root_opt_in
|
|
beta
|
|
type
|
|
shadow
|
|
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 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? || (!can_override? && !hidden?)
|
|
end
|
|
|
|
def enabled?
|
|
@state == STATE_ON || @state == STATE_DEFAULT_ON
|
|
end
|
|
|
|
def can_override?
|
|
@state == STATE_DEFAULT_OFF || @state == STATE_DEFAULT_ON
|
|
end
|
|
|
|
def hidden?
|
|
@state == "hidden"
|
|
end
|
|
|
|
def shadow?
|
|
@shadow || false
|
|
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?
|
|
environment == :production
|
|
end
|
|
|
|
# Register one or more features. Must be done during application initialization.
|
|
# If you need to add/modify/delete a FF, they are in config/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
|
|
# 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) { ... }
|
|
# }
|
|
STATE_OFF = "off"
|
|
STATE_ON = "on"
|
|
STATE_DEFAULT_OFF = "allowed"
|
|
STATE_DEFAULT_ON = "allowed_on"
|
|
STATE_HIDDEN = "hidden"
|
|
STATE_DISABLED = "disabled"
|
|
|
|
VALID_STATES = [STATE_ON, STATE_DEFAULT_OFF, STATE_DEFAULT_ON, STATE_HIDDEN, STATE_DISABLED].freeze
|
|
VALID_APPLIES_TO = %w[Course Account RootAccount User SiteAdmin].freeze
|
|
VALID_ENVS = %i[development ci beta test production].freeze
|
|
VALID_TYPES = %w[feature_option setting].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)
|
|
@features[feature] = if attrs[:state] == STATE_DISABLED
|
|
DISABLED_FEATURE
|
|
else
|
|
Feature.new({ 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]
|
|
raise "invalid 'type' for feature #{feature}: must be one of #{VALID_TYPES}, is #{attrs[:type]}" unless VALID_TYPES.include? attrs[:type]
|
|
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 = 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, type: nil)
|
|
applicable_types = []
|
|
case object
|
|
when 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?
|
|
when Course
|
|
applicable_types << "Course"
|
|
when User
|
|
applicable_types << "User"
|
|
end
|
|
definitions.values.select { |fd| applicable_types.include?(fd.applies_to) && (type.nil? || fd.type == type) }
|
|
end
|
|
|
|
def default_transitions(context, orig_state)
|
|
valid_states = [STATE_OFF, STATE_ON]
|
|
valid_states += [STATE_DEFAULT_OFF, STATE_DEFAULT_ON] if context.is_a?(Account)
|
|
(valid_states - [orig_state]).index_with do |state|
|
|
{ "locked" => [STATE_DEFAULT_OFF, STATE_DEFAULT_ON].include?(state) && ((@applies_to == "RootAccount" &&
|
|
context.is_a?(Account) && context.root_account? && !context.site_admin?) || @applies_to == "SiteAdmin") }
|
|
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 = definitions.keys
|
|
cutoff = 60.days.ago
|
|
delete_scope = FeatureFlag.where("updated_at<?", cutoff).where.not(feature: valid_features)
|
|
delete_scope.in_batches.delete_all
|
|
end
|
|
end
|
|
|
|
FeatureFlags::Loader.load_feature_flags
|