Commit 051c4be4 authored by Igor Drozdov's avatar Igor Drozdov

Merge branch '208412-featurable' into 'master'

Extract featurable concern

See merge request gitlab-org/gitlab!31700
parents 9eaf886f 969b7f01
# frozen_string_literal: true
# == Featurable concern
#
# This concern adds features (tools) functionality to Project and Group
# To enable features you need to call `set_available_features`
#
# Example:
#
# class ProjectFeature
# include Featurable
# set_available_features %i(wiki merge_request)
module Featurable
extend ActiveSupport::Concern
# Can be enabled only for members, everyone or disabled
# Access control is made only for non private containers.
#
# Permission levels:
#
# Disabled: not enabled for anyone
# Private: enabled only for team members
# Enabled: enabled for everyone able to access the project
# Public: enabled for everyone (only allowed for pages)
DISABLED = 0
PRIVATE = 10
ENABLED = 20
PUBLIC = 30
STRING_OPTIONS = HashWithIndifferentAccess.new({
'disabled' => DISABLED,
'private' => PRIVATE,
'enabled' => ENABLED,
'public' => PUBLIC
}).freeze
class_methods do
def set_available_features(available_features = [])
@available_features = available_features
class_eval do
available_features.each do |feature|
define_method("#{feature}_enabled?") do
public_send("#{feature}_access_level") > DISABLED # rubocop:disable GitlabSecurity/PublicSend
end
end
end
end
def available_features
@available_features
end
def access_level_attribute(feature)
feature = ensure_feature!(feature)
"#{feature}_access_level".to_sym
end
def quoted_access_level_column(feature)
attribute = connection.quote_column_name(access_level_attribute(feature))
table = connection.quote_table_name(table_name)
"#{table}.#{attribute}"
end
def access_level_from_str(level)
STRING_OPTIONS.fetch(level)
end
def str_from_access_level(level)
STRING_OPTIONS.key(level)
end
def ensure_feature!(feature)
feature = feature.model_name.plural if feature.respond_to?(:model_name)
feature = feature.to_sym
raise ArgumentError, "invalid feature: #{feature}" unless available_features.include?(feature)
feature
end
end
def access_level(feature)
public_send(self.class.access_level_attribute(feature)) # rubocop:disable GitlabSecurity/PublicSend
end
def feature_available?(feature, user)
# This feature might not be behind a feature flag at all, so default to true
return false unless ::Feature.enabled?(feature, user, default_enabled: true)
get_permission(user, feature)
end
def string_access_level(feature)
self.class.str_from_access_level(access_level(feature))
end
end
# frozen_string_literal: true
class ProjectFeature < ApplicationRecord
# == Project features permissions
#
# Grants access level to project tools
#
# Tools can be enabled only for users, everyone or disabled
# Access control is made only for non private projects
#
# levels:
#
# Disabled: not enabled for anyone
# Private: enabled only for team members
# Enabled: enabled for everyone able to access the project
# Public: enabled for everyone (only allowed for pages)
#
# Permission levels
DISABLED = 0
PRIVATE = 10
ENABLED = 20
PUBLIC = 30
include Featurable
FEATURES = %i(issues forking merge_requests wiki snippets builds repository pages metrics_dashboard).freeze
set_available_features(FEATURES)
PRIVATE_FEATURES_MIN_ACCESS_LEVEL = { merge_requests: Gitlab::Access::REPORTER, metrics_dashboard: Gitlab::Access::REPORTER }.freeze
PRIVATE_FEATURES_MIN_ACCESS_LEVEL_FOR_PRIVATE_PROJECT = { repository: Gitlab::Access::REPORTER }.freeze
STRING_OPTIONS = HashWithIndifferentAccess.new({
'disabled' => DISABLED,
'private' => PRIVATE,
'enabled' => ENABLED,
'public' => PUBLIC
}).freeze
class << self
def access_level_attribute(feature)
feature = ensure_feature!(feature)
"#{feature}_access_level".to_sym
end
def quoted_access_level_column(feature)
attribute = connection.quote_column_name(access_level_attribute(feature))
table = connection.quote_table_name(table_name)
"#{table}.#{attribute}"
end
def required_minimum_access_level(feature)
feature = ensure_feature!(feature)
......@@ -60,24 +25,6 @@ class ProjectFeature < ApplicationRecord
required_minimum_access_level(feature)
end
end
def access_level_from_str(level)
STRING_OPTIONS.fetch(level)
end
def str_from_access_level(level)
STRING_OPTIONS.key(level)
end
private
def ensure_feature!(feature)
feature = feature.model_name.plural if feature.respond_to?(:model_name)
feature = feature.to_sym
raise ArgumentError, "invalid project feature: #{feature}" unless FEATURES.include?(feature)
feature
end
end
# Default scopes force us to unscope here since a service may need to check
......@@ -107,45 +54,6 @@ class ProjectFeature < ApplicationRecord
end
end
def feature_available?(feature, user)
# This feature might not be behind a feature flag at all, so default to true
return false unless ::Feature.enabled?(feature, user, default_enabled: true)
get_permission(user, feature)
end
def access_level(feature)
public_send(ProjectFeature.access_level_attribute(feature)) # rubocop:disable GitlabSecurity/PublicSend
end
def string_access_level(feature)
ProjectFeature.str_from_access_level(access_level(feature))
end
def builds_enabled?
builds_access_level > DISABLED
end
def wiki_enabled?
wiki_access_level > DISABLED
end
def merge_requests_enabled?
merge_requests_access_level > DISABLED
end
def forking_enabled?
forking_access_level > DISABLED
end
def issues_enabled?
issues_access_level > DISABLED
end
def pages_enabled?
pages_access_level > DISABLED
end
def public_pages?
return true unless Gitlab.config.pages.access_control
......@@ -164,7 +72,7 @@ class ProjectFeature < ApplicationRecord
# which cannot be higher than repository access level
def repository_children_level
validator = lambda do |field|
level = public_send(field) || ProjectFeature::ENABLED # rubocop:disable GitlabSecurity/PublicSend
level = public_send(field) || ENABLED # rubocop:disable GitlabSecurity/PublicSend
not_allowed = level > repository_access_level
self.errors.add(field, "cannot have higher visibility level than repository access level") if not_allowed
end
......@@ -175,8 +83,8 @@ class ProjectFeature < ApplicationRecord
# Validates access level for other than pages cannot be PUBLIC
def allowed_access_levels
validator = lambda do |field|
level = public_send(field) || ProjectFeature::ENABLED # rubocop:disable GitlabSecurity/PublicSend
not_allowed = level > ProjectFeature::ENABLED
level = public_send(field) || ENABLED # rubocop:disable GitlabSecurity/PublicSend
not_allowed = level > ENABLED
self.errors.add(field, "cannot have public visibility level") if not_allowed
end
......
---
title: Extract featurable concern from ProjectFeature
merge_request: 31700
author: Alexander Randa
type: other
......@@ -160,7 +160,7 @@ describe 'GlobalSearch', :elastic do
# access_level can be :disabled, :enabled or :private
def feature_settings(access_level)
Hash[features.collect { |k| ["#{k}_access_level", ProjectFeature.const_get(access_level.to_s.upcase, false)] }]
Hash[features.collect { |k| ["#{k}_access_level", Featurable.const_get(access_level.to_s.upcase, false)] }]
end
def expect_no_items_to_be_found(user)
......
# frozen_string_literal: true
require 'spec_helper'
describe Featurable do
let_it_be(:user) { create(:user) }
let(:project) { create(:project) }
let(:feature_class) { subject.class }
let(:features) { feature_class::FEATURES }
subject { project.project_feature }
describe '.quoted_access_level_column' do
it 'returns the table name and quoted column name for a feature' do
expected = '"project_features"."issues_access_level"'
expect(feature_class.quoted_access_level_column(:issues)).to eq(expected)
end
end
describe '.access_level_attribute' do
it { expect(feature_class.access_level_attribute(:wiki)).to eq :wiki_access_level }
it 'raises error for unspecified feature' do
expect { feature_class.access_level_attribute(:unknown) }
.to raise_error(ArgumentError, /invalid feature: unknown/)
end
end
describe '.set_available_features' do
let!(:klass) do
Class.new do
include Featurable
set_available_features %i(feature1 feature2)
def feature1_access_level
Featurable::DISABLED
end
def feature2_access_level
Featurable::ENABLED
end
end
end
let!(:instance) { klass.new }
it { expect(klass.available_features).to eq [:feature1, :feature2] }
it { expect(instance.feature1_enabled?).to be_falsey }
it { expect(instance.feature2_enabled?).to be_truthy }
end
describe '.available_features' do
it { expect(feature_class.available_features).to include(*features) }
end
describe '#access_level' do
it 'returns access level' do
expect(subject.access_level(:wiki)).to eq(subject.wiki_access_level)
end
end
describe '#feature_available?' do
let(:features) { %w(issues wiki builds merge_requests snippets repository pages metrics_dashboard) }
context 'when features are disabled' do
it "returns false" do
update_all_project_features(project, features, ProjectFeature::DISABLED)
features.each do |feature|
expect(project.feature_available?(feature.to_sym, user)).to eq(false), "#{feature} failed"
end
end
end
context 'when features are enabled only for team members' do
it "returns false when user is not a team member" do
update_all_project_features(project, features, ProjectFeature::PRIVATE)
features.each do |feature|
expect(project.feature_available?(feature.to_sym, user)).to eq(false), "#{feature} failed"
end
end
it "returns true when user is a team member" do
project.add_developer(user)
update_all_project_features(project, features, ProjectFeature::PRIVATE)
features.each do |feature|
expect(project.feature_available?(feature.to_sym, user)).to eq(true), "#{feature} failed"
end
end
it "returns true when user is a member of project group" do
group = create(:group)
project = create(:project, namespace: group)
group.add_developer(user)
update_all_project_features(project, features, ProjectFeature::PRIVATE)
features.each do |feature|
expect(project.feature_available?(feature.to_sym, user)).to eq(true), "#{feature} failed"
end
end
context 'when admin mode is enabled', :enable_admin_mode do
it "returns true if user is an admin" do
user.update_attribute(:admin, true)
update_all_project_features(project, features, ProjectFeature::PRIVATE)
features.each do |feature|
expect(project.feature_available?(feature.to_sym, user)).to eq(true), "#{feature} failed"
end
end
end
context 'when admin mode is disabled' do
it "returns false when user is an admin" do
user.update_attribute(:admin, true)
update_all_project_features(project, features, ProjectFeature::PRIVATE)
features.each do |feature|
expect(project.feature_available?(feature.to_sym, user)).to eq(false), "#{feature} failed"
end
end
end
end
context 'when feature is enabled for everyone' do
it "returns true" do
expect(project.feature_available?(:issues, user)).to eq(true)
end
end
context 'when feature is disabled by a feature flag' do
it 'returns false' do
stub_feature_flags(issues: false)
expect(project.feature_available?(:issues, user)).to eq(false)
end
end
context 'when feature is enabled by a feature flag' do
it 'returns true' do
stub_feature_flags(issues: true)
expect(project.feature_available?(:issues, user)).to eq(true)
end
end
end
describe '#*_enabled?' do
let(:features) { %w(wiki builds merge_requests) }
it "returns false when feature is disabled" do
update_all_project_features(project, features, ProjectFeature::DISABLED)
features.each do |feature|
expect(project.public_send("#{feature}_enabled?")).to eq(false), "#{feature} failed"
end
end
it "returns true when feature is enabled only for team members" do
update_all_project_features(project, features, ProjectFeature::PRIVATE)
features.each do |feature|
expect(project.public_send("#{feature}_enabled?")).to eq(true), "#{feature} failed"
end
end
it "returns true when feature is enabled for everyone" do
features.each do |feature|
expect(project.public_send("#{feature}_enabled?")).to eq(true), "#{feature} failed"
end
end
end
def update_all_project_features(project, features, value)
project_feature_attributes = features.map { |f| ["#{f}_access_level", value] }.to_h
project.project_feature.update(project_feature_attributes)
end
end
......@@ -18,106 +18,6 @@ describe ProjectFeature do
end
end
describe '.quoted_access_level_column' do
it 'returns the table name and quoted column name for a feature' do
expected = '"project_features"."issues_access_level"'
expect(described_class.quoted_access_level_column(:issues)).to eq(expected)
end
end
describe '#feature_available?' do
let(:features) { %w(issues wiki builds merge_requests snippets repository pages metrics_dashboard) }
context 'when features are disabled' do
it "returns false" do
update_all_project_features(project, features, ProjectFeature::DISABLED)
features.each do |feature|
expect(project.feature_available?(feature.to_sym, user)).to eq(false), "#{feature} failed"
end
end
end
context 'when features are enabled only for team members' do
it "returns false when user is not a team member" do
update_all_project_features(project, features, ProjectFeature::PRIVATE)
features.each do |feature|
expect(project.feature_available?(feature.to_sym, user)).to eq(false), "#{feature} failed"
end
end
it "returns true when user is a team member" do
project.add_developer(user)
update_all_project_features(project, features, ProjectFeature::PRIVATE)
features.each do |feature|
expect(project.feature_available?(feature.to_sym, user)).to eq(true), "#{feature} failed"
end
end
it "returns true when user is a member of project group" do
group = create(:group)
project = create(:project, namespace: group)
group.add_developer(user)
update_all_project_features(project, features, ProjectFeature::PRIVATE)
features.each do |feature|
expect(project.feature_available?(feature.to_sym, user)).to eq(true), "#{feature} failed"
end
end
context 'when admin mode is enabled', :enable_admin_mode do
it "returns true if user is an admin" do
user.update_attribute(:admin, true)
update_all_project_features(project, features, ProjectFeature::PRIVATE)
features.each do |feature|
expect(project.feature_available?(feature.to_sym, user)).to eq(true), "#{feature} failed"
end
end
end
context 'when admin mode is disabled' do
it "returns false when user is an admin" do
user.update_attribute(:admin, true)
update_all_project_features(project, features, ProjectFeature::PRIVATE)
features.each do |feature|
expect(project.feature_available?(feature.to_sym, user)).to eq(false), "#{feature} failed"
end
end
end
end
context 'when feature is enabled for everyone' do
it "returns true" do
expect(project.feature_available?(:issues, user)).to eq(true)
end
end
context 'when feature is disabled by a feature flag' do
it 'returns false' do
stub_feature_flags(issues: false)
expect(project.feature_available?(:issues, user)).to eq(false)
end
end
context 'when feature is enabled by a feature flag' do
it 'returns true' do
stub_feature_flags(issues: true)
expect(project.feature_available?(:issues, user)).to eq(true)
end
end
end
context 'repository related features' do
before do
project.project_feature.update(
......@@ -153,32 +53,6 @@ describe ProjectFeature do
end
end
describe '#*_enabled?' do
let(:features) { %w(wiki builds merge_requests) }
it "returns false when feature is disabled" do
update_all_project_features(project, features, ProjectFeature::DISABLED)
features.each do |feature|
expect(project.public_send("#{feature}_enabled?")).to eq(false), "#{feature} failed"
end
end
it "returns true when feature is enabled only for team members" do
update_all_project_features(project, features, ProjectFeature::PRIVATE)
features.each do |feature|
expect(project.public_send("#{feature}_enabled?")).to eq(true), "#{feature} failed"
end
end
it "returns true when feature is enabled for everyone" do
features.each do |feature|
expect(project.public_send("#{feature}_enabled?")).to eq(true), "#{feature} failed"
end
end
end
describe 'default pages access level' do
subject { project_feature.pages_access_level }
......@@ -313,9 +187,4 @@ describe ProjectFeature do
expect(described_class.required_minimum_access_level_for_private_project(:issues)).to eq(Gitlab::Access::GUEST)
end
end
def update_all_project_features(project, features, value)
project_feature_attributes = features.map { |f| ["#{f}_access_level", value] }.to_h
project.project_feature.update(project_feature_attributes)
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment