canvas-lms/lib/feature.rb

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