Feature #4015 ยป draft-4015.patch
app/controllers/projects_controller.rb | ||
---|---|---|
39 | 39 |
helper :repositories |
40 | 40 |
helper :members |
41 | 41 |
helper :trackers |
42 |
helper :settings |
|
42 | 43 | |
43 | 44 |
# Lists visible projects |
44 | 45 |
def index |
app/controllers/settings_controller.rb | ||
---|---|---|
36 | 36 |
def edit |
37 | 37 |
@notifiables = Redmine::Notifiable.all |
38 | 38 |
if request.post? |
39 |
errors = Setting.set_all_from_params(params[:settings].to_unsafe_hash) |
|
39 |
if params[:project_id] |
|
40 |
find_project(params[:project_id]) |
|
41 |
unless User.current.allowed_to?(:edit_project, @project) |
|
42 |
raise ::Unauthorized |
|
43 |
end |
|
44 |
end |
|
45 | ||
46 |
errors = Setting.set_all_from_params(params[:settings].to_unsafe_hash, @project) |
|
40 | 47 |
if errors.blank? |
41 | 48 |
flash[:notice] = l(:notice_successful_update) |
42 |
redirect_to settings_path(:tab => params[:tab]) |
|
49 |
redirect_to @project ? settings_project_path(@project, :tab => params[:tab]) : settings_path(:tab => params[:tab])
|
|
43 | 50 |
return |
44 | 51 |
else |
45 | 52 |
@setting_errors = errors |
app/helpers/projects_helper.rb | ||
---|---|---|
39 | 39 |
{:name => 'boards', :action => :manage_boards, |
40 | 40 |
:partial => 'projects/settings/boards', :label => :label_board_plural}, |
41 | 41 |
{:name => 'activities', :action => :manage_project_activities, |
42 |
:partial => 'projects/settings/activities', :label => :label_time_tracking} |
|
42 |
:partial => 'projects/settings/activities', :label => :label_time_tracking}, |
|
43 |
{:name => 'overwrite_global_settings', :action => :edit_project, |
|
44 |
:partial => 'projects/settings/overwrite_global_settings', :label => :label_overwrite_global_settings} |
|
43 | 45 |
] |
44 | 46 |
tabs. |
45 | 47 |
select {|tab| User.current.allowed_to?(tab[:action], @project)}. |
app/helpers/settings_helper.rb | ||
---|---|---|
43 | 43 |
content_tag('div', content_tag('ul', s), :id => 'errorExplanation') |
44 | 44 |
end |
45 | 45 | |
46 |
def setting_value(setting) |
|
46 |
def setting_value(setting, project)
|
|
47 | 47 |
value = nil |
48 | 48 |
if params[:settings] |
49 | 49 |
value = params[:settings][setting] |
50 | 50 |
end |
51 |
value || Setting.send(setting)
|
|
51 |
value || (project ? project.send("setting_#{setting}") : Setting.send(setting))
|
|
52 | 52 |
end |
53 | 53 | |
54 | 54 |
def setting_select(setting, choices, options={}) |
... | ... | |
57 | 57 |
end |
58 | 58 |
setting_label(setting, options).html_safe + |
59 | 59 |
select_tag("settings[#{setting}]", |
60 |
options_for_select(choices, setting_value(setting).to_s), |
|
60 |
options_for_select(choices, setting_value(setting, options[:project]).to_s),
|
|
61 | 61 |
options).html_safe |
62 | 62 |
end |
63 | 63 | |
64 | 64 |
def setting_multiselect(setting, choices, options={}) |
65 |
setting_values = setting_value(setting) |
|
65 |
setting_values = setting_value(setting, options[:project])
|
|
66 | 66 |
setting_values = [] unless setting_values.is_a?(Array) |
67 | 67 | |
68 | 68 |
content_tag("label", l(options[:label] || "setting_#{setting}")) + |
... | ... | |
84 | 84 | |
85 | 85 |
def setting_text_field(setting, options={}) |
86 | 86 |
setting_label(setting, options).html_safe + |
87 |
text_field_tag("settings[#{setting}]", setting_value(setting), options).html_safe |
|
87 |
text_field_tag("settings[#{setting}]", setting_value(setting, options[:project]), options).html_safe
|
|
88 | 88 |
end |
89 | 89 | |
90 | 90 |
def setting_text_area(setting, options={}) |
91 | 91 |
setting_label(setting, options).html_safe + |
92 |
text_area_tag("settings[#{setting}]", setting_value(setting), options).html_safe |
|
92 |
text_area_tag("settings[#{setting}]", setting_value(setting, options[:project]), options).html_safe
|
|
93 | 93 |
end |
94 | 94 | |
95 | 95 |
def setting_check_box(setting, options={}) |
96 | 96 |
setting_label(setting, options).html_safe + |
97 | 97 |
hidden_field_tag("settings[#{setting}]", 0, :id => nil).html_safe + |
98 |
check_box_tag("settings[#{setting}]", 1, setting_value(setting).to_s != '0', options).html_safe |
|
98 |
check_box_tag("settings[#{setting}]", 1, setting_value(setting, options[:project]).to_s != '0', options).html_safe
|
|
99 | 99 |
end |
100 | 100 | |
101 | 101 |
def setting_label(setting, options={}) |
... | ... | |
109 | 109 |
end |
110 | 110 | |
111 | 111 |
# Renders a notification field for a Redmine::Notifiable option |
112 |
def notification_field(notifiable) |
|
112 |
def notification_field(notifiable, options={})
|
|
113 | 113 |
tag_data = notifiable.parent.present? ? |
114 | 114 |
{:parent_notifiable => notifiable.parent} : |
115 | 115 |
{:disables => "input[data-parent-notifiable=#{notifiable.name}]"} |
116 | 116 |
tag = check_box_tag('settings[notified_events][]', |
117 | 117 |
notifiable.name, |
118 |
setting_value('notified_events').include?(notifiable.name), |
|
118 |
setting_value('notified_events', options[:project]).include?(notifiable.name),
|
|
119 | 119 |
:id => nil, |
120 | 120 |
:data => tag_data) |
121 | 121 |
text = l_or_humanize(notifiable.name, :prefix => 'label_') |
122 |
options = {} |
|
123 | 122 |
if notifiable.parent.present? |
124 | 123 |
options[:class] = "parent" |
125 | 124 |
end |
app/models/issue_query.rb | ||
---|---|---|
274 | 274 | |
275 | 275 |
def default_columns_names |
276 | 276 |
@default_columns_names ||= begin |
277 |
default_columns = Setting.issue_list_default_columns.map(&:to_sym)
|
|
277 |
default_columns = (project ? project.setting_issue_list_default_columns : Setting.issue_list_default_columns).map(&:to_sym)
|
|
278 | 278 | |
279 | 279 |
project.present? ? default_columns : [:project] | default_columns |
280 | 280 |
end |
app/models/project.rb | ||
---|---|---|
58 | 58 |
:class_name => 'IssueCustomField', |
59 | 59 |
:join_table => "#{table_name_prefix}custom_fields_projects#{table_name_suffix}", |
60 | 60 |
:association_foreign_key => 'custom_field_id' |
61 |
has_many :settings, dependent: :destroy |
|
61 | 62 | |
62 | 63 |
acts_as_attachable :view_permission => :view_files, |
63 | 64 |
:edit_permission => :manage_files, |
... | ... | |
910 | 911 |
end |
911 | 912 |
end |
912 | 913 | |
914 |
# Define getter and setter for each setting per project |
|
915 |
# Then setting values can be read using: project.setting_some_setting_name |
|
916 |
# or set using project.setting_some_setting_name = "some value" |
|
917 |
def self.define_setting(name) |
|
918 |
project_setting_src = <<~END_SRC |
|
919 |
def setting_#{name} |
|
920 |
Setting[:#{name}, self] |
|
921 |
end |
|
922 | ||
923 |
def setting_#{name}? |
|
924 |
Setting[:#{name}, self].to_i > 0 |
|
925 |
end |
|
926 | ||
927 |
def setting_#{name}=(value) |
|
928 |
Setting[:#{name}, self] = value |
|
929 |
end |
|
930 |
END_SRC |
|
931 |
self.class_eval project_setting_src |
|
932 |
end |
|
933 | ||
913 | 934 |
private |
914 | 935 | |
915 | 936 |
def update_inherited_members |
app/models/setting.rb | ||
---|---|---|
18 | 18 |
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
19 | 19 | |
20 | 20 |
class Setting < ActiveRecord::Base |
21 |
belongs_to :project, optional: true |
|
22 | ||
21 | 23 |
PASSWORD_CHAR_CLASSES = { |
22 | 24 |
'uppercase' => /[A-Z]/, |
23 | 25 |
'lowercase' => /[a-z]/, |
... | ... | |
85 | 87 |
cattr_accessor :available_settings |
86 | 88 |
self.available_settings ||= {} |
87 | 89 | |
88 |
validates_uniqueness_of :name, :if => Proc.new {|setting| setting.new_record? || setting.name_changed?} |
|
90 |
validates_uniqueness_of :name, :scope => :project_id, :if => Proc.new {|setting| setting.new_record? || setting.name_changed?}
|
|
89 | 91 |
validates_inclusion_of :name, :in => Proc.new {available_settings.keys} |
90 | 92 |
validates_numericality_of :value, :only_integer => true, :if => Proc.new { |setting| |
91 | 93 |
(s = available_settings[setting.name]) && s['format'] == 'int' |
... | ... | |
112 | 114 |
end |
113 | 115 | |
114 | 116 |
# Returns the value of the setting named name |
115 |
def self.[](name) |
|
116 |
@cached_settings[name] ||= find_or_default(name).value |
|
117 |
# Example: |
|
118 |
# Setting[name] |
|
119 |
# Setting[name, project] |
|
120 |
def self.[](name, project=nil) |
|
121 |
if project |
|
122 |
setting = find_or_default(name, project) |
|
123 |
setting = find_or_default(name) if setting.new_record? |
|
124 |
setting.value |
|
125 |
else |
|
126 |
@cached_settings[name] ||= find_or_default(name).value |
|
127 |
end |
|
117 | 128 |
end |
118 | 129 | |
119 |
def self.[]=(name, v) |
|
120 |
setting = find_or_default(name) |
|
130 |
# Example: |
|
131 |
# Setting[name, project]=v |
|
132 |
# Setting[name]=v |
|
133 |
def self.[]=(name, project=nil, v) |
|
134 |
setting = find_or_default(name, project) |
|
121 | 135 |
setting.value = v || '' |
122 |
@cached_settings[name] = nil |
|
136 |
@cached_settings[name] = nil if project.nil?
|
|
123 | 137 |
setting.save |
124 | 138 |
setting.value |
125 | 139 |
end |
126 | 140 | |
127 | 141 |
# Updates multiple settings from params and sends a security notification if needed |
128 |
def self.set_all_from_params(settings) |
|
142 |
def self.set_all_from_params(settings, project=nil)
|
|
129 | 143 |
return nil unless settings.is_a?(Hash) |
130 | 144 | |
131 | 145 |
settings = settings.dup.symbolize_keys |
... | ... | |
138 | 152 |
next unless available_settings[name.to_s] |
139 | 153 | |
140 | 154 |
previous_value = Setting[name] |
141 |
set_from_params name, value
|
|
155 |
set_from_params(name, value, project)
|
|
142 | 156 |
if available_settings[name.to_s]['security_notifications'] && Setting[name] != previous_value |
143 | 157 |
changes << name |
144 | 158 |
end |
... | ... | |
187 | 201 |
end |
188 | 202 | |
189 | 203 |
# Sets a setting value from params |
190 |
def self.set_from_params(name, params) |
|
204 |
def self.set_from_params(name, params, project=nil)
|
|
191 | 205 |
params = params.dup |
192 | 206 |
params.delete_if {|v| v.blank? } if params.is_a?(Array) |
193 | 207 |
params.symbolize_keys! if params.is_a?(Hash) |
194 | 208 | |
195 | 209 |
m = "#{name}_from_params" |
196 | 210 |
if respond_to? m |
197 |
self[name.to_sym] = send m, params |
|
211 |
self[name.to_sym, project] = send m, params
|
|
198 | 212 |
else |
199 |
self[name.to_sym] = params |
|
213 |
self[name.to_sym, project] = params
|
|
200 | 214 |
end |
201 | 215 |
end |
202 | 216 | |
... | ... | |
294 | 308 |
end |
295 | 309 |
END_SRC |
296 | 310 |
class_eval src, __FILE__, __LINE__ |
311 | ||
312 |
# Define getters and setters to handle project specific settings |
|
313 |
Project.define_setting(name) |
|
297 | 314 |
end |
298 | 315 | |
299 | 316 |
def self.load_available_settings |
... | ... | |
333 | 350 | |
334 | 351 |
# Returns the Setting instance for the setting named name |
335 | 352 |
# (record found in database or new record with default value) |
336 |
def self.find_or_default(name) |
|
353 |
def self.find_or_default(name, project=nil)
|
|
337 | 354 |
name = name.to_s |
338 | 355 |
raise "There's no setting named #{name}" unless available_settings.has_key?(name) |
339 | 356 | |
340 |
setting = where(:name => name).order(:id => :desc).first |
|
357 |
if ActiveRecord::Base.connection.column_exists?(:settings, :project_id) |
|
358 |
setting = where(:name => name, :project => project).order(:id => :desc).first |
|
359 |
else |
|
360 |
setting = where(:name => name).order(:id => :desc).first |
|
361 |
end |
|
341 | 362 |
unless setting |
342 | 363 |
setting = new |
343 | 364 |
setting.name = name |
365 |
setting.project = project if ActiveRecord::Base.connection.column_exists?(:settings, :project_id) |
|
344 | 366 |
setting.value = available_settings[name]['default'] |
345 | 367 |
end |
346 | 368 |
setting |
app/views/projects/settings/_overwrite_global_settings.html.erb | ||
---|---|---|
1 |
<%= form_tag(settings_edit_path(project_id: @project.id)) do %> |
|
2 |
<%= hidden_field_tag 'tab', 'overwrite_global_settings' %> |
|
3 | ||
4 |
<fieldset class="box"> |
|
5 |
<legend><%= l(:setting_issue_list_default_columns) %></legend> |
|
6 |
<%= render_query_columns_selection( |
|
7 |
IssueQuery.new(:column_names => @project.setting_issue_list_default_columns), |
|
8 |
:name => 'settings[issue_list_default_columns]') %> |
|
9 |
</fieldset> |
|
10 | ||
11 |
<p><%= submit_tag l(:button_save) %></p> |
|
12 |
<% end %> |
db/migrate/20200609043401_add_project_id_to_setting.rb | ||
---|---|---|
1 |
class AddProjectIdToSetting < ActiveRecord::Migration[5.2] |
|
2 |
def change |
|
3 |
add_reference :settings, :project, foreign_key: true |
|
4 |
end |
|
5 |
end |
test/functional/issues_controller_test.rb | ||
---|---|---|
1395 | 1395 | |
1396 | 1396 |
def test_index_with_default_columns_should_respect_default_columns_order |
1397 | 1397 |
columns = ['assigned_to', 'subject', 'status', 'tracker'] |
1398 |
with_settings :issue_list_default_columns => columns do |
|
1398 |
with_settings :issue_list_default_columns => columns do
|
|
1399 | 1399 |
get( |
1400 | 1400 |
:index, |
1401 | 1401 |
:params => { |
... | ... | |
1407 | 1407 |
end |
1408 | 1408 |
end |
1409 | 1409 | |
1410 |
def test_index_with_default_columns_per_project_should_respect_default_columns_order_per_project |
|
1411 |
columns = ['assigned_to', 'subject', 'status', 'tracker'] |
|
1412 |
with_settings(:issue_list_default_columns => columns) do |
|
1413 |
with_project_settings(Project.find(1), :issue_list_default_columns => columns) do |
|
1414 |
get( |
|
1415 |
:index, |
|
1416 |
:params => { |
|
1417 |
:project_id => 1, |
|
1418 |
:set_filter => 1 |
|
1419 |
} |
|
1420 |
) |
|
1421 |
assert_equal ["#", "Assignee", "Subject", "Status", "Tracker"], columns_in_issues_list |
|
1422 |
end |
|
1423 |
end |
|
1424 |
end |
|
1425 | ||
1410 | 1426 |
def test_index_with_custom_field_column |
1411 | 1427 |
columns = %w(tracker subject cf_2) |
1412 | 1428 |
get( |
test/test_helper.rb | ||
---|---|---|
103 | 103 |
saved_settings.each {|k, v| Setting[k] = v} if saved_settings |
104 | 104 |
end |
105 | 105 | |
106 |
def with_project_settings(project, options, &block) |
|
107 |
options.each {|k, v| Setting[k, project] = v} |
|
108 |
yield |
|
109 |
ensure |
|
110 |
options.each {|k, _v| Setting.find_by(project: project, name: k).destroy } |
|
111 |
end |
|
112 | ||
106 | 113 |
# Yields the block with user as the current user |
107 | 114 |
def with_current_user(user, &block) |
108 | 115 |
saved_user = User.current |