diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index aa2927c83..ab8fd38d8 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -39,6 +39,7 @@ class ProjectsController < ApplicationController helper :repositories helper :members helper :trackers + helper :settings # Lists visible projects def index diff --git a/app/controllers/settings_controller.rb b/app/controllers/settings_controller.rb index 69580460a..d215a7f69 100644 --- a/app/controllers/settings_controller.rb +++ b/app/controllers/settings_controller.rb @@ -36,10 +36,17 @@ class SettingsController < ApplicationController def edit @notifiables = Redmine::Notifiable.all if request.post? - errors = Setting.set_all_from_params(params[:settings].to_unsafe_hash) + if params[:project_id] + find_project(params[:project_id]) + unless User.current.allowed_to?(:edit_project, @project) + raise ::Unauthorized + end + end + + errors = Setting.set_all_from_params(params[:settings].to_unsafe_hash, @project) if errors.blank? flash[:notice] = l(:notice_successful_update) - redirect_to settings_path(:tab => params[:tab]) + redirect_to @project ? settings_project_path(@project, :tab => params[:tab]) : settings_path(:tab => params[:tab]) return else @setting_errors = errors diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 785f84291..af7a9f402 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -39,7 +39,9 @@ module ProjectsHelper {:name => 'boards', :action => :manage_boards, :partial => 'projects/settings/boards', :label => :label_board_plural}, {:name => 'activities', :action => :manage_project_activities, - :partial => 'projects/settings/activities', :label => :label_time_tracking} + :partial => 'projects/settings/activities', :label => :label_time_tracking}, + {:name => 'overwrite_global_settings', :action => :edit_project, + :partial => 'projects/settings/overwrite_global_settings', :label => :label_overwrite_global_settings} ] tabs. select {|tab| User.current.allowed_to?(tab[:action], @project)}. diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb index fad1698ea..32ec28ec2 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -43,12 +43,12 @@ module SettingsHelper content_tag('div', content_tag('ul', s), :id => 'errorExplanation') end - def setting_value(setting) + def setting_value(setting, project) value = nil if params[:settings] value = params[:settings][setting] end - value || Setting.send(setting) + value || (project ? project.send("setting_#{setting}") : Setting.send(setting)) end def setting_select(setting, choices, options={}) @@ -57,12 +57,12 @@ module SettingsHelper end setting_label(setting, options).html_safe + select_tag("settings[#{setting}]", - options_for_select(choices, setting_value(setting).to_s), + options_for_select(choices, setting_value(setting, options[:project]).to_s), options).html_safe end def setting_multiselect(setting, choices, options={}) - setting_values = setting_value(setting) + setting_values = setting_value(setting, options[:project]) setting_values = [] unless setting_values.is_a?(Array) content_tag("label", l(options[:label] || "setting_#{setting}")) + @@ -84,18 +84,18 @@ module SettingsHelper def setting_text_field(setting, options={}) setting_label(setting, options).html_safe + - text_field_tag("settings[#{setting}]", setting_value(setting), options).html_safe + text_field_tag("settings[#{setting}]", setting_value(setting, options[:project]), options).html_safe end def setting_text_area(setting, options={}) setting_label(setting, options).html_safe + - text_area_tag("settings[#{setting}]", setting_value(setting), options).html_safe + text_area_tag("settings[#{setting}]", setting_value(setting, options[:project]), options).html_safe end def setting_check_box(setting, options={}) setting_label(setting, options).html_safe + hidden_field_tag("settings[#{setting}]", 0, :id => nil).html_safe + - check_box_tag("settings[#{setting}]", 1, setting_value(setting).to_s != '0', options).html_safe + check_box_tag("settings[#{setting}]", 1, setting_value(setting, options[:project]).to_s != '0', options).html_safe end def setting_label(setting, options={}) @@ -109,17 +109,16 @@ module SettingsHelper end # Renders a notification field for a Redmine::Notifiable option - def notification_field(notifiable) + def notification_field(notifiable, options={}) tag_data = notifiable.parent.present? ? {:parent_notifiable => notifiable.parent} : {:disables => "input[data-parent-notifiable=#{notifiable.name}]"} tag = check_box_tag('settings[notified_events][]', notifiable.name, - setting_value('notified_events').include?(notifiable.name), + setting_value('notified_events', options[:project]).include?(notifiable.name), :id => nil, :data => tag_data) text = l_or_humanize(notifiable.name, :prefix => 'label_') - options = {} if notifiable.parent.present? options[:class] = "parent" end diff --git a/app/models/issue_query.rb b/app/models/issue_query.rb index f33837337..1bc4f2b2d 100644 --- a/app/models/issue_query.rb +++ b/app/models/issue_query.rb @@ -274,7 +274,7 @@ class IssueQuery < Query def default_columns_names @default_columns_names ||= begin - default_columns = Setting.issue_list_default_columns.map(&:to_sym) + default_columns = (project ? project.setting_issue_list_default_columns : Setting.issue_list_default_columns).map(&:to_sym) project.present? ? default_columns : [:project] | default_columns end diff --git a/app/models/project.rb b/app/models/project.rb index 0119b1228..b31d43476 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -58,6 +58,7 @@ class Project < ActiveRecord::Base :class_name => 'IssueCustomField', :join_table => "#{table_name_prefix}custom_fields_projects#{table_name_suffix}", :association_foreign_key => 'custom_field_id' + has_many :settings, dependent: :destroy acts_as_attachable :view_permission => :view_files, :edit_permission => :manage_files, @@ -910,6 +911,26 @@ class Project < ActiveRecord::Base end end + # Define getter and setter for each setting per project + # Then setting values can be read using: project.setting_some_setting_name + # or set using project.setting_some_setting_name = "some value" + def self.define_setting(name) + project_setting_src = <<~END_SRC + def setting_#{name} + Setting[:#{name}, self] + end + + def setting_#{name}? + Setting[:#{name}, self].to_i > 0 + end + + def setting_#{name}=(value) + Setting[:#{name}, self] = value + end + END_SRC + self.class_eval project_setting_src + end + private def update_inherited_members diff --git a/app/models/setting.rb b/app/models/setting.rb index da121263d..80b908099 100644 --- a/app/models/setting.rb +++ b/app/models/setting.rb @@ -18,6 +18,8 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. class Setting < ActiveRecord::Base + belongs_to :project, optional: true + PASSWORD_CHAR_CLASSES = { 'uppercase' => /[A-Z]/, 'lowercase' => /[a-z]/, @@ -85,7 +87,7 @@ class Setting < ActiveRecord::Base cattr_accessor :available_settings self.available_settings ||= {} - validates_uniqueness_of :name, :if => Proc.new {|setting| setting.new_record? || setting.name_changed?} + validates_uniqueness_of :name, :scope => :project_id, :if => Proc.new {|setting| setting.new_record? || setting.name_changed?} validates_inclusion_of :name, :in => Proc.new {available_settings.keys} validates_numericality_of :value, :only_integer => true, :if => Proc.new { |setting| (s = available_settings[setting.name]) && s['format'] == 'int' @@ -112,20 +114,32 @@ class Setting < ActiveRecord::Base end # Returns the value of the setting named name - def self.[](name) - @cached_settings[name] ||= find_or_default(name).value + # Example: + # Setting[name] + # Setting[name, project] + def self.[](name, project=nil) + if project + setting = find_or_default(name, project) + setting = find_or_default(name) if setting.new_record? + setting.value + else + @cached_settings[name] ||= find_or_default(name).value + end end - def self.[]=(name, v) - setting = find_or_default(name) + # Example: + # Setting[name, project]=v + # Setting[name]=v + def self.[]=(name, project=nil, v) + setting = find_or_default(name, project) setting.value = v || '' - @cached_settings[name] = nil + @cached_settings[name] = nil if project.nil? setting.save setting.value end # Updates multiple settings from params and sends a security notification if needed - def self.set_all_from_params(settings) + def self.set_all_from_params(settings, project=nil) return nil unless settings.is_a?(Hash) settings = settings.dup.symbolize_keys @@ -138,7 +152,7 @@ class Setting < ActiveRecord::Base next unless available_settings[name.to_s] previous_value = Setting[name] - set_from_params name, value + set_from_params(name, value, project) if available_settings[name.to_s]['security_notifications'] && Setting[name] != previous_value changes << name end @@ -187,16 +201,16 @@ class Setting < ActiveRecord::Base end # Sets a setting value from params - def self.set_from_params(name, params) + def self.set_from_params(name, params, project=nil) params = params.dup params.delete_if {|v| v.blank? } if params.is_a?(Array) params.symbolize_keys! if params.is_a?(Hash) m = "#{name}_from_params" if respond_to? m - self[name.to_sym] = send m, params + self[name.to_sym, project] = send m, params else - self[name.to_sym] = params + self[name.to_sym, project] = params end end @@ -294,6 +308,9 @@ class Setting < ActiveRecord::Base end END_SRC class_eval src, __FILE__, __LINE__ + + # Define getters and setters to handle project specific settings + Project.define_setting(name) end def self.load_available_settings @@ -333,14 +350,19 @@ class Setting < ActiveRecord::Base # Returns the Setting instance for the setting named name # (record found in database or new record with default value) - def self.find_or_default(name) + def self.find_or_default(name, project=nil) name = name.to_s raise "There's no setting named #{name}" unless available_settings.has_key?(name) - setting = where(:name => name).order(:id => :desc).first + if ActiveRecord::Base.connection.column_exists?(:settings, :project_id) + setting = where(:name => name, :project => project).order(:id => :desc).first + else + setting = where(:name => name).order(:id => :desc).first + end unless setting setting = new setting.name = name + setting.project = project if ActiveRecord::Base.connection.column_exists?(:settings, :project_id) setting.value = available_settings[name]['default'] end setting diff --git a/app/views/projects/settings/_overwrite_global_settings.html.erb b/app/views/projects/settings/_overwrite_global_settings.html.erb new file mode 100644 index 000000000..c7dfda4c7 --- /dev/null +++ b/app/views/projects/settings/_overwrite_global_settings.html.erb @@ -0,0 +1,12 @@ +<%= form_tag(settings_edit_path(project_id: @project.id)) do %> + <%= hidden_field_tag 'tab', 'overwrite_global_settings' %> + +
+ <%= l(:setting_issue_list_default_columns) %> + <%= render_query_columns_selection( + IssueQuery.new(:column_names => @project.setting_issue_list_default_columns), + :name => 'settings[issue_list_default_columns]') %> +
+ +

<%= submit_tag l(:button_save) %>

+<% end %> \ No newline at end of file diff --git a/db/migrate/20200609043401_add_project_id_to_setting.rb b/db/migrate/20200609043401_add_project_id_to_setting.rb new file mode 100644 index 000000000..296ad3445 --- /dev/null +++ b/db/migrate/20200609043401_add_project_id_to_setting.rb @@ -0,0 +1,5 @@ +class AddProjectIdToSetting < ActiveRecord::Migration[5.2] + def change + add_reference :settings, :project, foreign_key: true + end +end diff --git a/test/functional/issues_controller_test.rb b/test/functional/issues_controller_test.rb index fcfb6821f..754f5eb1c 100644 --- a/test/functional/issues_controller_test.rb +++ b/test/functional/issues_controller_test.rb @@ -1395,7 +1395,7 @@ class IssuesControllerTest < Redmine::ControllerTest def test_index_with_default_columns_should_respect_default_columns_order columns = ['assigned_to', 'subject', 'status', 'tracker'] - with_settings :issue_list_default_columns => columns do + with_settings :issue_list_default_columns => columns do get( :index, :params => { @@ -1407,6 +1407,22 @@ class IssuesControllerTest < Redmine::ControllerTest end end + def test_index_with_default_columns_per_project_should_respect_default_columns_order_per_project + columns = ['assigned_to', 'subject', 'status', 'tracker'] + with_settings(:issue_list_default_columns => columns) do + with_project_settings(Project.find(1), :issue_list_default_columns => columns) do + get( + :index, + :params => { + :project_id => 1, + :set_filter => 1 + } + ) + assert_equal ["#", "Assignee", "Subject", "Status", "Tracker"], columns_in_issues_list + end + end + end + def test_index_with_custom_field_column columns = %w(tracker subject cf_2) get( diff --git a/test/test_helper.rb b/test/test_helper.rb index 1acfacf74..dfa93f953 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -103,6 +103,13 @@ class ActiveSupport::TestCase saved_settings.each {|k, v| Setting[k] = v} if saved_settings end + def with_project_settings(project, options, &block) + options.each {|k, v| Setting[k, project] = v} + yield + ensure + options.each {|k, _v| Setting.find_by(project: project, name: k).destroy } + end + # Yields the block with user as the current user def with_current_user(user, &block) saved_user = User.current