##// END OF EJS Templates
Add length validations for string fields (#24283)....
Jean-Philippe Lang -
r15607:7b3f2b51c0ea
parent child
Show More
@@ -1,327 +1,328
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2016 Jean-Philippe Lang
2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class CustomField < ActiveRecord::Base
18 class CustomField < ActiveRecord::Base
19 include Redmine::SafeAttributes
19 include Redmine::SafeAttributes
20 include Redmine::SubclassFactory
20 include Redmine::SubclassFactory
21
21
22 has_many :enumerations,
22 has_many :enumerations,
23 lambda { order(:position) },
23 lambda { order(:position) },
24 :class_name => 'CustomFieldEnumeration',
24 :class_name => 'CustomFieldEnumeration',
25 :dependent => :delete_all
25 :dependent => :delete_all
26 has_many :custom_values, :dependent => :delete_all
26 has_many :custom_values, :dependent => :delete_all
27 has_and_belongs_to_many :roles, :join_table => "#{table_name_prefix}custom_fields_roles#{table_name_suffix}", :foreign_key => "custom_field_id"
27 has_and_belongs_to_many :roles, :join_table => "#{table_name_prefix}custom_fields_roles#{table_name_suffix}", :foreign_key => "custom_field_id"
28 acts_as_positioned
28 acts_as_positioned
29 serialize :possible_values
29 serialize :possible_values
30 store :format_store
30 store :format_store
31
31
32 validates_presence_of :name, :field_format
32 validates_presence_of :name, :field_format
33 validates_uniqueness_of :name, :scope => :type
33 validates_uniqueness_of :name, :scope => :type
34 validates_length_of :name, :maximum => 30
34 validates_length_of :name, :maximum => 30
35 validates_length_of :regexp, maximum: 30
35 validates_inclusion_of :field_format, :in => Proc.new { Redmine::FieldFormat.available_formats }
36 validates_inclusion_of :field_format, :in => Proc.new { Redmine::FieldFormat.available_formats }
36 validate :validate_custom_field
37 validate :validate_custom_field
37 attr_protected :id
38 attr_protected :id
38
39
39 before_validation :set_searchable
40 before_validation :set_searchable
40 before_save do |field|
41 before_save do |field|
41 field.format.before_custom_field_save(field)
42 field.format.before_custom_field_save(field)
42 end
43 end
43 after_save :handle_multiplicity_change
44 after_save :handle_multiplicity_change
44 after_save do |field|
45 after_save do |field|
45 if field.visible_changed? && field.visible
46 if field.visible_changed? && field.visible
46 field.roles.clear
47 field.roles.clear
47 end
48 end
48 end
49 end
49
50
50 scope :sorted, lambda { order(:position) }
51 scope :sorted, lambda { order(:position) }
51 scope :visible, lambda {|*args|
52 scope :visible, lambda {|*args|
52 user = args.shift || User.current
53 user = args.shift || User.current
53 if user.admin?
54 if user.admin?
54 # nop
55 # nop
55 elsif user.memberships.any?
56 elsif user.memberships.any?
56 where("#{table_name}.visible = ? OR #{table_name}.id IN (SELECT DISTINCT cfr.custom_field_id FROM #{Member.table_name} m" +
57 where("#{table_name}.visible = ? OR #{table_name}.id IN (SELECT DISTINCT cfr.custom_field_id FROM #{Member.table_name} m" +
57 " INNER JOIN #{MemberRole.table_name} mr ON mr.member_id = m.id" +
58 " INNER JOIN #{MemberRole.table_name} mr ON mr.member_id = m.id" +
58 " INNER JOIN #{table_name_prefix}custom_fields_roles#{table_name_suffix} cfr ON cfr.role_id = mr.role_id" +
59 " INNER JOIN #{table_name_prefix}custom_fields_roles#{table_name_suffix} cfr ON cfr.role_id = mr.role_id" +
59 " WHERE m.user_id = ?)",
60 " WHERE m.user_id = ?)",
60 true, user.id)
61 true, user.id)
61 else
62 else
62 where(:visible => true)
63 where(:visible => true)
63 end
64 end
64 }
65 }
65 def visible_by?(project, user=User.current)
66 def visible_by?(project, user=User.current)
66 visible? || user.admin?
67 visible? || user.admin?
67 end
68 end
68
69
69 safe_attributes 'name',
70 safe_attributes 'name',
70 'field_format',
71 'field_format',
71 'possible_values',
72 'possible_values',
72 'regexp',
73 'regexp',
73 'min_lnegth',
74 'min_lnegth',
74 'max_length',
75 'max_length',
75 'is_required',
76 'is_required',
76 'is_for_all',
77 'is_for_all',
77 'is_filter',
78 'is_filter',
78 'position',
79 'position',
79 'searchable',
80 'searchable',
80 'default_value',
81 'default_value',
81 'editable',
82 'editable',
82 'visible',
83 'visible',
83 'multiple',
84 'multiple',
84 'description',
85 'description',
85 'role_ids',
86 'role_ids',
86 'url_pattern',
87 'url_pattern',
87 'text_formatting',
88 'text_formatting',
88 'edit_tag_style',
89 'edit_tag_style',
89 'user_role',
90 'user_role',
90 'version_status',
91 'version_status',
91 'extensions_allowed'
92 'extensions_allowed'
92
93
93 def format
94 def format
94 @format ||= Redmine::FieldFormat.find(field_format)
95 @format ||= Redmine::FieldFormat.find(field_format)
95 end
96 end
96
97
97 def field_format=(arg)
98 def field_format=(arg)
98 # cannot change format of a saved custom field
99 # cannot change format of a saved custom field
99 if new_record?
100 if new_record?
100 @format = nil
101 @format = nil
101 super
102 super
102 end
103 end
103 end
104 end
104
105
105 def set_searchable
106 def set_searchable
106 # make sure these fields are not searchable
107 # make sure these fields are not searchable
107 self.searchable = false unless format.class.searchable_supported
108 self.searchable = false unless format.class.searchable_supported
108 # make sure only these fields can have multiple values
109 # make sure only these fields can have multiple values
109 self.multiple = false unless format.class.multiple_supported
110 self.multiple = false unless format.class.multiple_supported
110 true
111 true
111 end
112 end
112
113
113 def validate_custom_field
114 def validate_custom_field
114 format.validate_custom_field(self).each do |attribute, message|
115 format.validate_custom_field(self).each do |attribute, message|
115 errors.add attribute, message
116 errors.add attribute, message
116 end
117 end
117
118
118 if regexp.present?
119 if regexp.present?
119 begin
120 begin
120 Regexp.new(regexp)
121 Regexp.new(regexp)
121 rescue
122 rescue
122 errors.add(:regexp, :invalid)
123 errors.add(:regexp, :invalid)
123 end
124 end
124 end
125 end
125
126
126 if default_value.present?
127 if default_value.present?
127 validate_field_value(default_value).each do |message|
128 validate_field_value(default_value).each do |message|
128 errors.add :default_value, message
129 errors.add :default_value, message
129 end
130 end
130 end
131 end
131 end
132 end
132
133
133 def possible_custom_value_options(custom_value)
134 def possible_custom_value_options(custom_value)
134 format.possible_custom_value_options(custom_value)
135 format.possible_custom_value_options(custom_value)
135 end
136 end
136
137
137 def possible_values_options(object=nil)
138 def possible_values_options(object=nil)
138 if object.is_a?(Array)
139 if object.is_a?(Array)
139 object.map {|o| format.possible_values_options(self, o)}.reduce(:&) || []
140 object.map {|o| format.possible_values_options(self, o)}.reduce(:&) || []
140 else
141 else
141 format.possible_values_options(self, object) || []
142 format.possible_values_options(self, object) || []
142 end
143 end
143 end
144 end
144
145
145 def possible_values
146 def possible_values
146 values = read_attribute(:possible_values)
147 values = read_attribute(:possible_values)
147 if values.is_a?(Array)
148 if values.is_a?(Array)
148 values.each do |value|
149 values.each do |value|
149 value.to_s.force_encoding('UTF-8')
150 value.to_s.force_encoding('UTF-8')
150 end
151 end
151 values
152 values
152 else
153 else
153 []
154 []
154 end
155 end
155 end
156 end
156
157
157 # Makes possible_values accept a multiline string
158 # Makes possible_values accept a multiline string
158 def possible_values=(arg)
159 def possible_values=(arg)
159 if arg.is_a?(Array)
160 if arg.is_a?(Array)
160 values = arg.compact.map {|a| a.to_s.strip}.reject(&:blank?)
161 values = arg.compact.map {|a| a.to_s.strip}.reject(&:blank?)
161 write_attribute(:possible_values, values)
162 write_attribute(:possible_values, values)
162 else
163 else
163 self.possible_values = arg.to_s.split(/[\n\r]+/)
164 self.possible_values = arg.to_s.split(/[\n\r]+/)
164 end
165 end
165 end
166 end
166
167
167 def set_custom_field_value(custom_field_value, value)
168 def set_custom_field_value(custom_field_value, value)
168 format.set_custom_field_value(self, custom_field_value, value)
169 format.set_custom_field_value(self, custom_field_value, value)
169 end
170 end
170
171
171 def cast_value(value)
172 def cast_value(value)
172 format.cast_value(self, value)
173 format.cast_value(self, value)
173 end
174 end
174
175
175 def value_from_keyword(keyword, customized)
176 def value_from_keyword(keyword, customized)
176 format.value_from_keyword(self, keyword, customized)
177 format.value_from_keyword(self, keyword, customized)
177 end
178 end
178
179
179 # Returns the options hash used to build a query filter for the field
180 # Returns the options hash used to build a query filter for the field
180 def query_filter_options(query)
181 def query_filter_options(query)
181 format.query_filter_options(self, query)
182 format.query_filter_options(self, query)
182 end
183 end
183
184
184 def totalable?
185 def totalable?
185 format.totalable_supported
186 format.totalable_supported
186 end
187 end
187
188
188 # Returns a ORDER BY clause that can used to sort customized
189 # Returns a ORDER BY clause that can used to sort customized
189 # objects by their value of the custom field.
190 # objects by their value of the custom field.
190 # Returns nil if the custom field can not be used for sorting.
191 # Returns nil if the custom field can not be used for sorting.
191 def order_statement
192 def order_statement
192 return nil if multiple?
193 return nil if multiple?
193 format.order_statement(self)
194 format.order_statement(self)
194 end
195 end
195
196
196 # Returns a GROUP BY clause that can used to group by custom value
197 # Returns a GROUP BY clause that can used to group by custom value
197 # Returns nil if the custom field can not be used for grouping.
198 # Returns nil if the custom field can not be used for grouping.
198 def group_statement
199 def group_statement
199 return nil if multiple?
200 return nil if multiple?
200 format.group_statement(self)
201 format.group_statement(self)
201 end
202 end
202
203
203 def join_for_order_statement
204 def join_for_order_statement
204 format.join_for_order_statement(self)
205 format.join_for_order_statement(self)
205 end
206 end
206
207
207 def visibility_by_project_condition(project_key=nil, user=User.current, id_column=nil)
208 def visibility_by_project_condition(project_key=nil, user=User.current, id_column=nil)
208 if visible? || user.admin?
209 if visible? || user.admin?
209 "1=1"
210 "1=1"
210 elsif user.anonymous?
211 elsif user.anonymous?
211 "1=0"
212 "1=0"
212 else
213 else
213 project_key ||= "#{self.class.customized_class.table_name}.project_id"
214 project_key ||= "#{self.class.customized_class.table_name}.project_id"
214 id_column ||= id
215 id_column ||= id
215 "#{project_key} IN (SELECT DISTINCT m.project_id FROM #{Member.table_name} m" +
216 "#{project_key} IN (SELECT DISTINCT m.project_id FROM #{Member.table_name} m" +
216 " INNER JOIN #{MemberRole.table_name} mr ON mr.member_id = m.id" +
217 " INNER JOIN #{MemberRole.table_name} mr ON mr.member_id = m.id" +
217 " INNER JOIN #{table_name_prefix}custom_fields_roles#{table_name_suffix} cfr ON cfr.role_id = mr.role_id" +
218 " INNER JOIN #{table_name_prefix}custom_fields_roles#{table_name_suffix} cfr ON cfr.role_id = mr.role_id" +
218 " WHERE m.user_id = #{user.id} AND cfr.custom_field_id = #{id_column})"
219 " WHERE m.user_id = #{user.id} AND cfr.custom_field_id = #{id_column})"
219 end
220 end
220 end
221 end
221
222
222 def self.visibility_condition
223 def self.visibility_condition
223 if user.admin?
224 if user.admin?
224 "1=1"
225 "1=1"
225 elsif user.anonymous?
226 elsif user.anonymous?
226 "#{table_name}.visible"
227 "#{table_name}.visible"
227 else
228 else
228 "#{project_key} IN (SELECT DISTINCT m.project_id FROM #{Member.table_name} m" +
229 "#{project_key} IN (SELECT DISTINCT m.project_id FROM #{Member.table_name} m" +
229 " INNER JOIN #{MemberRole.table_name} mr ON mr.member_id = m.id" +
230 " INNER JOIN #{MemberRole.table_name} mr ON mr.member_id = m.id" +
230 " INNER JOIN #{table_name_prefix}custom_fields_roles#{table_name_suffix} cfr ON cfr.role_id = mr.role_id" +
231 " INNER JOIN #{table_name_prefix}custom_fields_roles#{table_name_suffix} cfr ON cfr.role_id = mr.role_id" +
231 " WHERE m.user_id = #{user.id} AND cfr.custom_field_id = #{id})"
232 " WHERE m.user_id = #{user.id} AND cfr.custom_field_id = #{id})"
232 end
233 end
233 end
234 end
234
235
235 def <=>(field)
236 def <=>(field)
236 position <=> field.position
237 position <=> field.position
237 end
238 end
238
239
239 # Returns the class that values represent
240 # Returns the class that values represent
240 def value_class
241 def value_class
241 format.target_class if format.respond_to?(:target_class)
242 format.target_class if format.respond_to?(:target_class)
242 end
243 end
243
244
244 def self.customized_class
245 def self.customized_class
245 self.name =~ /^(.+)CustomField$/
246 self.name =~ /^(.+)CustomField$/
246 $1.constantize rescue nil
247 $1.constantize rescue nil
247 end
248 end
248
249
249 # to move in project_custom_field
250 # to move in project_custom_field
250 def self.for_all
251 def self.for_all
251 where(:is_for_all => true).order('position').to_a
252 where(:is_for_all => true).order('position').to_a
252 end
253 end
253
254
254 def type_name
255 def type_name
255 nil
256 nil
256 end
257 end
257
258
258 # Returns the error messages for the given value
259 # Returns the error messages for the given value
259 # or an empty array if value is a valid value for the custom field
260 # or an empty array if value is a valid value for the custom field
260 def validate_custom_value(custom_value)
261 def validate_custom_value(custom_value)
261 value = custom_value.value
262 value = custom_value.value
262 errs = format.validate_custom_value(custom_value)
263 errs = format.validate_custom_value(custom_value)
263
264
264 unless errs.any?
265 unless errs.any?
265 if value.is_a?(Array)
266 if value.is_a?(Array)
266 if !multiple?
267 if !multiple?
267 errs << ::I18n.t('activerecord.errors.messages.invalid')
268 errs << ::I18n.t('activerecord.errors.messages.invalid')
268 end
269 end
269 if is_required? && value.detect(&:present?).nil?
270 if is_required? && value.detect(&:present?).nil?
270 errs << ::I18n.t('activerecord.errors.messages.blank')
271 errs << ::I18n.t('activerecord.errors.messages.blank')
271 end
272 end
272 else
273 else
273 if is_required? && value.blank?
274 if is_required? && value.blank?
274 errs << ::I18n.t('activerecord.errors.messages.blank')
275 errs << ::I18n.t('activerecord.errors.messages.blank')
275 end
276 end
276 end
277 end
277 end
278 end
278
279
279 errs
280 errs
280 end
281 end
281
282
282 # Returns the error messages for the default custom field value
283 # Returns the error messages for the default custom field value
283 def validate_field_value(value)
284 def validate_field_value(value)
284 validate_custom_value(CustomFieldValue.new(:custom_field => self, :value => value))
285 validate_custom_value(CustomFieldValue.new(:custom_field => self, :value => value))
285 end
286 end
286
287
287 # Returns true if value is a valid value for the custom field
288 # Returns true if value is a valid value for the custom field
288 def valid_field_value?(value)
289 def valid_field_value?(value)
289 validate_field_value(value).empty?
290 validate_field_value(value).empty?
290 end
291 end
291
292
292 def after_save_custom_value(custom_value)
293 def after_save_custom_value(custom_value)
293 format.after_save_custom_value(self, custom_value)
294 format.after_save_custom_value(self, custom_value)
294 end
295 end
295
296
296 def format_in?(*args)
297 def format_in?(*args)
297 args.include?(field_format)
298 args.include?(field_format)
298 end
299 end
299
300
300 def self.human_attribute_name(attribute_key_name, *args)
301 def self.human_attribute_name(attribute_key_name, *args)
301 attr_name = attribute_key_name.to_s
302 attr_name = attribute_key_name.to_s
302 if attr_name == 'url_pattern'
303 if attr_name == 'url_pattern'
303 attr_name = "url"
304 attr_name = "url"
304 end
305 end
305 super(attr_name, *args)
306 super(attr_name, *args)
306 end
307 end
307
308
308 protected
309 protected
309
310
310 # Removes multiple values for the custom field after setting the multiple attribute to false
311 # Removes multiple values for the custom field after setting the multiple attribute to false
311 # We kepp the value with the highest id for each customized object
312 # We kepp the value with the highest id for each customized object
312 def handle_multiplicity_change
313 def handle_multiplicity_change
313 if !new_record? && multiple_was && !multiple
314 if !new_record? && multiple_was && !multiple
314 ids = custom_values.
315 ids = custom_values.
315 where("EXISTS(SELECT 1 FROM #{CustomValue.table_name} cve WHERE cve.custom_field_id = #{CustomValue.table_name}.custom_field_id" +
316 where("EXISTS(SELECT 1 FROM #{CustomValue.table_name} cve WHERE cve.custom_field_id = #{CustomValue.table_name}.custom_field_id" +
316 " AND cve.customized_type = #{CustomValue.table_name}.customized_type AND cve.customized_id = #{CustomValue.table_name}.customized_id" +
317 " AND cve.customized_type = #{CustomValue.table_name}.customized_type AND cve.customized_id = #{CustomValue.table_name}.customized_id" +
317 " AND cve.id > #{CustomValue.table_name}.id)").
318 " AND cve.id > #{CustomValue.table_name}.id)").
318 pluck(:id)
319 pluck(:id)
319
320
320 if ids.any?
321 if ids.any?
321 custom_values.where(:id => ids).delete_all
322 custom_values.where(:id => ids).delete_all
322 end
323 end
323 end
324 end
324 end
325 end
325 end
326 end
326
327
327 require_dependency 'redmine/field_format'
328 require_dependency 'redmine/field_format'
@@ -1,514 +1,516
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2016 Jean-Philippe Lang
2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class ScmFetchError < Exception; end
18 class ScmFetchError < Exception; end
19
19
20 class Repository < ActiveRecord::Base
20 class Repository < ActiveRecord::Base
21 include Redmine::Ciphering
21 include Redmine::Ciphering
22 include Redmine::SafeAttributes
22 include Redmine::SafeAttributes
23
23
24 # Maximum length for repository identifiers
24 # Maximum length for repository identifiers
25 IDENTIFIER_MAX_LENGTH = 255
25 IDENTIFIER_MAX_LENGTH = 255
26
26
27 belongs_to :project
27 belongs_to :project
28 has_many :changesets, lambda{order("#{Changeset.table_name}.committed_on DESC, #{Changeset.table_name}.id DESC")}
28 has_many :changesets, lambda{order("#{Changeset.table_name}.committed_on DESC, #{Changeset.table_name}.id DESC")}
29 has_many :filechanges, :class_name => 'Change', :through => :changesets
29 has_many :filechanges, :class_name => 'Change', :through => :changesets
30
30
31 serialize :extra_info
31 serialize :extra_info
32
32
33 before_validation :normalize_identifier
33 before_validation :normalize_identifier
34 before_save :check_default
34 before_save :check_default
35
35
36 # Raw SQL to delete changesets and changes in the database
36 # Raw SQL to delete changesets and changes in the database
37 # has_many :changesets, :dependent => :destroy is too slow for big repositories
37 # has_many :changesets, :dependent => :destroy is too slow for big repositories
38 before_destroy :clear_changesets
38 before_destroy :clear_changesets
39
39
40 validates_length_of :login, maximum: 60, allow_nil: true
40 validates_length_of :password, :maximum => 255, :allow_nil => true
41 validates_length_of :password, :maximum => 255, :allow_nil => true
42 validates_length_of :root_url, :url, maximum: 255
41 validates_length_of :identifier, :maximum => IDENTIFIER_MAX_LENGTH, :allow_blank => true
43 validates_length_of :identifier, :maximum => IDENTIFIER_MAX_LENGTH, :allow_blank => true
42 validates_uniqueness_of :identifier, :scope => :project_id
44 validates_uniqueness_of :identifier, :scope => :project_id
43 validates_exclusion_of :identifier, :in => %w(browse show entry raw changes annotate diff statistics graph revisions revision)
45 validates_exclusion_of :identifier, :in => %w(browse show entry raw changes annotate diff statistics graph revisions revision)
44 # donwcase letters, digits, dashes, underscores but not digits only
46 # donwcase letters, digits, dashes, underscores but not digits only
45 validates_format_of :identifier, :with => /\A(?!\d+$)[a-z0-9\-_]*\z/, :allow_blank => true
47 validates_format_of :identifier, :with => /\A(?!\d+$)[a-z0-9\-_]*\z/, :allow_blank => true
46 # Checks if the SCM is enabled when creating a repository
48 # Checks if the SCM is enabled when creating a repository
47 validate :repo_create_validation, :on => :create
49 validate :repo_create_validation, :on => :create
48 validate :validate_repository_path
50 validate :validate_repository_path
49 attr_protected :id
51 attr_protected :id
50
52
51 safe_attributes 'identifier',
53 safe_attributes 'identifier',
52 'login',
54 'login',
53 'password',
55 'password',
54 'path_encoding',
56 'path_encoding',
55 'log_encoding',
57 'log_encoding',
56 'is_default'
58 'is_default'
57
59
58 safe_attributes 'url',
60 safe_attributes 'url',
59 :if => lambda {|repository, user| repository.new_record?}
61 :if => lambda {|repository, user| repository.new_record?}
60
62
61 def repo_create_validation
63 def repo_create_validation
62 unless Setting.enabled_scm.include?(self.class.name.demodulize)
64 unless Setting.enabled_scm.include?(self.class.name.demodulize)
63 errors.add(:type, :invalid)
65 errors.add(:type, :invalid)
64 end
66 end
65 end
67 end
66
68
67 def self.human_attribute_name(attribute_key_name, *args)
69 def self.human_attribute_name(attribute_key_name, *args)
68 attr_name = attribute_key_name.to_s
70 attr_name = attribute_key_name.to_s
69 if attr_name == "log_encoding"
71 if attr_name == "log_encoding"
70 attr_name = "commit_logs_encoding"
72 attr_name = "commit_logs_encoding"
71 end
73 end
72 super(attr_name, *args)
74 super(attr_name, *args)
73 end
75 end
74
76
75 # Removes leading and trailing whitespace
77 # Removes leading and trailing whitespace
76 def url=(arg)
78 def url=(arg)
77 write_attribute(:url, arg ? arg.to_s.strip : nil)
79 write_attribute(:url, arg ? arg.to_s.strip : nil)
78 end
80 end
79
81
80 # Removes leading and trailing whitespace
82 # Removes leading and trailing whitespace
81 def root_url=(arg)
83 def root_url=(arg)
82 write_attribute(:root_url, arg ? arg.to_s.strip : nil)
84 write_attribute(:root_url, arg ? arg.to_s.strip : nil)
83 end
85 end
84
86
85 def password
87 def password
86 read_ciphered_attribute(:password)
88 read_ciphered_attribute(:password)
87 end
89 end
88
90
89 def password=(arg)
91 def password=(arg)
90 write_ciphered_attribute(:password, arg)
92 write_ciphered_attribute(:password, arg)
91 end
93 end
92
94
93 def scm_adapter
95 def scm_adapter
94 self.class.scm_adapter_class
96 self.class.scm_adapter_class
95 end
97 end
96
98
97 def scm
99 def scm
98 unless @scm
100 unless @scm
99 @scm = self.scm_adapter.new(url, root_url,
101 @scm = self.scm_adapter.new(url, root_url,
100 login, password, path_encoding)
102 login, password, path_encoding)
101 if root_url.blank? && @scm.root_url.present?
103 if root_url.blank? && @scm.root_url.present?
102 update_attribute(:root_url, @scm.root_url)
104 update_attribute(:root_url, @scm.root_url)
103 end
105 end
104 end
106 end
105 @scm
107 @scm
106 end
108 end
107
109
108 def scm_name
110 def scm_name
109 self.class.scm_name
111 self.class.scm_name
110 end
112 end
111
113
112 def name
114 def name
113 if identifier.present?
115 if identifier.present?
114 identifier
116 identifier
115 elsif is_default?
117 elsif is_default?
116 l(:field_repository_is_default)
118 l(:field_repository_is_default)
117 else
119 else
118 scm_name
120 scm_name
119 end
121 end
120 end
122 end
121
123
122 def identifier=(identifier)
124 def identifier=(identifier)
123 super unless identifier_frozen?
125 super unless identifier_frozen?
124 end
126 end
125
127
126 def identifier_frozen?
128 def identifier_frozen?
127 errors[:identifier].blank? && !(new_record? || identifier.blank?)
129 errors[:identifier].blank? && !(new_record? || identifier.blank?)
128 end
130 end
129
131
130 def identifier_param
132 def identifier_param
131 if is_default?
133 if is_default?
132 nil
134 nil
133 elsif identifier.present?
135 elsif identifier.present?
134 identifier
136 identifier
135 else
137 else
136 id.to_s
138 id.to_s
137 end
139 end
138 end
140 end
139
141
140 def <=>(repository)
142 def <=>(repository)
141 if is_default?
143 if is_default?
142 -1
144 -1
143 elsif repository.is_default?
145 elsif repository.is_default?
144 1
146 1
145 else
147 else
146 identifier.to_s <=> repository.identifier.to_s
148 identifier.to_s <=> repository.identifier.to_s
147 end
149 end
148 end
150 end
149
151
150 def self.find_by_identifier_param(param)
152 def self.find_by_identifier_param(param)
151 if param.to_s =~ /^\d+$/
153 if param.to_s =~ /^\d+$/
152 find_by_id(param)
154 find_by_id(param)
153 else
155 else
154 find_by_identifier(param)
156 find_by_identifier(param)
155 end
157 end
156 end
158 end
157
159
158 # TODO: should return an empty hash instead of nil to avoid many ||{}
160 # TODO: should return an empty hash instead of nil to avoid many ||{}
159 def extra_info
161 def extra_info
160 h = read_attribute(:extra_info)
162 h = read_attribute(:extra_info)
161 h.is_a?(Hash) ? h : nil
163 h.is_a?(Hash) ? h : nil
162 end
164 end
163
165
164 def merge_extra_info(arg)
166 def merge_extra_info(arg)
165 h = extra_info || {}
167 h = extra_info || {}
166 return h if arg.nil?
168 return h if arg.nil?
167 h.merge!(arg)
169 h.merge!(arg)
168 write_attribute(:extra_info, h)
170 write_attribute(:extra_info, h)
169 end
171 end
170
172
171 def report_last_commit
173 def report_last_commit
172 true
174 true
173 end
175 end
174
176
175 def supports_cat?
177 def supports_cat?
176 scm.supports_cat?
178 scm.supports_cat?
177 end
179 end
178
180
179 def supports_annotate?
181 def supports_annotate?
180 scm.supports_annotate?
182 scm.supports_annotate?
181 end
183 end
182
184
183 def supports_all_revisions?
185 def supports_all_revisions?
184 true
186 true
185 end
187 end
186
188
187 def supports_directory_revisions?
189 def supports_directory_revisions?
188 false
190 false
189 end
191 end
190
192
191 def supports_revision_graph?
193 def supports_revision_graph?
192 false
194 false
193 end
195 end
194
196
195 def entry(path=nil, identifier=nil)
197 def entry(path=nil, identifier=nil)
196 scm.entry(path, identifier)
198 scm.entry(path, identifier)
197 end
199 end
198
200
199 def scm_entries(path=nil, identifier=nil)
201 def scm_entries(path=nil, identifier=nil)
200 scm.entries(path, identifier)
202 scm.entries(path, identifier)
201 end
203 end
202 protected :scm_entries
204 protected :scm_entries
203
205
204 def entries(path=nil, identifier=nil)
206 def entries(path=nil, identifier=nil)
205 entries = scm_entries(path, identifier)
207 entries = scm_entries(path, identifier)
206 load_entries_changesets(entries)
208 load_entries_changesets(entries)
207 entries
209 entries
208 end
210 end
209
211
210 def branches
212 def branches
211 scm.branches
213 scm.branches
212 end
214 end
213
215
214 def tags
216 def tags
215 scm.tags
217 scm.tags
216 end
218 end
217
219
218 def default_branch
220 def default_branch
219 nil
221 nil
220 end
222 end
221
223
222 def properties(path, identifier=nil)
224 def properties(path, identifier=nil)
223 scm.properties(path, identifier)
225 scm.properties(path, identifier)
224 end
226 end
225
227
226 def cat(path, identifier=nil)
228 def cat(path, identifier=nil)
227 scm.cat(path, identifier)
229 scm.cat(path, identifier)
228 end
230 end
229
231
230 def diff(path, rev, rev_to)
232 def diff(path, rev, rev_to)
231 scm.diff(path, rev, rev_to)
233 scm.diff(path, rev, rev_to)
232 end
234 end
233
235
234 def diff_format_revisions(cs, cs_to, sep=':')
236 def diff_format_revisions(cs, cs_to, sep=':')
235 text = ""
237 text = ""
236 text << cs_to.format_identifier + sep if cs_to
238 text << cs_to.format_identifier + sep if cs_to
237 text << cs.format_identifier if cs
239 text << cs.format_identifier if cs
238 text
240 text
239 end
241 end
240
242
241 # Returns a path relative to the url of the repository
243 # Returns a path relative to the url of the repository
242 def relative_path(path)
244 def relative_path(path)
243 path
245 path
244 end
246 end
245
247
246 # Finds and returns a revision with a number or the beginning of a hash
248 # Finds and returns a revision with a number or the beginning of a hash
247 def find_changeset_by_name(name)
249 def find_changeset_by_name(name)
248 return nil if name.blank?
250 return nil if name.blank?
249 s = name.to_s
251 s = name.to_s
250 if s.match(/^\d*$/)
252 if s.match(/^\d*$/)
251 changesets.where("revision = ?", s).first
253 changesets.where("revision = ?", s).first
252 else
254 else
253 changesets.where("revision LIKE ?", s + '%').first
255 changesets.where("revision LIKE ?", s + '%').first
254 end
256 end
255 end
257 end
256
258
257 def latest_changeset
259 def latest_changeset
258 @latest_changeset ||= changesets.first
260 @latest_changeset ||= changesets.first
259 end
261 end
260
262
261 # Returns the latest changesets for +path+
263 # Returns the latest changesets for +path+
262 # Default behaviour is to search in cached changesets
264 # Default behaviour is to search in cached changesets
263 def latest_changesets(path, rev, limit=10)
265 def latest_changesets(path, rev, limit=10)
264 if path.blank?
266 if path.blank?
265 changesets.
267 changesets.
266 reorder("#{Changeset.table_name}.committed_on DESC, #{Changeset.table_name}.id DESC").
268 reorder("#{Changeset.table_name}.committed_on DESC, #{Changeset.table_name}.id DESC").
267 limit(limit).
269 limit(limit).
268 preload(:user).
270 preload(:user).
269 to_a
271 to_a
270 else
272 else
271 filechanges.
273 filechanges.
272 where("path = ?", path.with_leading_slash).
274 where("path = ?", path.with_leading_slash).
273 reorder("#{Changeset.table_name}.committed_on DESC, #{Changeset.table_name}.id DESC").
275 reorder("#{Changeset.table_name}.committed_on DESC, #{Changeset.table_name}.id DESC").
274 limit(limit).
276 limit(limit).
275 preload(:changeset => :user).
277 preload(:changeset => :user).
276 collect(&:changeset)
278 collect(&:changeset)
277 end
279 end
278 end
280 end
279
281
280 def scan_changesets_for_issue_ids
282 def scan_changesets_for_issue_ids
281 self.changesets.each(&:scan_comment_for_issue_ids)
283 self.changesets.each(&:scan_comment_for_issue_ids)
282 end
284 end
283
285
284 # Returns an array of committers usernames and associated user_id
286 # Returns an array of committers usernames and associated user_id
285 def committers
287 def committers
286 @committers ||= Changeset.where(:repository_id => id).distinct.pluck(:committer, :user_id)
288 @committers ||= Changeset.where(:repository_id => id).distinct.pluck(:committer, :user_id)
287 end
289 end
288
290
289 # Maps committers username to a user ids
291 # Maps committers username to a user ids
290 def committer_ids=(h)
292 def committer_ids=(h)
291 if h.is_a?(Hash)
293 if h.is_a?(Hash)
292 committers.each do |committer, user_id|
294 committers.each do |committer, user_id|
293 new_user_id = h[committer]
295 new_user_id = h[committer]
294 if new_user_id && (new_user_id.to_i != user_id.to_i)
296 if new_user_id && (new_user_id.to_i != user_id.to_i)
295 new_user_id = (new_user_id.to_i > 0 ? new_user_id.to_i : nil)
297 new_user_id = (new_user_id.to_i > 0 ? new_user_id.to_i : nil)
296 Changeset.where(["repository_id = ? AND committer = ?", id, committer]).
298 Changeset.where(["repository_id = ? AND committer = ?", id, committer]).
297 update_all("user_id = #{new_user_id.nil? ? 'NULL' : new_user_id}")
299 update_all("user_id = #{new_user_id.nil? ? 'NULL' : new_user_id}")
298 end
300 end
299 end
301 end
300 @committers = nil
302 @committers = nil
301 @found_committer_users = nil
303 @found_committer_users = nil
302 true
304 true
303 else
305 else
304 false
306 false
305 end
307 end
306 end
308 end
307
309
308 # Returns the Redmine User corresponding to the given +committer+
310 # Returns the Redmine User corresponding to the given +committer+
309 # It will return nil if the committer is not yet mapped and if no User
311 # It will return nil if the committer is not yet mapped and if no User
310 # with the same username or email was found
312 # with the same username or email was found
311 def find_committer_user(committer)
313 def find_committer_user(committer)
312 unless committer.blank?
314 unless committer.blank?
313 @found_committer_users ||= {}
315 @found_committer_users ||= {}
314 return @found_committer_users[committer] if @found_committer_users.has_key?(committer)
316 return @found_committer_users[committer] if @found_committer_users.has_key?(committer)
315
317
316 user = nil
318 user = nil
317 c = changesets.where(:committer => committer).
319 c = changesets.where(:committer => committer).
318 includes(:user).references(:user).first
320 includes(:user).references(:user).first
319 if c && c.user
321 if c && c.user
320 user = c.user
322 user = c.user
321 elsif committer.strip =~ /^([^<]+)(<(.*)>)?$/
323 elsif committer.strip =~ /^([^<]+)(<(.*)>)?$/
322 username, email = $1.strip, $3
324 username, email = $1.strip, $3
323 u = User.find_by_login(username)
325 u = User.find_by_login(username)
324 u ||= User.find_by_mail(email) unless email.blank?
326 u ||= User.find_by_mail(email) unless email.blank?
325 user = u
327 user = u
326 end
328 end
327 @found_committer_users[committer] = user
329 @found_committer_users[committer] = user
328 user
330 user
329 end
331 end
330 end
332 end
331
333
332 def repo_log_encoding
334 def repo_log_encoding
333 encoding = log_encoding.to_s.strip
335 encoding = log_encoding.to_s.strip
334 encoding.blank? ? 'UTF-8' : encoding
336 encoding.blank? ? 'UTF-8' : encoding
335 end
337 end
336
338
337 # Fetches new changesets for all repositories of active projects
339 # Fetches new changesets for all repositories of active projects
338 # Can be called periodically by an external script
340 # Can be called periodically by an external script
339 # eg. ruby script/runner "Repository.fetch_changesets"
341 # eg. ruby script/runner "Repository.fetch_changesets"
340 def self.fetch_changesets
342 def self.fetch_changesets
341 Project.active.has_module(:repository).all.each do |project|
343 Project.active.has_module(:repository).all.each do |project|
342 project.repositories.each do |repository|
344 project.repositories.each do |repository|
343 begin
345 begin
344 repository.fetch_changesets
346 repository.fetch_changesets
345 rescue Redmine::Scm::Adapters::CommandFailed => e
347 rescue Redmine::Scm::Adapters::CommandFailed => e
346 logger.error "scm: error during fetching changesets: #{e.message}"
348 logger.error "scm: error during fetching changesets: #{e.message}"
347 end
349 end
348 end
350 end
349 end
351 end
350 end
352 end
351
353
352 # scan changeset comments to find related and fixed issues for all repositories
354 # scan changeset comments to find related and fixed issues for all repositories
353 def self.scan_changesets_for_issue_ids
355 def self.scan_changesets_for_issue_ids
354 all.each(&:scan_changesets_for_issue_ids)
356 all.each(&:scan_changesets_for_issue_ids)
355 end
357 end
356
358
357 def self.scm_name
359 def self.scm_name
358 'Abstract'
360 'Abstract'
359 end
361 end
360
362
361 def self.available_scm
363 def self.available_scm
362 subclasses.collect {|klass| [klass.scm_name, klass.name]}
364 subclasses.collect {|klass| [klass.scm_name, klass.name]}
363 end
365 end
364
366
365 def self.factory(klass_name, *args)
367 def self.factory(klass_name, *args)
366 repository_class(klass_name).new(*args) rescue nil
368 repository_class(klass_name).new(*args) rescue nil
367 end
369 end
368
370
369 def self.repository_class(class_name)
371 def self.repository_class(class_name)
370 class_name = class_name.to_s.camelize
372 class_name = class_name.to_s.camelize
371 if Redmine::Scm::Base.all.include?(class_name)
373 if Redmine::Scm::Base.all.include?(class_name)
372 "Repository::#{class_name}".constantize
374 "Repository::#{class_name}".constantize
373 end
375 end
374 end
376 end
375
377
376 def self.scm_adapter_class
378 def self.scm_adapter_class
377 nil
379 nil
378 end
380 end
379
381
380 def self.scm_command
382 def self.scm_command
381 ret = ""
383 ret = ""
382 begin
384 begin
383 ret = self.scm_adapter_class.client_command if self.scm_adapter_class
385 ret = self.scm_adapter_class.client_command if self.scm_adapter_class
384 rescue Exception => e
386 rescue Exception => e
385 logger.error "scm: error during get command: #{e.message}"
387 logger.error "scm: error during get command: #{e.message}"
386 end
388 end
387 ret
389 ret
388 end
390 end
389
391
390 def self.scm_version_string
392 def self.scm_version_string
391 ret = ""
393 ret = ""
392 begin
394 begin
393 ret = self.scm_adapter_class.client_version_string if self.scm_adapter_class
395 ret = self.scm_adapter_class.client_version_string if self.scm_adapter_class
394 rescue Exception => e
396 rescue Exception => e
395 logger.error "scm: error during get version string: #{e.message}"
397 logger.error "scm: error during get version string: #{e.message}"
396 end
398 end
397 ret
399 ret
398 end
400 end
399
401
400 def self.scm_available
402 def self.scm_available
401 ret = false
403 ret = false
402 begin
404 begin
403 ret = self.scm_adapter_class.client_available if self.scm_adapter_class
405 ret = self.scm_adapter_class.client_available if self.scm_adapter_class
404 rescue Exception => e
406 rescue Exception => e
405 logger.error "scm: error during get scm available: #{e.message}"
407 logger.error "scm: error during get scm available: #{e.message}"
406 end
408 end
407 ret
409 ret
408 end
410 end
409
411
410 def set_as_default?
412 def set_as_default?
411 new_record? && project && Repository.where(:project_id => project.id).empty?
413 new_record? && project && Repository.where(:project_id => project.id).empty?
412 end
414 end
413
415
414 # Returns a hash with statistics by author in the following form:
416 # Returns a hash with statistics by author in the following form:
415 # {
417 # {
416 # "John Smith" => { :commits => 45, :changes => 324 },
418 # "John Smith" => { :commits => 45, :changes => 324 },
417 # "Bob" => { ... }
419 # "Bob" => { ... }
418 # }
420 # }
419 #
421 #
420 # Notes:
422 # Notes:
421 # - this hash honnors the users mapping defined for the repository
423 # - this hash honnors the users mapping defined for the repository
422 def stats_by_author
424 def stats_by_author
423 commits = Changeset.where("repository_id = ?", id).select("committer, user_id, count(*) as count").group("committer, user_id")
425 commits = Changeset.where("repository_id = ?", id).select("committer, user_id, count(*) as count").group("committer, user_id")
424
426
425 #TODO: restore ordering ; this line probably never worked
427 #TODO: restore ordering ; this line probably never worked
426 #commits.to_a.sort! {|x, y| x.last <=> y.last}
428 #commits.to_a.sort! {|x, y| x.last <=> y.last}
427
429
428 changes = Change.joins(:changeset).where("#{Changeset.table_name}.repository_id = ?", id).select("committer, user_id, count(*) as count").group("committer, user_id")
430 changes = Change.joins(:changeset).where("#{Changeset.table_name}.repository_id = ?", id).select("committer, user_id, count(*) as count").group("committer, user_id")
429
431
430 user_ids = changesets.map(&:user_id).compact.uniq
432 user_ids = changesets.map(&:user_id).compact.uniq
431 authors_names = User.where(:id => user_ids).inject({}) do |memo, user|
433 authors_names = User.where(:id => user_ids).inject({}) do |memo, user|
432 memo[user.id] = user.to_s
434 memo[user.id] = user.to_s
433 memo
435 memo
434 end
436 end
435
437
436 (commits + changes).inject({}) do |hash, element|
438 (commits + changes).inject({}) do |hash, element|
437 mapped_name = element.committer
439 mapped_name = element.committer
438 if username = authors_names[element.user_id.to_i]
440 if username = authors_names[element.user_id.to_i]
439 mapped_name = username
441 mapped_name = username
440 end
442 end
441 hash[mapped_name] ||= { :commits_count => 0, :changes_count => 0 }
443 hash[mapped_name] ||= { :commits_count => 0, :changes_count => 0 }
442 if element.is_a?(Changeset)
444 if element.is_a?(Changeset)
443 hash[mapped_name][:commits_count] += element.count.to_i
445 hash[mapped_name][:commits_count] += element.count.to_i
444 else
446 else
445 hash[mapped_name][:changes_count] += element.count.to_i
447 hash[mapped_name][:changes_count] += element.count.to_i
446 end
448 end
447 hash
449 hash
448 end
450 end
449 end
451 end
450
452
451 # Returns a scope of changesets that come from the same commit as the given changeset
453 # Returns a scope of changesets that come from the same commit as the given changeset
452 # in different repositories that point to the same backend
454 # in different repositories that point to the same backend
453 def same_commits_in_scope(scope, changeset)
455 def same_commits_in_scope(scope, changeset)
454 scope = scope.joins(:repository).where(:repositories => {:url => url, :root_url => root_url, :type => type})
456 scope = scope.joins(:repository).where(:repositories => {:url => url, :root_url => root_url, :type => type})
455 if changeset.scmid.present?
457 if changeset.scmid.present?
456 scope = scope.where(:scmid => changeset.scmid)
458 scope = scope.where(:scmid => changeset.scmid)
457 else
459 else
458 scope = scope.where(:revision => changeset.revision)
460 scope = scope.where(:revision => changeset.revision)
459 end
461 end
460 scope
462 scope
461 end
463 end
462
464
463 protected
465 protected
464
466
465 # Validates repository url based against an optional regular expression
467 # Validates repository url based against an optional regular expression
466 # that can be set in the Redmine configuration file.
468 # that can be set in the Redmine configuration file.
467 def validate_repository_path(attribute=:url)
469 def validate_repository_path(attribute=:url)
468 regexp = Redmine::Configuration["scm_#{scm_name.to_s.downcase}_path_regexp"]
470 regexp = Redmine::Configuration["scm_#{scm_name.to_s.downcase}_path_regexp"]
469 if changes[attribute] && regexp.present?
471 if changes[attribute] && regexp.present?
470 regexp = regexp.to_s.strip.gsub('%project%') {Regexp.escape(project.try(:identifier).to_s)}
472 regexp = regexp.to_s.strip.gsub('%project%') {Regexp.escape(project.try(:identifier).to_s)}
471 unless send(attribute).to_s.match(Regexp.new("\\A#{regexp}\\z"))
473 unless send(attribute).to_s.match(Regexp.new("\\A#{regexp}\\z"))
472 errors.add(attribute, :invalid)
474 errors.add(attribute, :invalid)
473 end
475 end
474 end
476 end
475 end
477 end
476
478
477 def normalize_identifier
479 def normalize_identifier
478 self.identifier = identifier.to_s.strip
480 self.identifier = identifier.to_s.strip
479 end
481 end
480
482
481 def check_default
483 def check_default
482 if !is_default? && set_as_default?
484 if !is_default? && set_as_default?
483 self.is_default = true
485 self.is_default = true
484 end
486 end
485 if is_default? && is_default_changed?
487 if is_default? && is_default_changed?
486 Repository.where(["project_id = ?", project_id]).update_all(["is_default = ?", false])
488 Repository.where(["project_id = ?", project_id]).update_all(["is_default = ?", false])
487 end
489 end
488 end
490 end
489
491
490 def load_entries_changesets(entries)
492 def load_entries_changesets(entries)
491 if entries
493 if entries
492 entries.each do |entry|
494 entries.each do |entry|
493 if entry.lastrev && entry.lastrev.identifier
495 if entry.lastrev && entry.lastrev.identifier
494 entry.changeset = find_changeset_by_name(entry.lastrev.identifier)
496 entry.changeset = find_changeset_by_name(entry.lastrev.identifier)
495 end
497 end
496 end
498 end
497 end
499 end
498 end
500 end
499
501
500 private
502 private
501
503
502 # Deletes repository data
504 # Deletes repository data
503 def clear_changesets
505 def clear_changesets
504 cs = Changeset.table_name
506 cs = Changeset.table_name
505 ch = Change.table_name
507 ch = Change.table_name
506 ci = "#{table_name_prefix}changesets_issues#{table_name_suffix}"
508 ci = "#{table_name_prefix}changesets_issues#{table_name_suffix}"
507 cp = "#{table_name_prefix}changeset_parents#{table_name_suffix}"
509 cp = "#{table_name_prefix}changeset_parents#{table_name_suffix}"
508
510
509 self.class.connection.delete("DELETE FROM #{ch} WHERE #{ch}.changeset_id IN (SELECT #{cs}.id FROM #{cs} WHERE #{cs}.repository_id = #{id})")
511 self.class.connection.delete("DELETE FROM #{ch} WHERE #{ch}.changeset_id IN (SELECT #{cs}.id FROM #{cs} WHERE #{cs}.repository_id = #{id})")
510 self.class.connection.delete("DELETE FROM #{ci} WHERE #{ci}.changeset_id IN (SELECT #{cs}.id FROM #{cs} WHERE #{cs}.repository_id = #{id})")
512 self.class.connection.delete("DELETE FROM #{ci} WHERE #{ci}.changeset_id IN (SELECT #{cs}.id FROM #{cs} WHERE #{cs}.repository_id = #{id})")
511 self.class.connection.delete("DELETE FROM #{cp} WHERE #{cp}.changeset_id IN (SELECT #{cs}.id FROM #{cs} WHERE #{cs}.repository_id = #{id})")
513 self.class.connection.delete("DELETE FROM #{cp} WHERE #{cp}.changeset_id IN (SELECT #{cs}.id FROM #{cs} WHERE #{cs}.repository_id = #{id})")
512 self.class.connection.delete("DELETE FROM #{cs} WHERE #{cs}.repository_id = #{id}")
514 self.class.connection.delete("DELETE FROM #{cs} WHERE #{cs}.repository_id = #{id}")
513 end
515 end
514 end
516 end
@@ -1,928 +1,929
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2016 Jean-Philippe Lang
2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 require "digest/sha1"
18 require "digest/sha1"
19
19
20 class User < Principal
20 class User < Principal
21 include Redmine::SafeAttributes
21 include Redmine::SafeAttributes
22
22
23 # Different ways of displaying/sorting users
23 # Different ways of displaying/sorting users
24 USER_FORMATS = {
24 USER_FORMATS = {
25 :firstname_lastname => {
25 :firstname_lastname => {
26 :string => '#{firstname} #{lastname}',
26 :string => '#{firstname} #{lastname}',
27 :order => %w(firstname lastname id),
27 :order => %w(firstname lastname id),
28 :setting_order => 1
28 :setting_order => 1
29 },
29 },
30 :firstname_lastinitial => {
30 :firstname_lastinitial => {
31 :string => '#{firstname} #{lastname.to_s.chars.first}.',
31 :string => '#{firstname} #{lastname.to_s.chars.first}.',
32 :order => %w(firstname lastname id),
32 :order => %w(firstname lastname id),
33 :setting_order => 2
33 :setting_order => 2
34 },
34 },
35 :firstinitial_lastname => {
35 :firstinitial_lastname => {
36 :string => '#{firstname.to_s.gsub(/(([[:alpha:]])[[:alpha:]]*\.?)/, \'\2.\')} #{lastname}',
36 :string => '#{firstname.to_s.gsub(/(([[:alpha:]])[[:alpha:]]*\.?)/, \'\2.\')} #{lastname}',
37 :order => %w(firstname lastname id),
37 :order => %w(firstname lastname id),
38 :setting_order => 2
38 :setting_order => 2
39 },
39 },
40 :firstname => {
40 :firstname => {
41 :string => '#{firstname}',
41 :string => '#{firstname}',
42 :order => %w(firstname id),
42 :order => %w(firstname id),
43 :setting_order => 3
43 :setting_order => 3
44 },
44 },
45 :lastname_firstname => {
45 :lastname_firstname => {
46 :string => '#{lastname} #{firstname}',
46 :string => '#{lastname} #{firstname}',
47 :order => %w(lastname firstname id),
47 :order => %w(lastname firstname id),
48 :setting_order => 4
48 :setting_order => 4
49 },
49 },
50 :lastnamefirstname => {
50 :lastnamefirstname => {
51 :string => '#{lastname}#{firstname}',
51 :string => '#{lastname}#{firstname}',
52 :order => %w(lastname firstname id),
52 :order => %w(lastname firstname id),
53 :setting_order => 5
53 :setting_order => 5
54 },
54 },
55 :lastname_comma_firstname => {
55 :lastname_comma_firstname => {
56 :string => '#{lastname}, #{firstname}',
56 :string => '#{lastname}, #{firstname}',
57 :order => %w(lastname firstname id),
57 :order => %w(lastname firstname id),
58 :setting_order => 6
58 :setting_order => 6
59 },
59 },
60 :lastname => {
60 :lastname => {
61 :string => '#{lastname}',
61 :string => '#{lastname}',
62 :order => %w(lastname id),
62 :order => %w(lastname id),
63 :setting_order => 7
63 :setting_order => 7
64 },
64 },
65 :username => {
65 :username => {
66 :string => '#{login}',
66 :string => '#{login}',
67 :order => %w(login id),
67 :order => %w(login id),
68 :setting_order => 8
68 :setting_order => 8
69 },
69 },
70 }
70 }
71
71
72 MAIL_NOTIFICATION_OPTIONS = [
72 MAIL_NOTIFICATION_OPTIONS = [
73 ['all', :label_user_mail_option_all],
73 ['all', :label_user_mail_option_all],
74 ['selected', :label_user_mail_option_selected],
74 ['selected', :label_user_mail_option_selected],
75 ['only_my_events', :label_user_mail_option_only_my_events],
75 ['only_my_events', :label_user_mail_option_only_my_events],
76 ['only_assigned', :label_user_mail_option_only_assigned],
76 ['only_assigned', :label_user_mail_option_only_assigned],
77 ['only_owner', :label_user_mail_option_only_owner],
77 ['only_owner', :label_user_mail_option_only_owner],
78 ['none', :label_user_mail_option_none]
78 ['none', :label_user_mail_option_none]
79 ]
79 ]
80
80
81 has_and_belongs_to_many :groups,
81 has_and_belongs_to_many :groups,
82 :join_table => "#{table_name_prefix}groups_users#{table_name_suffix}",
82 :join_table => "#{table_name_prefix}groups_users#{table_name_suffix}",
83 :after_add => Proc.new {|user, group| group.user_added(user)},
83 :after_add => Proc.new {|user, group| group.user_added(user)},
84 :after_remove => Proc.new {|user, group| group.user_removed(user)}
84 :after_remove => Proc.new {|user, group| group.user_removed(user)}
85 has_many :changesets, :dependent => :nullify
85 has_many :changesets, :dependent => :nullify
86 has_one :preference, :dependent => :destroy, :class_name => 'UserPreference'
86 has_one :preference, :dependent => :destroy, :class_name => 'UserPreference'
87 has_one :rss_token, lambda {where "action='feeds'"}, :class_name => 'Token'
87 has_one :rss_token, lambda {where "action='feeds'"}, :class_name => 'Token'
88 has_one :api_token, lambda {where "action='api'"}, :class_name => 'Token'
88 has_one :api_token, lambda {where "action='api'"}, :class_name => 'Token'
89 has_one :email_address, lambda {where :is_default => true}, :autosave => true
89 has_one :email_address, lambda {where :is_default => true}, :autosave => true
90 has_many :email_addresses, :dependent => :delete_all
90 has_many :email_addresses, :dependent => :delete_all
91 belongs_to :auth_source
91 belongs_to :auth_source
92
92
93 scope :logged, lambda { where("#{User.table_name}.status <> #{STATUS_ANONYMOUS}") }
93 scope :logged, lambda { where("#{User.table_name}.status <> #{STATUS_ANONYMOUS}") }
94 scope :status, lambda {|arg| where(arg.blank? ? nil : {:status => arg.to_i}) }
94 scope :status, lambda {|arg| where(arg.blank? ? nil : {:status => arg.to_i}) }
95
95
96 acts_as_customizable
96 acts_as_customizable
97
97
98 attr_accessor :password, :password_confirmation, :generate_password
98 attr_accessor :password, :password_confirmation, :generate_password
99 attr_accessor :last_before_login_on
99 attr_accessor :last_before_login_on
100 attr_accessor :remote_ip
100 attr_accessor :remote_ip
101
101
102 # Prevents unauthorized assignments
102 # Prevents unauthorized assignments
103 attr_protected :password, :password_confirmation, :hashed_password
103 attr_protected :password, :password_confirmation, :hashed_password
104
104
105 LOGIN_LENGTH_LIMIT = 60
105 LOGIN_LENGTH_LIMIT = 60
106 MAIL_LENGTH_LIMIT = 60
106 MAIL_LENGTH_LIMIT = 60
107
107
108 validates_presence_of :login, :firstname, :lastname, :if => Proc.new { |user| !user.is_a?(AnonymousUser) }
108 validates_presence_of :login, :firstname, :lastname, :if => Proc.new { |user| !user.is_a?(AnonymousUser) }
109 validates_uniqueness_of :login, :if => Proc.new { |user| user.login_changed? && user.login.present? }, :case_sensitive => false
109 validates_uniqueness_of :login, :if => Proc.new { |user| user.login_changed? && user.login.present? }, :case_sensitive => false
110 # Login must contain letters, numbers, underscores only
110 # Login must contain letters, numbers, underscores only
111 validates_format_of :login, :with => /\A[a-z0-9_\-@\.]*\z/i
111 validates_format_of :login, :with => /\A[a-z0-9_\-@\.]*\z/i
112 validates_length_of :login, :maximum => LOGIN_LENGTH_LIMIT
112 validates_length_of :login, :maximum => LOGIN_LENGTH_LIMIT
113 validates_length_of :firstname, :lastname, :maximum => 30
113 validates_length_of :firstname, :lastname, :maximum => 30
114 validates_length_of :identity_url, maximum: 255
114 validates_inclusion_of :mail_notification, :in => MAIL_NOTIFICATION_OPTIONS.collect(&:first), :allow_blank => true
115 validates_inclusion_of :mail_notification, :in => MAIL_NOTIFICATION_OPTIONS.collect(&:first), :allow_blank => true
115 validate :validate_password_length
116 validate :validate_password_length
116 validate do
117 validate do
117 if password_confirmation && password != password_confirmation
118 if password_confirmation && password != password_confirmation
118 errors.add(:password, :confirmation)
119 errors.add(:password, :confirmation)
119 end
120 end
120 end
121 end
121
122
122 self.valid_statuses = [STATUS_ACTIVE, STATUS_REGISTERED, STATUS_LOCKED]
123 self.valid_statuses = [STATUS_ACTIVE, STATUS_REGISTERED, STATUS_LOCKED]
123
124
124 before_validation :instantiate_email_address
125 before_validation :instantiate_email_address
125 before_create :set_mail_notification
126 before_create :set_mail_notification
126 before_save :generate_password_if_needed, :update_hashed_password
127 before_save :generate_password_if_needed, :update_hashed_password
127 before_destroy :remove_references_before_destroy
128 before_destroy :remove_references_before_destroy
128 after_save :update_notified_project_ids, :destroy_tokens, :deliver_security_notification
129 after_save :update_notified_project_ids, :destroy_tokens, :deliver_security_notification
129 after_destroy :deliver_security_notification
130 after_destroy :deliver_security_notification
130
131
131 scope :in_group, lambda {|group|
132 scope :in_group, lambda {|group|
132 group_id = group.is_a?(Group) ? group.id : group.to_i
133 group_id = group.is_a?(Group) ? group.id : group.to_i
133 where("#{User.table_name}.id IN (SELECT gu.user_id FROM #{table_name_prefix}groups_users#{table_name_suffix} gu WHERE gu.group_id = ?)", group_id)
134 where("#{User.table_name}.id IN (SELECT gu.user_id FROM #{table_name_prefix}groups_users#{table_name_suffix} gu WHERE gu.group_id = ?)", group_id)
134 }
135 }
135 scope :not_in_group, lambda {|group|
136 scope :not_in_group, lambda {|group|
136 group_id = group.is_a?(Group) ? group.id : group.to_i
137 group_id = group.is_a?(Group) ? group.id : group.to_i
137 where("#{User.table_name}.id NOT IN (SELECT gu.user_id FROM #{table_name_prefix}groups_users#{table_name_suffix} gu WHERE gu.group_id = ?)", group_id)
138 where("#{User.table_name}.id NOT IN (SELECT gu.user_id FROM #{table_name_prefix}groups_users#{table_name_suffix} gu WHERE gu.group_id = ?)", group_id)
138 }
139 }
139 scope :sorted, lambda { order(*User.fields_for_order_statement)}
140 scope :sorted, lambda { order(*User.fields_for_order_statement)}
140 scope :having_mail, lambda {|arg|
141 scope :having_mail, lambda {|arg|
141 addresses = Array.wrap(arg).map {|a| a.to_s.downcase}
142 addresses = Array.wrap(arg).map {|a| a.to_s.downcase}
142 if addresses.any?
143 if addresses.any?
143 joins(:email_addresses).where("LOWER(#{EmailAddress.table_name}.address) IN (?)", addresses).distinct
144 joins(:email_addresses).where("LOWER(#{EmailAddress.table_name}.address) IN (?)", addresses).distinct
144 else
145 else
145 none
146 none
146 end
147 end
147 }
148 }
148
149
149 def set_mail_notification
150 def set_mail_notification
150 self.mail_notification = Setting.default_notification_option if self.mail_notification.blank?
151 self.mail_notification = Setting.default_notification_option if self.mail_notification.blank?
151 true
152 true
152 end
153 end
153
154
154 def update_hashed_password
155 def update_hashed_password
155 # update hashed_password if password was set
156 # update hashed_password if password was set
156 if self.password && self.auth_source_id.blank?
157 if self.password && self.auth_source_id.blank?
157 salt_password(password)
158 salt_password(password)
158 end
159 end
159 end
160 end
160
161
161 alias :base_reload :reload
162 alias :base_reload :reload
162 def reload(*args)
163 def reload(*args)
163 @name = nil
164 @name = nil
164 @projects_by_role = nil
165 @projects_by_role = nil
165 @membership_by_project_id = nil
166 @membership_by_project_id = nil
166 @notified_projects_ids = nil
167 @notified_projects_ids = nil
167 @notified_projects_ids_changed = false
168 @notified_projects_ids_changed = false
168 @builtin_role = nil
169 @builtin_role = nil
169 @visible_project_ids = nil
170 @visible_project_ids = nil
170 @managed_roles = nil
171 @managed_roles = nil
171 base_reload(*args)
172 base_reload(*args)
172 end
173 end
173
174
174 def mail
175 def mail
175 email_address.try(:address)
176 email_address.try(:address)
176 end
177 end
177
178
178 def mail=(arg)
179 def mail=(arg)
179 email = email_address || build_email_address
180 email = email_address || build_email_address
180 email.address = arg
181 email.address = arg
181 end
182 end
182
183
183 def mail_changed?
184 def mail_changed?
184 email_address.try(:address_changed?)
185 email_address.try(:address_changed?)
185 end
186 end
186
187
187 def mails
188 def mails
188 email_addresses.pluck(:address)
189 email_addresses.pluck(:address)
189 end
190 end
190
191
191 def self.find_or_initialize_by_identity_url(url)
192 def self.find_or_initialize_by_identity_url(url)
192 user = where(:identity_url => url).first
193 user = where(:identity_url => url).first
193 unless user
194 unless user
194 user = User.new
195 user = User.new
195 user.identity_url = url
196 user.identity_url = url
196 end
197 end
197 user
198 user
198 end
199 end
199
200
200 def identity_url=(url)
201 def identity_url=(url)
201 if url.blank?
202 if url.blank?
202 write_attribute(:identity_url, '')
203 write_attribute(:identity_url, '')
203 else
204 else
204 begin
205 begin
205 write_attribute(:identity_url, OpenIdAuthentication.normalize_identifier(url))
206 write_attribute(:identity_url, OpenIdAuthentication.normalize_identifier(url))
206 rescue OpenIdAuthentication::InvalidOpenId
207 rescue OpenIdAuthentication::InvalidOpenId
207 # Invalid url, don't save
208 # Invalid url, don't save
208 end
209 end
209 end
210 end
210 self.read_attribute(:identity_url)
211 self.read_attribute(:identity_url)
211 end
212 end
212
213
213 # Returns the user that matches provided login and password, or nil
214 # Returns the user that matches provided login and password, or nil
214 def self.try_to_login(login, password, active_only=true)
215 def self.try_to_login(login, password, active_only=true)
215 login = login.to_s
216 login = login.to_s
216 password = password.to_s
217 password = password.to_s
217
218
218 # Make sure no one can sign in with an empty login or password
219 # Make sure no one can sign in with an empty login or password
219 return nil if login.empty? || password.empty?
220 return nil if login.empty? || password.empty?
220 user = find_by_login(login)
221 user = find_by_login(login)
221 if user
222 if user
222 # user is already in local database
223 # user is already in local database
223 return nil unless user.check_password?(password)
224 return nil unless user.check_password?(password)
224 return nil if !user.active? && active_only
225 return nil if !user.active? && active_only
225 else
226 else
226 # user is not yet registered, try to authenticate with available sources
227 # user is not yet registered, try to authenticate with available sources
227 attrs = AuthSource.authenticate(login, password)
228 attrs = AuthSource.authenticate(login, password)
228 if attrs
229 if attrs
229 user = new(attrs)
230 user = new(attrs)
230 user.login = login
231 user.login = login
231 user.language = Setting.default_language
232 user.language = Setting.default_language
232 if user.save
233 if user.save
233 user.reload
234 user.reload
234 logger.info("User '#{user.login}' created from external auth source: #{user.auth_source.type} - #{user.auth_source.name}") if logger && user.auth_source
235 logger.info("User '#{user.login}' created from external auth source: #{user.auth_source.type} - #{user.auth_source.name}") if logger && user.auth_source
235 end
236 end
236 end
237 end
237 end
238 end
238 user.update_column(:last_login_on, Time.now) if user && !user.new_record? && user.active?
239 user.update_column(:last_login_on, Time.now) if user && !user.new_record? && user.active?
239 user
240 user
240 rescue => text
241 rescue => text
241 raise text
242 raise text
242 end
243 end
243
244
244 # Returns the user who matches the given autologin +key+ or nil
245 # Returns the user who matches the given autologin +key+ or nil
245 def self.try_to_autologin(key)
246 def self.try_to_autologin(key)
246 user = Token.find_active_user('autologin', key, Setting.autologin.to_i)
247 user = Token.find_active_user('autologin', key, Setting.autologin.to_i)
247 if user
248 if user
248 user.update_column(:last_login_on, Time.now)
249 user.update_column(:last_login_on, Time.now)
249 user
250 user
250 end
251 end
251 end
252 end
252
253
253 def self.name_formatter(formatter = nil)
254 def self.name_formatter(formatter = nil)
254 USER_FORMATS[formatter || Setting.user_format] || USER_FORMATS[:firstname_lastname]
255 USER_FORMATS[formatter || Setting.user_format] || USER_FORMATS[:firstname_lastname]
255 end
256 end
256
257
257 # Returns an array of fields names than can be used to make an order statement for users
258 # Returns an array of fields names than can be used to make an order statement for users
258 # according to how user names are displayed
259 # according to how user names are displayed
259 # Examples:
260 # Examples:
260 #
261 #
261 # User.fields_for_order_statement => ['users.login', 'users.id']
262 # User.fields_for_order_statement => ['users.login', 'users.id']
262 # User.fields_for_order_statement('authors') => ['authors.login', 'authors.id']
263 # User.fields_for_order_statement('authors') => ['authors.login', 'authors.id']
263 def self.fields_for_order_statement(table=nil)
264 def self.fields_for_order_statement(table=nil)
264 table ||= table_name
265 table ||= table_name
265 name_formatter[:order].map {|field| "#{table}.#{field}"}
266 name_formatter[:order].map {|field| "#{table}.#{field}"}
266 end
267 end
267
268
268 # Return user's full name for display
269 # Return user's full name for display
269 def name(formatter = nil)
270 def name(formatter = nil)
270 f = self.class.name_formatter(formatter)
271 f = self.class.name_formatter(formatter)
271 if formatter
272 if formatter
272 eval('"' + f[:string] + '"')
273 eval('"' + f[:string] + '"')
273 else
274 else
274 @name ||= eval('"' + f[:string] + '"')
275 @name ||= eval('"' + f[:string] + '"')
275 end
276 end
276 end
277 end
277
278
278 def active?
279 def active?
279 self.status == STATUS_ACTIVE
280 self.status == STATUS_ACTIVE
280 end
281 end
281
282
282 def registered?
283 def registered?
283 self.status == STATUS_REGISTERED
284 self.status == STATUS_REGISTERED
284 end
285 end
285
286
286 def locked?
287 def locked?
287 self.status == STATUS_LOCKED
288 self.status == STATUS_LOCKED
288 end
289 end
289
290
290 def activate
291 def activate
291 self.status = STATUS_ACTIVE
292 self.status = STATUS_ACTIVE
292 end
293 end
293
294
294 def register
295 def register
295 self.status = STATUS_REGISTERED
296 self.status = STATUS_REGISTERED
296 end
297 end
297
298
298 def lock
299 def lock
299 self.status = STATUS_LOCKED
300 self.status = STATUS_LOCKED
300 end
301 end
301
302
302 def activate!
303 def activate!
303 update_attribute(:status, STATUS_ACTIVE)
304 update_attribute(:status, STATUS_ACTIVE)
304 end
305 end
305
306
306 def register!
307 def register!
307 update_attribute(:status, STATUS_REGISTERED)
308 update_attribute(:status, STATUS_REGISTERED)
308 end
309 end
309
310
310 def lock!
311 def lock!
311 update_attribute(:status, STATUS_LOCKED)
312 update_attribute(:status, STATUS_LOCKED)
312 end
313 end
313
314
314 # Returns true if +clear_password+ is the correct user's password, otherwise false
315 # Returns true if +clear_password+ is the correct user's password, otherwise false
315 def check_password?(clear_password)
316 def check_password?(clear_password)
316 if auth_source_id.present?
317 if auth_source_id.present?
317 auth_source.authenticate(self.login, clear_password)
318 auth_source.authenticate(self.login, clear_password)
318 else
319 else
319 User.hash_password("#{salt}#{User.hash_password clear_password}") == hashed_password
320 User.hash_password("#{salt}#{User.hash_password clear_password}") == hashed_password
320 end
321 end
321 end
322 end
322
323
323 # Generates a random salt and computes hashed_password for +clear_password+
324 # Generates a random salt and computes hashed_password for +clear_password+
324 # The hashed password is stored in the following form: SHA1(salt + SHA1(password))
325 # The hashed password is stored in the following form: SHA1(salt + SHA1(password))
325 def salt_password(clear_password)
326 def salt_password(clear_password)
326 self.salt = User.generate_salt
327 self.salt = User.generate_salt
327 self.hashed_password = User.hash_password("#{salt}#{User.hash_password clear_password}")
328 self.hashed_password = User.hash_password("#{salt}#{User.hash_password clear_password}")
328 self.passwd_changed_on = Time.now.change(:usec => 0)
329 self.passwd_changed_on = Time.now.change(:usec => 0)
329 end
330 end
330
331
331 # Does the backend storage allow this user to change their password?
332 # Does the backend storage allow this user to change their password?
332 def change_password_allowed?
333 def change_password_allowed?
333 return true if auth_source.nil?
334 return true if auth_source.nil?
334 return auth_source.allow_password_changes?
335 return auth_source.allow_password_changes?
335 end
336 end
336
337
337 # Returns true if the user password has expired
338 # Returns true if the user password has expired
338 def password_expired?
339 def password_expired?
339 period = Setting.password_max_age.to_i
340 period = Setting.password_max_age.to_i
340 if period.zero?
341 if period.zero?
341 false
342 false
342 else
343 else
343 changed_on = self.passwd_changed_on || Time.at(0)
344 changed_on = self.passwd_changed_on || Time.at(0)
344 changed_on < period.days.ago
345 changed_on < period.days.ago
345 end
346 end
346 end
347 end
347
348
348 def must_change_password?
349 def must_change_password?
349 (must_change_passwd? || password_expired?) && change_password_allowed?
350 (must_change_passwd? || password_expired?) && change_password_allowed?
350 end
351 end
351
352
352 def generate_password?
353 def generate_password?
353 generate_password == '1' || generate_password == true
354 generate_password == '1' || generate_password == true
354 end
355 end
355
356
356 # Generate and set a random password on given length
357 # Generate and set a random password on given length
357 def random_password(length=40)
358 def random_password(length=40)
358 chars = ("a".."z").to_a + ("A".."Z").to_a + ("0".."9").to_a
359 chars = ("a".."z").to_a + ("A".."Z").to_a + ("0".."9").to_a
359 chars -= %w(0 O 1 l)
360 chars -= %w(0 O 1 l)
360 password = ''
361 password = ''
361 length.times {|i| password << chars[SecureRandom.random_number(chars.size)] }
362 length.times {|i| password << chars[SecureRandom.random_number(chars.size)] }
362 self.password = password
363 self.password = password
363 self.password_confirmation = password
364 self.password_confirmation = password
364 self
365 self
365 end
366 end
366
367
367 def pref
368 def pref
368 self.preference ||= UserPreference.new(:user => self)
369 self.preference ||= UserPreference.new(:user => self)
369 end
370 end
370
371
371 def time_zone
372 def time_zone
372 @time_zone ||= (self.pref.time_zone.blank? ? nil : ActiveSupport::TimeZone[self.pref.time_zone])
373 @time_zone ||= (self.pref.time_zone.blank? ? nil : ActiveSupport::TimeZone[self.pref.time_zone])
373 end
374 end
374
375
375 def force_default_language?
376 def force_default_language?
376 Setting.force_default_language_for_loggedin?
377 Setting.force_default_language_for_loggedin?
377 end
378 end
378
379
379 def language
380 def language
380 if force_default_language?
381 if force_default_language?
381 Setting.default_language
382 Setting.default_language
382 else
383 else
383 super
384 super
384 end
385 end
385 end
386 end
386
387
387 def wants_comments_in_reverse_order?
388 def wants_comments_in_reverse_order?
388 self.pref[:comments_sorting] == 'desc'
389 self.pref[:comments_sorting] == 'desc'
389 end
390 end
390
391
391 # Return user's RSS key (a 40 chars long string), used to access feeds
392 # Return user's RSS key (a 40 chars long string), used to access feeds
392 def rss_key
393 def rss_key
393 if rss_token.nil?
394 if rss_token.nil?
394 create_rss_token(:action => 'feeds')
395 create_rss_token(:action => 'feeds')
395 end
396 end
396 rss_token.value
397 rss_token.value
397 end
398 end
398
399
399 # Return user's API key (a 40 chars long string), used to access the API
400 # Return user's API key (a 40 chars long string), used to access the API
400 def api_key
401 def api_key
401 if api_token.nil?
402 if api_token.nil?
402 create_api_token(:action => 'api')
403 create_api_token(:action => 'api')
403 end
404 end
404 api_token.value
405 api_token.value
405 end
406 end
406
407
407 # Generates a new session token and returns its value
408 # Generates a new session token and returns its value
408 def generate_session_token
409 def generate_session_token
409 token = Token.create!(:user_id => id, :action => 'session')
410 token = Token.create!(:user_id => id, :action => 'session')
410 token.value
411 token.value
411 end
412 end
412
413
413 # Returns true if token is a valid session token for the user whose id is user_id
414 # Returns true if token is a valid session token for the user whose id is user_id
414 def self.verify_session_token(user_id, token)
415 def self.verify_session_token(user_id, token)
415 return false if user_id.blank? || token.blank?
416 return false if user_id.blank? || token.blank?
416
417
417 scope = Token.where(:user_id => user_id, :value => token.to_s, :action => 'session')
418 scope = Token.where(:user_id => user_id, :value => token.to_s, :action => 'session')
418 if Setting.session_lifetime?
419 if Setting.session_lifetime?
419 scope = scope.where("created_on > ?", Setting.session_lifetime.to_i.minutes.ago)
420 scope = scope.where("created_on > ?", Setting.session_lifetime.to_i.minutes.ago)
420 end
421 end
421 if Setting.session_timeout?
422 if Setting.session_timeout?
422 scope = scope.where("updated_on > ?", Setting.session_timeout.to_i.minutes.ago)
423 scope = scope.where("updated_on > ?", Setting.session_timeout.to_i.minutes.ago)
423 end
424 end
424 scope.update_all(:updated_on => Time.now) == 1
425 scope.update_all(:updated_on => Time.now) == 1
425 end
426 end
426
427
427 # Return an array of project ids for which the user has explicitly turned mail notifications on
428 # Return an array of project ids for which the user has explicitly turned mail notifications on
428 def notified_projects_ids
429 def notified_projects_ids
429 @notified_projects_ids ||= memberships.select {|m| m.mail_notification?}.collect(&:project_id)
430 @notified_projects_ids ||= memberships.select {|m| m.mail_notification?}.collect(&:project_id)
430 end
431 end
431
432
432 def notified_project_ids=(ids)
433 def notified_project_ids=(ids)
433 @notified_projects_ids_changed = true
434 @notified_projects_ids_changed = true
434 @notified_projects_ids = ids.map(&:to_i).uniq.select {|n| n > 0}
435 @notified_projects_ids = ids.map(&:to_i).uniq.select {|n| n > 0}
435 end
436 end
436
437
437 # Updates per project notifications (after_save callback)
438 # Updates per project notifications (after_save callback)
438 def update_notified_project_ids
439 def update_notified_project_ids
439 if @notified_projects_ids_changed
440 if @notified_projects_ids_changed
440 ids = (mail_notification == 'selected' ? Array.wrap(notified_projects_ids).reject(&:blank?) : [])
441 ids = (mail_notification == 'selected' ? Array.wrap(notified_projects_ids).reject(&:blank?) : [])
441 members.update_all(:mail_notification => false)
442 members.update_all(:mail_notification => false)
442 members.where(:project_id => ids).update_all(:mail_notification => true) if ids.any?
443 members.where(:project_id => ids).update_all(:mail_notification => true) if ids.any?
443 end
444 end
444 end
445 end
445 private :update_notified_project_ids
446 private :update_notified_project_ids
446
447
447 def valid_notification_options
448 def valid_notification_options
448 self.class.valid_notification_options(self)
449 self.class.valid_notification_options(self)
449 end
450 end
450
451
451 # Only users that belong to more than 1 project can select projects for which they are notified
452 # Only users that belong to more than 1 project can select projects for which they are notified
452 def self.valid_notification_options(user=nil)
453 def self.valid_notification_options(user=nil)
453 # Note that @user.membership.size would fail since AR ignores
454 # Note that @user.membership.size would fail since AR ignores
454 # :include association option when doing a count
455 # :include association option when doing a count
455 if user.nil? || user.memberships.length < 1
456 if user.nil? || user.memberships.length < 1
456 MAIL_NOTIFICATION_OPTIONS.reject {|option| option.first == 'selected'}
457 MAIL_NOTIFICATION_OPTIONS.reject {|option| option.first == 'selected'}
457 else
458 else
458 MAIL_NOTIFICATION_OPTIONS
459 MAIL_NOTIFICATION_OPTIONS
459 end
460 end
460 end
461 end
461
462
462 # Find a user account by matching the exact login and then a case-insensitive
463 # Find a user account by matching the exact login and then a case-insensitive
463 # version. Exact matches will be given priority.
464 # version. Exact matches will be given priority.
464 def self.find_by_login(login)
465 def self.find_by_login(login)
465 login = Redmine::CodesetUtil.replace_invalid_utf8(login.to_s)
466 login = Redmine::CodesetUtil.replace_invalid_utf8(login.to_s)
466 if login.present?
467 if login.present?
467 # First look for an exact match
468 # First look for an exact match
468 user = where(:login => login).detect {|u| u.login == login}
469 user = where(:login => login).detect {|u| u.login == login}
469 unless user
470 unless user
470 # Fail over to case-insensitive if none was found
471 # Fail over to case-insensitive if none was found
471 user = where("LOWER(login) = ?", login.downcase).first
472 user = where("LOWER(login) = ?", login.downcase).first
472 end
473 end
473 user
474 user
474 end
475 end
475 end
476 end
476
477
477 def self.find_by_rss_key(key)
478 def self.find_by_rss_key(key)
478 Token.find_active_user('feeds', key)
479 Token.find_active_user('feeds', key)
479 end
480 end
480
481
481 def self.find_by_api_key(key)
482 def self.find_by_api_key(key)
482 Token.find_active_user('api', key)
483 Token.find_active_user('api', key)
483 end
484 end
484
485
485 # Makes find_by_mail case-insensitive
486 # Makes find_by_mail case-insensitive
486 def self.find_by_mail(mail)
487 def self.find_by_mail(mail)
487 having_mail(mail).first
488 having_mail(mail).first
488 end
489 end
489
490
490 # Returns true if the default admin account can no longer be used
491 # Returns true if the default admin account can no longer be used
491 def self.default_admin_account_changed?
492 def self.default_admin_account_changed?
492 !User.active.find_by_login("admin").try(:check_password?, "admin")
493 !User.active.find_by_login("admin").try(:check_password?, "admin")
493 end
494 end
494
495
495 def to_s
496 def to_s
496 name
497 name
497 end
498 end
498
499
499 CSS_CLASS_BY_STATUS = {
500 CSS_CLASS_BY_STATUS = {
500 STATUS_ANONYMOUS => 'anon',
501 STATUS_ANONYMOUS => 'anon',
501 STATUS_ACTIVE => 'active',
502 STATUS_ACTIVE => 'active',
502 STATUS_REGISTERED => 'registered',
503 STATUS_REGISTERED => 'registered',
503 STATUS_LOCKED => 'locked'
504 STATUS_LOCKED => 'locked'
504 }
505 }
505
506
506 def css_classes
507 def css_classes
507 "user #{CSS_CLASS_BY_STATUS[status]}"
508 "user #{CSS_CLASS_BY_STATUS[status]}"
508 end
509 end
509
510
510 # Returns the current day according to user's time zone
511 # Returns the current day according to user's time zone
511 def today
512 def today
512 if time_zone.nil?
513 if time_zone.nil?
513 Date.today
514 Date.today
514 else
515 else
515 time_zone.today
516 time_zone.today
516 end
517 end
517 end
518 end
518
519
519 # Returns the day of +time+ according to user's time zone
520 # Returns the day of +time+ according to user's time zone
520 def time_to_date(time)
521 def time_to_date(time)
521 if time_zone.nil?
522 if time_zone.nil?
522 time.to_date
523 time.to_date
523 else
524 else
524 time.in_time_zone(time_zone).to_date
525 time.in_time_zone(time_zone).to_date
525 end
526 end
526 end
527 end
527
528
528 def logged?
529 def logged?
529 true
530 true
530 end
531 end
531
532
532 def anonymous?
533 def anonymous?
533 !logged?
534 !logged?
534 end
535 end
535
536
536 # Returns user's membership for the given project
537 # Returns user's membership for the given project
537 # or nil if the user is not a member of project
538 # or nil if the user is not a member of project
538 def membership(project)
539 def membership(project)
539 project_id = project.is_a?(Project) ? project.id : project
540 project_id = project.is_a?(Project) ? project.id : project
540
541
541 @membership_by_project_id ||= Hash.new {|h, project_id|
542 @membership_by_project_id ||= Hash.new {|h, project_id|
542 h[project_id] = memberships.where(:project_id => project_id).first
543 h[project_id] = memberships.where(:project_id => project_id).first
543 }
544 }
544 @membership_by_project_id[project_id]
545 @membership_by_project_id[project_id]
545 end
546 end
546
547
547 # Returns the user's bult-in role
548 # Returns the user's bult-in role
548 def builtin_role
549 def builtin_role
549 @builtin_role ||= Role.non_member
550 @builtin_role ||= Role.non_member
550 end
551 end
551
552
552 # Return user's roles for project
553 # Return user's roles for project
553 def roles_for_project(project)
554 def roles_for_project(project)
554 # No role on archived projects
555 # No role on archived projects
555 return [] if project.nil? || project.archived?
556 return [] if project.nil? || project.archived?
556 if membership = membership(project)
557 if membership = membership(project)
557 membership.roles.to_a
558 membership.roles.to_a
558 elsif project.is_public?
559 elsif project.is_public?
559 project.override_roles(builtin_role)
560 project.override_roles(builtin_role)
560 else
561 else
561 []
562 []
562 end
563 end
563 end
564 end
564
565
565 # Returns a hash of user's projects grouped by roles
566 # Returns a hash of user's projects grouped by roles
566 def projects_by_role
567 def projects_by_role
567 return @projects_by_role if @projects_by_role
568 return @projects_by_role if @projects_by_role
568
569
569 hash = Hash.new([])
570 hash = Hash.new([])
570
571
571 group_class = anonymous? ? GroupAnonymous : GroupNonMember
572 group_class = anonymous? ? GroupAnonymous : GroupNonMember
572 members = Member.joins(:project, :principal).
573 members = Member.joins(:project, :principal).
573 where("#{Project.table_name}.status <> 9").
574 where("#{Project.table_name}.status <> 9").
574 where("#{Member.table_name}.user_id = ? OR (#{Project.table_name}.is_public = ? AND #{Principal.table_name}.type = ?)", self.id, true, group_class.name).
575 where("#{Member.table_name}.user_id = ? OR (#{Project.table_name}.is_public = ? AND #{Principal.table_name}.type = ?)", self.id, true, group_class.name).
575 preload(:project, :roles).
576 preload(:project, :roles).
576 to_a
577 to_a
577
578
578 members.reject! {|member| member.user_id != id && project_ids.include?(member.project_id)}
579 members.reject! {|member| member.user_id != id && project_ids.include?(member.project_id)}
579 members.each do |member|
580 members.each do |member|
580 if member.project
581 if member.project
581 member.roles.each do |role|
582 member.roles.each do |role|
582 hash[role] = [] unless hash.key?(role)
583 hash[role] = [] unless hash.key?(role)
583 hash[role] << member.project
584 hash[role] << member.project
584 end
585 end
585 end
586 end
586 end
587 end
587
588
588 hash.each do |role, projects|
589 hash.each do |role, projects|
589 projects.uniq!
590 projects.uniq!
590 end
591 end
591
592
592 @projects_by_role = hash
593 @projects_by_role = hash
593 end
594 end
594
595
595 # Returns the ids of visible projects
596 # Returns the ids of visible projects
596 def visible_project_ids
597 def visible_project_ids
597 @visible_project_ids ||= Project.visible(self).pluck(:id)
598 @visible_project_ids ||= Project.visible(self).pluck(:id)
598 end
599 end
599
600
600 # Returns the roles that the user is allowed to manage for the given project
601 # Returns the roles that the user is allowed to manage for the given project
601 def managed_roles(project)
602 def managed_roles(project)
602 if admin?
603 if admin?
603 @managed_roles ||= Role.givable.to_a
604 @managed_roles ||= Role.givable.to_a
604 else
605 else
605 membership(project).try(:managed_roles) || []
606 membership(project).try(:managed_roles) || []
606 end
607 end
607 end
608 end
608
609
609 # Returns true if user is arg or belongs to arg
610 # Returns true if user is arg or belongs to arg
610 def is_or_belongs_to?(arg)
611 def is_or_belongs_to?(arg)
611 if arg.is_a?(User)
612 if arg.is_a?(User)
612 self == arg
613 self == arg
613 elsif arg.is_a?(Group)
614 elsif arg.is_a?(Group)
614 arg.users.include?(self)
615 arg.users.include?(self)
615 else
616 else
616 false
617 false
617 end
618 end
618 end
619 end
619
620
620 # Return true if the user is allowed to do the specified action on a specific context
621 # Return true if the user is allowed to do the specified action on a specific context
621 # Action can be:
622 # Action can be:
622 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
623 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
623 # * a permission Symbol (eg. :edit_project)
624 # * a permission Symbol (eg. :edit_project)
624 # Context can be:
625 # Context can be:
625 # * a project : returns true if user is allowed to do the specified action on this project
626 # * a project : returns true if user is allowed to do the specified action on this project
626 # * an array of projects : returns true if user is allowed on every project
627 # * an array of projects : returns true if user is allowed on every project
627 # * nil with options[:global] set : check if user has at least one role allowed for this action,
628 # * nil with options[:global] set : check if user has at least one role allowed for this action,
628 # or falls back to Non Member / Anonymous permissions depending if the user is logged
629 # or falls back to Non Member / Anonymous permissions depending if the user is logged
629 def allowed_to?(action, context, options={}, &block)
630 def allowed_to?(action, context, options={}, &block)
630 if context && context.is_a?(Project)
631 if context && context.is_a?(Project)
631 return false unless context.allows_to?(action)
632 return false unless context.allows_to?(action)
632 # Admin users are authorized for anything else
633 # Admin users are authorized for anything else
633 return true if admin?
634 return true if admin?
634
635
635 roles = roles_for_project(context)
636 roles = roles_for_project(context)
636 return false unless roles
637 return false unless roles
637 roles.any? {|role|
638 roles.any? {|role|
638 (context.is_public? || role.member?) &&
639 (context.is_public? || role.member?) &&
639 role.allowed_to?(action) &&
640 role.allowed_to?(action) &&
640 (block_given? ? yield(role, self) : true)
641 (block_given? ? yield(role, self) : true)
641 }
642 }
642 elsif context && context.is_a?(Array)
643 elsif context && context.is_a?(Array)
643 if context.empty?
644 if context.empty?
644 false
645 false
645 else
646 else
646 # Authorize if user is authorized on every element of the array
647 # Authorize if user is authorized on every element of the array
647 context.map {|project| allowed_to?(action, project, options, &block)}.reduce(:&)
648 context.map {|project| allowed_to?(action, project, options, &block)}.reduce(:&)
648 end
649 end
649 elsif context
650 elsif context
650 raise ArgumentError.new("#allowed_to? context argument must be a Project, an Array of projects or nil")
651 raise ArgumentError.new("#allowed_to? context argument must be a Project, an Array of projects or nil")
651 elsif options[:global]
652 elsif options[:global]
652 # Admin users are always authorized
653 # Admin users are always authorized
653 return true if admin?
654 return true if admin?
654
655
655 # authorize if user has at least one role that has this permission
656 # authorize if user has at least one role that has this permission
656 roles = memberships.collect {|m| m.roles}.flatten.uniq
657 roles = memberships.collect {|m| m.roles}.flatten.uniq
657 roles << (self.logged? ? Role.non_member : Role.anonymous)
658 roles << (self.logged? ? Role.non_member : Role.anonymous)
658 roles.any? {|role|
659 roles.any? {|role|
659 role.allowed_to?(action) &&
660 role.allowed_to?(action) &&
660 (block_given? ? yield(role, self) : true)
661 (block_given? ? yield(role, self) : true)
661 }
662 }
662 else
663 else
663 false
664 false
664 end
665 end
665 end
666 end
666
667
667 # Is the user allowed to do the specified action on any project?
668 # Is the user allowed to do the specified action on any project?
668 # See allowed_to? for the actions and valid options.
669 # See allowed_to? for the actions and valid options.
669 #
670 #
670 # NB: this method is not used anywhere in the core codebase as of
671 # NB: this method is not used anywhere in the core codebase as of
671 # 2.5.2, but it's used by many plugins so if we ever want to remove
672 # 2.5.2, but it's used by many plugins so if we ever want to remove
672 # it it has to be carefully deprecated for a version or two.
673 # it it has to be carefully deprecated for a version or two.
673 def allowed_to_globally?(action, options={}, &block)
674 def allowed_to_globally?(action, options={}, &block)
674 allowed_to?(action, nil, options.reverse_merge(:global => true), &block)
675 allowed_to?(action, nil, options.reverse_merge(:global => true), &block)
675 end
676 end
676
677
677 def allowed_to_view_all_time_entries?(context)
678 def allowed_to_view_all_time_entries?(context)
678 allowed_to?(:view_time_entries, context) do |role, user|
679 allowed_to?(:view_time_entries, context) do |role, user|
679 role.time_entries_visibility == 'all'
680 role.time_entries_visibility == 'all'
680 end
681 end
681 end
682 end
682
683
683 # Returns true if the user is allowed to delete the user's own account
684 # Returns true if the user is allowed to delete the user's own account
684 def own_account_deletable?
685 def own_account_deletable?
685 Setting.unsubscribe? &&
686 Setting.unsubscribe? &&
686 (!admin? || User.active.where("admin = ? AND id <> ?", true, id).exists?)
687 (!admin? || User.active.where("admin = ? AND id <> ?", true, id).exists?)
687 end
688 end
688
689
689 safe_attributes 'firstname',
690 safe_attributes 'firstname',
690 'lastname',
691 'lastname',
691 'mail',
692 'mail',
692 'mail_notification',
693 'mail_notification',
693 'notified_project_ids',
694 'notified_project_ids',
694 'language',
695 'language',
695 'custom_field_values',
696 'custom_field_values',
696 'custom_fields',
697 'custom_fields',
697 'identity_url'
698 'identity_url'
698
699
699 safe_attributes 'login',
700 safe_attributes 'login',
700 :if => lambda {|user, current_user| user.new_record?}
701 :if => lambda {|user, current_user| user.new_record?}
701
702
702 safe_attributes 'status',
703 safe_attributes 'status',
703 'auth_source_id',
704 'auth_source_id',
704 'generate_password',
705 'generate_password',
705 'must_change_passwd',
706 'must_change_passwd',
706 'login',
707 'login',
707 'admin',
708 'admin',
708 :if => lambda {|user, current_user| current_user.admin?}
709 :if => lambda {|user, current_user| current_user.admin?}
709
710
710 safe_attributes 'group_ids',
711 safe_attributes 'group_ids',
711 :if => lambda {|user, current_user| current_user.admin? && !user.new_record?}
712 :if => lambda {|user, current_user| current_user.admin? && !user.new_record?}
712
713
713 # Utility method to help check if a user should be notified about an
714 # Utility method to help check if a user should be notified about an
714 # event.
715 # event.
715 #
716 #
716 # TODO: only supports Issue events currently
717 # TODO: only supports Issue events currently
717 def notify_about?(object)
718 def notify_about?(object)
718 if mail_notification == 'all'
719 if mail_notification == 'all'
719 true
720 true
720 elsif mail_notification.blank? || mail_notification == 'none'
721 elsif mail_notification.blank? || mail_notification == 'none'
721 false
722 false
722 else
723 else
723 case object
724 case object
724 when Issue
725 when Issue
725 case mail_notification
726 case mail_notification
726 when 'selected', 'only_my_events'
727 when 'selected', 'only_my_events'
727 # user receives notifications for created/assigned issues on unselected projects
728 # user receives notifications for created/assigned issues on unselected projects
728 object.author == self || is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.assigned_to_was)
729 object.author == self || is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.assigned_to_was)
729 when 'only_assigned'
730 when 'only_assigned'
730 is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.assigned_to_was)
731 is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.assigned_to_was)
731 when 'only_owner'
732 when 'only_owner'
732 object.author == self
733 object.author == self
733 end
734 end
734 when News
735 when News
735 # always send to project members except when mail_notification is set to 'none'
736 # always send to project members except when mail_notification is set to 'none'
736 true
737 true
737 end
738 end
738 end
739 end
739 end
740 end
740
741
741 def self.current=(user)
742 def self.current=(user)
742 RequestStore.store[:current_user] = user
743 RequestStore.store[:current_user] = user
743 end
744 end
744
745
745 def self.current
746 def self.current
746 RequestStore.store[:current_user] ||= User.anonymous
747 RequestStore.store[:current_user] ||= User.anonymous
747 end
748 end
748
749
749 # Returns the anonymous user. If the anonymous user does not exist, it is created. There can be only
750 # Returns the anonymous user. If the anonymous user does not exist, it is created. There can be only
750 # one anonymous user per database.
751 # one anonymous user per database.
751 def self.anonymous
752 def self.anonymous
752 anonymous_user = AnonymousUser.first
753 anonymous_user = AnonymousUser.first
753 if anonymous_user.nil?
754 if anonymous_user.nil?
754 anonymous_user = AnonymousUser.create(:lastname => 'Anonymous', :firstname => '', :login => '', :status => 0)
755 anonymous_user = AnonymousUser.create(:lastname => 'Anonymous', :firstname => '', :login => '', :status => 0)
755 raise 'Unable to create the anonymous user.' if anonymous_user.new_record?
756 raise 'Unable to create the anonymous user.' if anonymous_user.new_record?
756 end
757 end
757 anonymous_user
758 anonymous_user
758 end
759 end
759
760
760 # Salts all existing unsalted passwords
761 # Salts all existing unsalted passwords
761 # It changes password storage scheme from SHA1(password) to SHA1(salt + SHA1(password))
762 # It changes password storage scheme from SHA1(password) to SHA1(salt + SHA1(password))
762 # This method is used in the SaltPasswords migration and is to be kept as is
763 # This method is used in the SaltPasswords migration and is to be kept as is
763 def self.salt_unsalted_passwords!
764 def self.salt_unsalted_passwords!
764 transaction do
765 transaction do
765 User.where("salt IS NULL OR salt = ''").find_each do |user|
766 User.where("salt IS NULL OR salt = ''").find_each do |user|
766 next if user.hashed_password.blank?
767 next if user.hashed_password.blank?
767 salt = User.generate_salt
768 salt = User.generate_salt
768 hashed_password = User.hash_password("#{salt}#{user.hashed_password}")
769 hashed_password = User.hash_password("#{salt}#{user.hashed_password}")
769 User.where(:id => user.id).update_all(:salt => salt, :hashed_password => hashed_password)
770 User.where(:id => user.id).update_all(:salt => salt, :hashed_password => hashed_password)
770 end
771 end
771 end
772 end
772 end
773 end
773
774
774 protected
775 protected
775
776
776 def validate_password_length
777 def validate_password_length
777 return if password.blank? && generate_password?
778 return if password.blank? && generate_password?
778 # Password length validation based on setting
779 # Password length validation based on setting
779 if !password.nil? && password.size < Setting.password_min_length.to_i
780 if !password.nil? && password.size < Setting.password_min_length.to_i
780 errors.add(:password, :too_short, :count => Setting.password_min_length.to_i)
781 errors.add(:password, :too_short, :count => Setting.password_min_length.to_i)
781 end
782 end
782 end
783 end
783
784
784 def instantiate_email_address
785 def instantiate_email_address
785 email_address || build_email_address
786 email_address || build_email_address
786 end
787 end
787
788
788 private
789 private
789
790
790 def generate_password_if_needed
791 def generate_password_if_needed
791 if generate_password? && auth_source.nil?
792 if generate_password? && auth_source.nil?
792 length = [Setting.password_min_length.to_i + 2, 10].max
793 length = [Setting.password_min_length.to_i + 2, 10].max
793 random_password(length)
794 random_password(length)
794 end
795 end
795 end
796 end
796
797
797 # Delete all outstanding password reset tokens on password change.
798 # Delete all outstanding password reset tokens on password change.
798 # Delete the autologin tokens on password change to prohibit session leakage.
799 # Delete the autologin tokens on password change to prohibit session leakage.
799 # This helps to keep the account secure in case the associated email account
800 # This helps to keep the account secure in case the associated email account
800 # was compromised.
801 # was compromised.
801 def destroy_tokens
802 def destroy_tokens
802 if hashed_password_changed? || (status_changed? && !active?)
803 if hashed_password_changed? || (status_changed? && !active?)
803 tokens = ['recovery', 'autologin', 'session']
804 tokens = ['recovery', 'autologin', 'session']
804 Token.where(:user_id => id, :action => tokens).delete_all
805 Token.where(:user_id => id, :action => tokens).delete_all
805 end
806 end
806 end
807 end
807
808
808 # Removes references that are not handled by associations
809 # Removes references that are not handled by associations
809 # Things that are not deleted are reassociated with the anonymous user
810 # Things that are not deleted are reassociated with the anonymous user
810 def remove_references_before_destroy
811 def remove_references_before_destroy
811 return if self.id.nil?
812 return if self.id.nil?
812
813
813 substitute = User.anonymous
814 substitute = User.anonymous
814 Attachment.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
815 Attachment.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
815 Comment.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
816 Comment.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
816 Issue.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
817 Issue.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
817 Issue.where(['assigned_to_id = ?', id]).update_all('assigned_to_id = NULL')
818 Issue.where(['assigned_to_id = ?', id]).update_all('assigned_to_id = NULL')
818 Journal.where(['user_id = ?', id]).update_all(['user_id = ?', substitute.id])
819 Journal.where(['user_id = ?', id]).update_all(['user_id = ?', substitute.id])
819 JournalDetail.
820 JournalDetail.
820 where(["property = 'attr' AND prop_key = 'assigned_to_id' AND old_value = ?", id.to_s]).
821 where(["property = 'attr' AND prop_key = 'assigned_to_id' AND old_value = ?", id.to_s]).
821 update_all(['old_value = ?', substitute.id.to_s])
822 update_all(['old_value = ?', substitute.id.to_s])
822 JournalDetail.
823 JournalDetail.
823 where(["property = 'attr' AND prop_key = 'assigned_to_id' AND value = ?", id.to_s]).
824 where(["property = 'attr' AND prop_key = 'assigned_to_id' AND value = ?", id.to_s]).
824 update_all(['value = ?', substitute.id.to_s])
825 update_all(['value = ?', substitute.id.to_s])
825 Message.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
826 Message.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
826 News.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
827 News.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
827 # Remove private queries and keep public ones
828 # Remove private queries and keep public ones
828 ::Query.where('user_id = ? AND visibility = ?', id, ::Query::VISIBILITY_PRIVATE).delete_all
829 ::Query.where('user_id = ? AND visibility = ?', id, ::Query::VISIBILITY_PRIVATE).delete_all
829 ::Query.where(['user_id = ?', id]).update_all(['user_id = ?', substitute.id])
830 ::Query.where(['user_id = ?', id]).update_all(['user_id = ?', substitute.id])
830 TimeEntry.where(['user_id = ?', id]).update_all(['user_id = ?', substitute.id])
831 TimeEntry.where(['user_id = ?', id]).update_all(['user_id = ?', substitute.id])
831 Token.where('user_id = ?', id).delete_all
832 Token.where('user_id = ?', id).delete_all
832 Watcher.where('user_id = ?', id).delete_all
833 Watcher.where('user_id = ?', id).delete_all
833 WikiContent.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
834 WikiContent.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
834 WikiContent::Version.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
835 WikiContent::Version.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
835 end
836 end
836
837
837 # Return password digest
838 # Return password digest
838 def self.hash_password(clear_password)
839 def self.hash_password(clear_password)
839 Digest::SHA1.hexdigest(clear_password || "")
840 Digest::SHA1.hexdigest(clear_password || "")
840 end
841 end
841
842
842 # Returns a 128bits random salt as a hex string (32 chars long)
843 # Returns a 128bits random salt as a hex string (32 chars long)
843 def self.generate_salt
844 def self.generate_salt
844 Redmine::Utils.random_hex(16)
845 Redmine::Utils.random_hex(16)
845 end
846 end
846
847
847 # Send a security notification to all admins if the user has gained/lost admin privileges
848 # Send a security notification to all admins if the user has gained/lost admin privileges
848 def deliver_security_notification
849 def deliver_security_notification
849 options = {
850 options = {
850 field: :field_admin,
851 field: :field_admin,
851 value: login,
852 value: login,
852 title: :label_user_plural,
853 title: :label_user_plural,
853 url: {controller: 'users', action: 'index'}
854 url: {controller: 'users', action: 'index'}
854 }
855 }
855
856
856 deliver = false
857 deliver = false
857 if (admin? && id_changed? && active?) || # newly created admin
858 if (admin? && id_changed? && active?) || # newly created admin
858 (admin? && admin_changed? && active?) || # regular user became admin
859 (admin? && admin_changed? && active?) || # regular user became admin
859 (admin? && status_changed? && active?) # locked admin became active again
860 (admin? && status_changed? && active?) # locked admin became active again
860
861
861 deliver = true
862 deliver = true
862 options[:message] = :mail_body_security_notification_add
863 options[:message] = :mail_body_security_notification_add
863
864
864 elsif (admin? && destroyed? && active?) || # active admin user was deleted
865 elsif (admin? && destroyed? && active?) || # active admin user was deleted
865 (!admin? && admin_changed? && active?) || # admin is no longer admin
866 (!admin? && admin_changed? && active?) || # admin is no longer admin
866 (admin? && status_changed? && !active?) # admin was locked
867 (admin? && status_changed? && !active?) # admin was locked
867
868
868 deliver = true
869 deliver = true
869 options[:message] = :mail_body_security_notification_remove
870 options[:message] = :mail_body_security_notification_remove
870 end
871 end
871
872
872 if deliver
873 if deliver
873 users = User.active.where(admin: true).to_a
874 users = User.active.where(admin: true).to_a
874 Mailer.security_notification(users, options).deliver
875 Mailer.security_notification(users, options).deliver
875 end
876 end
876 end
877 end
877 end
878 end
878
879
879 class AnonymousUser < User
880 class AnonymousUser < User
880 validate :validate_anonymous_uniqueness, :on => :create
881 validate :validate_anonymous_uniqueness, :on => :create
881
882
882 self.valid_statuses = [STATUS_ANONYMOUS]
883 self.valid_statuses = [STATUS_ANONYMOUS]
883
884
884 def validate_anonymous_uniqueness
885 def validate_anonymous_uniqueness
885 # There should be only one AnonymousUser in the database
886 # There should be only one AnonymousUser in the database
886 errors.add :base, 'An anonymous user already exists.' if AnonymousUser.exists?
887 errors.add :base, 'An anonymous user already exists.' if AnonymousUser.exists?
887 end
888 end
888
889
889 def available_custom_fields
890 def available_custom_fields
890 []
891 []
891 end
892 end
892
893
893 # Overrides a few properties
894 # Overrides a few properties
894 def logged?; false end
895 def logged?; false end
895 def admin; false end
896 def admin; false end
896 def name(*args); I18n.t(:label_user_anonymous) end
897 def name(*args); I18n.t(:label_user_anonymous) end
897 def mail=(*args); nil end
898 def mail=(*args); nil end
898 def mail; nil end
899 def mail; nil end
899 def time_zone; nil end
900 def time_zone; nil end
900 def rss_key; nil end
901 def rss_key; nil end
901
902
902 def pref
903 def pref
903 UserPreference.new(:user => self)
904 UserPreference.new(:user => self)
904 end
905 end
905
906
906 # Returns the user's bult-in role
907 # Returns the user's bult-in role
907 def builtin_role
908 def builtin_role
908 @builtin_role ||= Role.anonymous
909 @builtin_role ||= Role.anonymous
909 end
910 end
910
911
911 def membership(*args)
912 def membership(*args)
912 nil
913 nil
913 end
914 end
914
915
915 def member_of?(*args)
916 def member_of?(*args)
916 false
917 false
917 end
918 end
918
919
919 # Anonymous user can not be destroyed
920 # Anonymous user can not be destroyed
920 def destroy
921 def destroy
921 false
922 false
922 end
923 end
923
924
924 protected
925 protected
925
926
926 def instantiate_email_address
927 def instantiate_email_address
927 end
928 end
928 end
929 end
@@ -1,362 +1,362
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2016 Jean-Philippe Lang
2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class Version < ActiveRecord::Base
18 class Version < ActiveRecord::Base
19 include Redmine::SafeAttributes
19 include Redmine::SafeAttributes
20
20
21 after_update :update_issues_from_sharing_change
21 after_update :update_issues_from_sharing_change
22 after_save :update_default_project_version
22 after_save :update_default_project_version
23 before_destroy :nullify_projects_default_version
23 before_destroy :nullify_projects_default_version
24
24
25 belongs_to :project
25 belongs_to :project
26 has_many :fixed_issues, :class_name => 'Issue', :foreign_key => 'fixed_version_id', :dependent => :nullify
26 has_many :fixed_issues, :class_name => 'Issue', :foreign_key => 'fixed_version_id', :dependent => :nullify
27 acts_as_customizable
27 acts_as_customizable
28 acts_as_attachable :view_permission => :view_files,
28 acts_as_attachable :view_permission => :view_files,
29 :edit_permission => :manage_files,
29 :edit_permission => :manage_files,
30 :delete_permission => :manage_files
30 :delete_permission => :manage_files
31
31
32 VERSION_STATUSES = %w(open locked closed)
32 VERSION_STATUSES = %w(open locked closed)
33 VERSION_SHARINGS = %w(none descendants hierarchy tree system)
33 VERSION_SHARINGS = %w(none descendants hierarchy tree system)
34
34
35 validates_presence_of :name
35 validates_presence_of :name
36 validates_uniqueness_of :name, :scope => [:project_id]
36 validates_uniqueness_of :name, :scope => [:project_id]
37 validates_length_of :name, :maximum => 60
37 validates_length_of :name, :maximum => 60
38 validates_length_of :description, :maximum => 255
38 validates_length_of :description, :wiki_page_title, :maximum => 255
39 validates :effective_date, :date => true
39 validates :effective_date, :date => true
40 validates_inclusion_of :status, :in => VERSION_STATUSES
40 validates_inclusion_of :status, :in => VERSION_STATUSES
41 validates_inclusion_of :sharing, :in => VERSION_SHARINGS
41 validates_inclusion_of :sharing, :in => VERSION_SHARINGS
42 attr_protected :id
42 attr_protected :id
43
43
44 scope :named, lambda {|arg| where("LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip)}
44 scope :named, lambda {|arg| where("LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip)}
45 scope :like, lambda {|arg|
45 scope :like, lambda {|arg|
46 if arg.present?
46 if arg.present?
47 pattern = "%#{arg.to_s.strip}%"
47 pattern = "%#{arg.to_s.strip}%"
48 where("LOWER(#{Version.table_name}.name) LIKE :p", :p => pattern)
48 where("LOWER(#{Version.table_name}.name) LIKE :p", :p => pattern)
49 end
49 end
50 }
50 }
51 scope :open, lambda { where(:status => 'open') }
51 scope :open, lambda { where(:status => 'open') }
52 scope :status, lambda {|status|
52 scope :status, lambda {|status|
53 if status.present?
53 if status.present?
54 where(:status => status.to_s)
54 where(:status => status.to_s)
55 end
55 end
56 }
56 }
57 scope :visible, lambda {|*args|
57 scope :visible, lambda {|*args|
58 joins(:project).
58 joins(:project).
59 where(Project.allowed_to_condition(args.first || User.current, :view_issues))
59 where(Project.allowed_to_condition(args.first || User.current, :view_issues))
60 }
60 }
61
61
62 safe_attributes 'name',
62 safe_attributes 'name',
63 'description',
63 'description',
64 'effective_date',
64 'effective_date',
65 'due_date',
65 'due_date',
66 'wiki_page_title',
66 'wiki_page_title',
67 'status',
67 'status',
68 'sharing',
68 'sharing',
69 'default_project_version',
69 'default_project_version',
70 'custom_field_values',
70 'custom_field_values',
71 'custom_fields'
71 'custom_fields'
72
72
73 # Returns true if +user+ or current user is allowed to view the version
73 # Returns true if +user+ or current user is allowed to view the version
74 def visible?(user=User.current)
74 def visible?(user=User.current)
75 user.allowed_to?(:view_issues, self.project)
75 user.allowed_to?(:view_issues, self.project)
76 end
76 end
77
77
78 # Version files have same visibility as project files
78 # Version files have same visibility as project files
79 def attachments_visible?(*args)
79 def attachments_visible?(*args)
80 project.present? && project.attachments_visible?(*args)
80 project.present? && project.attachments_visible?(*args)
81 end
81 end
82
82
83 def attachments_deletable?(usr=User.current)
83 def attachments_deletable?(usr=User.current)
84 project.present? && project.attachments_deletable?(usr)
84 project.present? && project.attachments_deletable?(usr)
85 end
85 end
86
86
87 alias :base_reload :reload
87 alias :base_reload :reload
88 def reload(*args)
88 def reload(*args)
89 @default_project_version = nil
89 @default_project_version = nil
90 base_reload(*args)
90 base_reload(*args)
91 end
91 end
92
92
93 def start_date
93 def start_date
94 @start_date ||= fixed_issues.minimum('start_date')
94 @start_date ||= fixed_issues.minimum('start_date')
95 end
95 end
96
96
97 def due_date
97 def due_date
98 effective_date
98 effective_date
99 end
99 end
100
100
101 def due_date=(arg)
101 def due_date=(arg)
102 self.effective_date=(arg)
102 self.effective_date=(arg)
103 end
103 end
104
104
105 # Returns the total estimated time for this version
105 # Returns the total estimated time for this version
106 # (sum of leaves estimated_hours)
106 # (sum of leaves estimated_hours)
107 def estimated_hours
107 def estimated_hours
108 @estimated_hours ||= fixed_issues.sum(:estimated_hours).to_f
108 @estimated_hours ||= fixed_issues.sum(:estimated_hours).to_f
109 end
109 end
110
110
111 # Returns the total reported time for this version
111 # Returns the total reported time for this version
112 def spent_hours
112 def spent_hours
113 @spent_hours ||= TimeEntry.joins(:issue).where("#{Issue.table_name}.fixed_version_id = ?", id).sum(:hours).to_f
113 @spent_hours ||= TimeEntry.joins(:issue).where("#{Issue.table_name}.fixed_version_id = ?", id).sum(:hours).to_f
114 end
114 end
115
115
116 def closed?
116 def closed?
117 status == 'closed'
117 status == 'closed'
118 end
118 end
119
119
120 def open?
120 def open?
121 status == 'open'
121 status == 'open'
122 end
122 end
123
123
124 # Returns true if the version is completed: closed or due date reached and no open issues
124 # Returns true if the version is completed: closed or due date reached and no open issues
125 def completed?
125 def completed?
126 closed? || (effective_date && (effective_date < User.current.today) && (open_issues_count == 0))
126 closed? || (effective_date && (effective_date < User.current.today) && (open_issues_count == 0))
127 end
127 end
128
128
129 def behind_schedule?
129 def behind_schedule?
130 if completed_percent == 100
130 if completed_percent == 100
131 return false
131 return false
132 elsif due_date && start_date
132 elsif due_date && start_date
133 done_date = start_date + ((due_date - start_date+1)* completed_percent/100).floor
133 done_date = start_date + ((due_date - start_date+1)* completed_percent/100).floor
134 return done_date <= User.current.today
134 return done_date <= User.current.today
135 else
135 else
136 false # No issues so it's not late
136 false # No issues so it's not late
137 end
137 end
138 end
138 end
139
139
140 # Returns the completion percentage of this version based on the amount of open/closed issues
140 # Returns the completion percentage of this version based on the amount of open/closed issues
141 # and the time spent on the open issues.
141 # and the time spent on the open issues.
142 def completed_percent
142 def completed_percent
143 if issues_count == 0
143 if issues_count == 0
144 0
144 0
145 elsif open_issues_count == 0
145 elsif open_issues_count == 0
146 100
146 100
147 else
147 else
148 issues_progress(false) + issues_progress(true)
148 issues_progress(false) + issues_progress(true)
149 end
149 end
150 end
150 end
151
151
152 # Returns the percentage of issues that have been marked as 'closed'.
152 # Returns the percentage of issues that have been marked as 'closed'.
153 def closed_percent
153 def closed_percent
154 if issues_count == 0
154 if issues_count == 0
155 0
155 0
156 else
156 else
157 issues_progress(false)
157 issues_progress(false)
158 end
158 end
159 end
159 end
160
160
161 # Returns true if the version is overdue: due date reached and some open issues
161 # Returns true if the version is overdue: due date reached and some open issues
162 def overdue?
162 def overdue?
163 effective_date && (effective_date < User.current.today) && (open_issues_count > 0)
163 effective_date && (effective_date < User.current.today) && (open_issues_count > 0)
164 end
164 end
165
165
166 # Returns assigned issues count
166 # Returns assigned issues count
167 def issues_count
167 def issues_count
168 load_issue_counts
168 load_issue_counts
169 @issue_count
169 @issue_count
170 end
170 end
171
171
172 # Returns the total amount of open issues for this version.
172 # Returns the total amount of open issues for this version.
173 def open_issues_count
173 def open_issues_count
174 load_issue_counts
174 load_issue_counts
175 @open_issues_count
175 @open_issues_count
176 end
176 end
177
177
178 # Returns the total amount of closed issues for this version.
178 # Returns the total amount of closed issues for this version.
179 def closed_issues_count
179 def closed_issues_count
180 load_issue_counts
180 load_issue_counts
181 @closed_issues_count
181 @closed_issues_count
182 end
182 end
183
183
184 def wiki_page
184 def wiki_page
185 if project.wiki && !wiki_page_title.blank?
185 if project.wiki && !wiki_page_title.blank?
186 @wiki_page ||= project.wiki.find_page(wiki_page_title)
186 @wiki_page ||= project.wiki.find_page(wiki_page_title)
187 end
187 end
188 @wiki_page
188 @wiki_page
189 end
189 end
190
190
191 def to_s; name end
191 def to_s; name end
192
192
193 def to_s_with_project
193 def to_s_with_project
194 "#{project} - #{name}"
194 "#{project} - #{name}"
195 end
195 end
196
196
197 # Versions are sorted by effective_date and name
197 # Versions are sorted by effective_date and name
198 # Those with no effective_date are at the end, sorted by name
198 # Those with no effective_date are at the end, sorted by name
199 def <=>(version)
199 def <=>(version)
200 if self.effective_date
200 if self.effective_date
201 if version.effective_date
201 if version.effective_date
202 if self.effective_date == version.effective_date
202 if self.effective_date == version.effective_date
203 name == version.name ? id <=> version.id : name <=> version.name
203 name == version.name ? id <=> version.id : name <=> version.name
204 else
204 else
205 self.effective_date <=> version.effective_date
205 self.effective_date <=> version.effective_date
206 end
206 end
207 else
207 else
208 -1
208 -1
209 end
209 end
210 else
210 else
211 if version.effective_date
211 if version.effective_date
212 1
212 1
213 else
213 else
214 name == version.name ? id <=> version.id : name <=> version.name
214 name == version.name ? id <=> version.id : name <=> version.name
215 end
215 end
216 end
216 end
217 end
217 end
218
218
219 # Sort versions by status (open, locked then closed versions)
219 # Sort versions by status (open, locked then closed versions)
220 def self.sort_by_status(versions)
220 def self.sort_by_status(versions)
221 versions.sort do |a, b|
221 versions.sort do |a, b|
222 if a.status == b.status
222 if a.status == b.status
223 a <=> b
223 a <=> b
224 else
224 else
225 b.status <=> a.status
225 b.status <=> a.status
226 end
226 end
227 end
227 end
228 end
228 end
229
229
230 def css_classes
230 def css_classes
231 [
231 [
232 completed? ? 'version-completed' : 'version-incompleted',
232 completed? ? 'version-completed' : 'version-incompleted',
233 "version-#{status}"
233 "version-#{status}"
234 ].join(' ')
234 ].join(' ')
235 end
235 end
236
236
237 def self.fields_for_order_statement(table=nil)
237 def self.fields_for_order_statement(table=nil)
238 table ||= table_name
238 table ||= table_name
239 ["(CASE WHEN #{table}.effective_date IS NULL THEN 1 ELSE 0 END)", "#{table}.effective_date", "#{table}.name", "#{table}.id"]
239 ["(CASE WHEN #{table}.effective_date IS NULL THEN 1 ELSE 0 END)", "#{table}.effective_date", "#{table}.name", "#{table}.id"]
240 end
240 end
241
241
242 scope :sorted, lambda { order(fields_for_order_statement) }
242 scope :sorted, lambda { order(fields_for_order_statement) }
243
243
244 # Returns the sharings that +user+ can set the version to
244 # Returns the sharings that +user+ can set the version to
245 def allowed_sharings(user = User.current)
245 def allowed_sharings(user = User.current)
246 VERSION_SHARINGS.select do |s|
246 VERSION_SHARINGS.select do |s|
247 if sharing == s
247 if sharing == s
248 true
248 true
249 else
249 else
250 case s
250 case s
251 when 'system'
251 when 'system'
252 # Only admin users can set a systemwide sharing
252 # Only admin users can set a systemwide sharing
253 user.admin?
253 user.admin?
254 when 'hierarchy', 'tree'
254 when 'hierarchy', 'tree'
255 # Only users allowed to manage versions of the root project can
255 # Only users allowed to manage versions of the root project can
256 # set sharing to hierarchy or tree
256 # set sharing to hierarchy or tree
257 project.nil? || user.allowed_to?(:manage_versions, project.root)
257 project.nil? || user.allowed_to?(:manage_versions, project.root)
258 else
258 else
259 true
259 true
260 end
260 end
261 end
261 end
262 end
262 end
263 end
263 end
264
264
265 # Returns true if the version is shared, otherwise false
265 # Returns true if the version is shared, otherwise false
266 def shared?
266 def shared?
267 sharing != 'none'
267 sharing != 'none'
268 end
268 end
269
269
270 def deletable?
270 def deletable?
271 fixed_issues.empty? && !referenced_by_a_custom_field?
271 fixed_issues.empty? && !referenced_by_a_custom_field?
272 end
272 end
273
273
274 def default_project_version
274 def default_project_version
275 if @default_project_version.nil?
275 if @default_project_version.nil?
276 project.present? && project.default_version == self
276 project.present? && project.default_version == self
277 else
277 else
278 @default_project_version
278 @default_project_version
279 end
279 end
280 end
280 end
281
281
282 def default_project_version=(arg)
282 def default_project_version=(arg)
283 @default_project_version = (arg == '1' || arg == true)
283 @default_project_version = (arg == '1' || arg == true)
284 end
284 end
285
285
286 private
286 private
287
287
288 def load_issue_counts
288 def load_issue_counts
289 unless @issue_count
289 unless @issue_count
290 @open_issues_count = 0
290 @open_issues_count = 0
291 @closed_issues_count = 0
291 @closed_issues_count = 0
292 fixed_issues.group(:status).count.each do |status, count|
292 fixed_issues.group(:status).count.each do |status, count|
293 if status.is_closed?
293 if status.is_closed?
294 @closed_issues_count += count
294 @closed_issues_count += count
295 else
295 else
296 @open_issues_count += count
296 @open_issues_count += count
297 end
297 end
298 end
298 end
299 @issue_count = @open_issues_count + @closed_issues_count
299 @issue_count = @open_issues_count + @closed_issues_count
300 end
300 end
301 end
301 end
302
302
303 # Update the issue's fixed versions. Used if a version's sharing changes.
303 # Update the issue's fixed versions. Used if a version's sharing changes.
304 def update_issues_from_sharing_change
304 def update_issues_from_sharing_change
305 if sharing_changed?
305 if sharing_changed?
306 if VERSION_SHARINGS.index(sharing_was).nil? ||
306 if VERSION_SHARINGS.index(sharing_was).nil? ||
307 VERSION_SHARINGS.index(sharing).nil? ||
307 VERSION_SHARINGS.index(sharing).nil? ||
308 VERSION_SHARINGS.index(sharing_was) > VERSION_SHARINGS.index(sharing)
308 VERSION_SHARINGS.index(sharing_was) > VERSION_SHARINGS.index(sharing)
309 Issue.update_versions_from_sharing_change self
309 Issue.update_versions_from_sharing_change self
310 end
310 end
311 end
311 end
312 end
312 end
313
313
314 def update_default_project_version
314 def update_default_project_version
315 if @default_project_version && project.present?
315 if @default_project_version && project.present?
316 project.update_columns :default_version_id => id
316 project.update_columns :default_version_id => id
317 end
317 end
318 end
318 end
319
319
320 # Returns the average estimated time of assigned issues
320 # Returns the average estimated time of assigned issues
321 # or 1 if no issue has an estimated time
321 # or 1 if no issue has an estimated time
322 # Used to weight unestimated issues in progress calculation
322 # Used to weight unestimated issues in progress calculation
323 def estimated_average
323 def estimated_average
324 if @estimated_average.nil?
324 if @estimated_average.nil?
325 average = fixed_issues.average(:estimated_hours).to_f
325 average = fixed_issues.average(:estimated_hours).to_f
326 if average == 0
326 if average == 0
327 average = 1
327 average = 1
328 end
328 end
329 @estimated_average = average
329 @estimated_average = average
330 end
330 end
331 @estimated_average
331 @estimated_average
332 end
332 end
333
333
334 # Returns the total progress of open or closed issues. The returned percentage takes into account
334 # Returns the total progress of open or closed issues. The returned percentage takes into account
335 # the amount of estimated time set for this version.
335 # the amount of estimated time set for this version.
336 #
336 #
337 # Examples:
337 # Examples:
338 # issues_progress(true) => returns the progress percentage for open issues.
338 # issues_progress(true) => returns the progress percentage for open issues.
339 # issues_progress(false) => returns the progress percentage for closed issues.
339 # issues_progress(false) => returns the progress percentage for closed issues.
340 def issues_progress(open)
340 def issues_progress(open)
341 @issues_progress ||= {}
341 @issues_progress ||= {}
342 @issues_progress[open] ||= begin
342 @issues_progress[open] ||= begin
343 progress = 0
343 progress = 0
344 if issues_count > 0
344 if issues_count > 0
345 ratio = open ? 'done_ratio' : 100
345 ratio = open ? 'done_ratio' : 100
346
346
347 done = fixed_issues.open(open).sum("COALESCE(estimated_hours, #{estimated_average}) * #{ratio}").to_f
347 done = fixed_issues.open(open).sum("COALESCE(estimated_hours, #{estimated_average}) * #{ratio}").to_f
348 progress = done / (estimated_average * issues_count)
348 progress = done / (estimated_average * issues_count)
349 end
349 end
350 progress
350 progress
351 end
351 end
352 end
352 end
353
353
354 def referenced_by_a_custom_field?
354 def referenced_by_a_custom_field?
355 CustomValue.joins(:custom_field).
355 CustomValue.joins(:custom_field).
356 where(:value => id.to_s, :custom_fields => {:field_format => 'version'}).any?
356 where(:value => id.to_s, :custom_fields => {:field_format => 'version'}).any?
357 end
357 end
358
358
359 def nullify_projects_default_version
359 def nullify_projects_default_version
360 Project.where(:default_version_id => id).update_all(:default_version_id => nil)
360 Project.where(:default_version_id => id).update_all(:default_version_id => nil)
361 end
361 end
362 end
362 end
@@ -1,106 +1,107
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2016 Jean-Philippe Lang
2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class Wiki < ActiveRecord::Base
18 class Wiki < ActiveRecord::Base
19 include Redmine::SafeAttributes
19 include Redmine::SafeAttributes
20 belongs_to :project
20 belongs_to :project
21 has_many :pages, lambda {order('title')}, :class_name => 'WikiPage', :dependent => :destroy
21 has_many :pages, lambda {order('title')}, :class_name => 'WikiPage', :dependent => :destroy
22 has_many :redirects, :class_name => 'WikiRedirect'
22 has_many :redirects, :class_name => 'WikiRedirect'
23
23
24 acts_as_watchable
24 acts_as_watchable
25
25
26 validates_presence_of :start_page
26 validates_presence_of :start_page
27 validates_format_of :start_page, :with => /\A[^,\.\/\?\;\|\:]*\z/
27 validates_format_of :start_page, :with => /\A[^,\.\/\?\;\|\:]*\z/
28 validates_length_of :title, maximum: 255
28 attr_protected :id
29 attr_protected :id
29
30
30 before_destroy :delete_redirects
31 before_destroy :delete_redirects
31
32
32 safe_attributes 'start_page'
33 safe_attributes 'start_page'
33
34
34 def visible?(user=User.current)
35 def visible?(user=User.current)
35 !user.nil? && user.allowed_to?(:view_wiki_pages, project)
36 !user.nil? && user.allowed_to?(:view_wiki_pages, project)
36 end
37 end
37
38
38 # Returns the wiki page that acts as the sidebar content
39 # Returns the wiki page that acts as the sidebar content
39 # or nil if no such page exists
40 # or nil if no such page exists
40 def sidebar
41 def sidebar
41 @sidebar ||= find_page('Sidebar', :with_redirect => false)
42 @sidebar ||= find_page('Sidebar', :with_redirect => false)
42 end
43 end
43
44
44 # find the page with the given title
45 # find the page with the given title
45 # if page doesn't exist, return a new page
46 # if page doesn't exist, return a new page
46 def find_or_new_page(title)
47 def find_or_new_page(title)
47 title = start_page if title.blank?
48 title = start_page if title.blank?
48 find_page(title) || WikiPage.new(:wiki => self, :title => Wiki.titleize(title))
49 find_page(title) || WikiPage.new(:wiki => self, :title => Wiki.titleize(title))
49 end
50 end
50
51
51 # find the page with the given title
52 # find the page with the given title
52 def find_page(title, options = {})
53 def find_page(title, options = {})
53 @page_found_with_redirect = false
54 @page_found_with_redirect = false
54 title = start_page if title.blank?
55 title = start_page if title.blank?
55 title = Wiki.titleize(title)
56 title = Wiki.titleize(title)
56 page = pages.where("LOWER(title) = LOWER(?)", title).first
57 page = pages.where("LOWER(title) = LOWER(?)", title).first
57 if page.nil? && options[:with_redirect] != false
58 if page.nil? && options[:with_redirect] != false
58 # search for a redirect
59 # search for a redirect
59 redirect = redirects.where("LOWER(title) = LOWER(?)", title).first
60 redirect = redirects.where("LOWER(title) = LOWER(?)", title).first
60 if redirect
61 if redirect
61 page = redirect.target_page
62 page = redirect.target_page
62 @page_found_with_redirect = true
63 @page_found_with_redirect = true
63 end
64 end
64 end
65 end
65 page
66 page
66 end
67 end
67
68
68 # Returns true if the last page was found with a redirect
69 # Returns true if the last page was found with a redirect
69 def page_found_with_redirect?
70 def page_found_with_redirect?
70 @page_found_with_redirect
71 @page_found_with_redirect
71 end
72 end
72
73
73 # Deletes all redirects from/to the wiki
74 # Deletes all redirects from/to the wiki
74 def delete_redirects
75 def delete_redirects
75 WikiRedirect.where(:wiki_id => id).delete_all
76 WikiRedirect.where(:wiki_id => id).delete_all
76 WikiRedirect.where(:redirects_to_wiki_id => id).delete_all
77 WikiRedirect.where(:redirects_to_wiki_id => id).delete_all
77 end
78 end
78
79
79 # Finds a page by title
80 # Finds a page by title
80 # The given string can be of one of the forms: "title" or "project:title"
81 # The given string can be of one of the forms: "title" or "project:title"
81 # Examples:
82 # Examples:
82 # Wiki.find_page("bar", project => foo)
83 # Wiki.find_page("bar", project => foo)
83 # Wiki.find_page("foo:bar")
84 # Wiki.find_page("foo:bar")
84 def self.find_page(title, options = {})
85 def self.find_page(title, options = {})
85 project = options[:project]
86 project = options[:project]
86 if title.to_s =~ %r{^([^\:]+)\:(.*)$}
87 if title.to_s =~ %r{^([^\:]+)\:(.*)$}
87 project_identifier, title = $1, $2
88 project_identifier, title = $1, $2
88 project = Project.find_by_identifier(project_identifier) || Project.find_by_name(project_identifier)
89 project = Project.find_by_identifier(project_identifier) || Project.find_by_name(project_identifier)
89 end
90 end
90 if project && project.wiki
91 if project && project.wiki
91 page = project.wiki.find_page(title)
92 page = project.wiki.find_page(title)
92 if page && page.content
93 if page && page.content
93 page
94 page
94 end
95 end
95 end
96 end
96 end
97 end
97
98
98 # turn a string into a valid page title
99 # turn a string into a valid page title
99 def self.titleize(title)
100 def self.titleize(title)
100 # replace spaces with _ and remove unwanted caracters
101 # replace spaces with _ and remove unwanted caracters
101 title = title.gsub(/\s+/, '_').delete(',./?;|:') if title
102 title = title.gsub(/\s+/, '_').delete(',./?;|:') if title
102 # upcase the first letter
103 # upcase the first letter
103 title = (title.slice(0..0).upcase + (title.slice(1..-1) || '')) if title
104 title = (title.slice(0..0).upcase + (title.slice(1..-1) || '')) if title
104 title
105 title
105 end
106 end
106 end
107 end
@@ -1,297 +1,298
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2016 Jean-Philippe Lang
2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 require 'diff'
18 require 'diff'
19 require 'enumerator'
19 require 'enumerator'
20
20
21 class WikiPage < ActiveRecord::Base
21 class WikiPage < ActiveRecord::Base
22 include Redmine::SafeAttributes
22 include Redmine::SafeAttributes
23
23
24 belongs_to :wiki
24 belongs_to :wiki
25 has_one :content, :class_name => 'WikiContent', :foreign_key => 'page_id', :dependent => :destroy
25 has_one :content, :class_name => 'WikiContent', :foreign_key => 'page_id', :dependent => :destroy
26 has_one :content_without_text, lambda {without_text.readonly}, :class_name => 'WikiContent', :foreign_key => 'page_id'
26 has_one :content_without_text, lambda {without_text.readonly}, :class_name => 'WikiContent', :foreign_key => 'page_id'
27
27
28 acts_as_attachable :delete_permission => :delete_wiki_pages_attachments
28 acts_as_attachable :delete_permission => :delete_wiki_pages_attachments
29 acts_as_tree :dependent => :nullify, :order => 'title'
29 acts_as_tree :dependent => :nullify, :order => 'title'
30
30
31 acts_as_watchable
31 acts_as_watchable
32 acts_as_event :title => Proc.new {|o| "#{l(:label_wiki)}: #{o.title}"},
32 acts_as_event :title => Proc.new {|o| "#{l(:label_wiki)}: #{o.title}"},
33 :description => :text,
33 :description => :text,
34 :datetime => :created_on,
34 :datetime => :created_on,
35 :url => Proc.new {|o| {:controller => 'wiki', :action => 'show', :project_id => o.wiki.project, :id => o.title}}
35 :url => Proc.new {|o| {:controller => 'wiki', :action => 'show', :project_id => o.wiki.project, :id => o.title}}
36
36
37 acts_as_searchable :columns => ['title', "#{WikiContent.table_name}.text"],
37 acts_as_searchable :columns => ['title', "#{WikiContent.table_name}.text"],
38 :scope => joins(:content, {:wiki => :project}),
38 :scope => joins(:content, {:wiki => :project}),
39 :preload => [:content, {:wiki => :project}],
39 :preload => [:content, {:wiki => :project}],
40 :permission => :view_wiki_pages,
40 :permission => :view_wiki_pages,
41 :project_key => "#{Wiki.table_name}.project_id"
41 :project_key => "#{Wiki.table_name}.project_id"
42
42
43 attr_accessor :redirect_existing_links
43 attr_accessor :redirect_existing_links
44
44
45 validates_presence_of :title
45 validates_presence_of :title
46 validates_format_of :title, :with => /\A[^,\.\/\?\;\|\s]*\z/
46 validates_format_of :title, :with => /\A[^,\.\/\?\;\|\s]*\z/
47 validates_uniqueness_of :title, :scope => :wiki_id, :case_sensitive => false
47 validates_uniqueness_of :title, :scope => :wiki_id, :case_sensitive => false
48 validates_length_of :title, maximum: 255
48 validates_associated :content
49 validates_associated :content
49 attr_protected :id
50 attr_protected :id
50
51
51 validate :validate_parent_title
52 validate :validate_parent_title
52 before_destroy :delete_redirects
53 before_destroy :delete_redirects
53 before_save :handle_rename_or_move
54 before_save :handle_rename_or_move
54 after_save :handle_children_move
55 after_save :handle_children_move
55
56
56 # eager load information about last updates, without loading text
57 # eager load information about last updates, without loading text
57 scope :with_updated_on, lambda { preload(:content_without_text) }
58 scope :with_updated_on, lambda { preload(:content_without_text) }
58
59
59 # Wiki pages that are protected by default
60 # Wiki pages that are protected by default
60 DEFAULT_PROTECTED_PAGES = %w(sidebar)
61 DEFAULT_PROTECTED_PAGES = %w(sidebar)
61
62
62 safe_attributes 'parent_id', 'parent_title', 'title', 'redirect_existing_links', 'wiki_id',
63 safe_attributes 'parent_id', 'parent_title', 'title', 'redirect_existing_links', 'wiki_id',
63 :if => lambda {|page, user| page.new_record? || user.allowed_to?(:rename_wiki_pages, page.project)}
64 :if => lambda {|page, user| page.new_record? || user.allowed_to?(:rename_wiki_pages, page.project)}
64
65
65 def initialize(attributes=nil, *args)
66 def initialize(attributes=nil, *args)
66 super
67 super
67 if new_record? && DEFAULT_PROTECTED_PAGES.include?(title.to_s.downcase)
68 if new_record? && DEFAULT_PROTECTED_PAGES.include?(title.to_s.downcase)
68 self.protected = true
69 self.protected = true
69 end
70 end
70 end
71 end
71
72
72 def visible?(user=User.current)
73 def visible?(user=User.current)
73 !user.nil? && user.allowed_to?(:view_wiki_pages, project)
74 !user.nil? && user.allowed_to?(:view_wiki_pages, project)
74 end
75 end
75
76
76 def title=(value)
77 def title=(value)
77 value = Wiki.titleize(value)
78 value = Wiki.titleize(value)
78 write_attribute(:title, value)
79 write_attribute(:title, value)
79 end
80 end
80
81
81 def safe_attributes=(attrs, user=User.current)
82 def safe_attributes=(attrs, user=User.current)
82 return unless attrs.is_a?(Hash)
83 return unless attrs.is_a?(Hash)
83 attrs = attrs.deep_dup
84 attrs = attrs.deep_dup
84
85
85 # Project and Tracker must be set before since new_statuses_allowed_to depends on it.
86 # Project and Tracker must be set before since new_statuses_allowed_to depends on it.
86 if (w_id = attrs.delete('wiki_id')) && safe_attribute?('wiki_id')
87 if (w_id = attrs.delete('wiki_id')) && safe_attribute?('wiki_id')
87 if (w = Wiki.find_by_id(w_id)) && w.project && user.allowed_to?(:rename_wiki_pages, w.project)
88 if (w = Wiki.find_by_id(w_id)) && w.project && user.allowed_to?(:rename_wiki_pages, w.project)
88 self.wiki = w
89 self.wiki = w
89 end
90 end
90 end
91 end
91
92
92 super attrs, user
93 super attrs, user
93 end
94 end
94
95
95 # Manages redirects if page is renamed or moved
96 # Manages redirects if page is renamed or moved
96 def handle_rename_or_move
97 def handle_rename_or_move
97 if !new_record? && (title_changed? || wiki_id_changed?)
98 if !new_record? && (title_changed? || wiki_id_changed?)
98 # Update redirects that point to the old title
99 # Update redirects that point to the old title
99 WikiRedirect.where(:redirects_to => title_was, :redirects_to_wiki_id => wiki_id_was).each do |r|
100 WikiRedirect.where(:redirects_to => title_was, :redirects_to_wiki_id => wiki_id_was).each do |r|
100 r.redirects_to = title
101 r.redirects_to = title
101 r.redirects_to_wiki_id = wiki_id
102 r.redirects_to_wiki_id = wiki_id
102 (r.title == r.redirects_to && r.wiki_id == r.redirects_to_wiki_id) ? r.destroy : r.save
103 (r.title == r.redirects_to && r.wiki_id == r.redirects_to_wiki_id) ? r.destroy : r.save
103 end
104 end
104 # Remove redirects for the new title
105 # Remove redirects for the new title
105 WikiRedirect.where(:wiki_id => wiki_id, :title => title).delete_all
106 WikiRedirect.where(:wiki_id => wiki_id, :title => title).delete_all
106 # Create a redirect to the new title
107 # Create a redirect to the new title
107 unless redirect_existing_links == "0"
108 unless redirect_existing_links == "0"
108 WikiRedirect.create(
109 WikiRedirect.create(
109 :wiki_id => wiki_id_was, :title => title_was,
110 :wiki_id => wiki_id_was, :title => title_was,
110 :redirects_to_wiki_id => wiki_id, :redirects_to => title
111 :redirects_to_wiki_id => wiki_id, :redirects_to => title
111 )
112 )
112 end
113 end
113 end
114 end
114 if !new_record? && wiki_id_changed? && parent.present?
115 if !new_record? && wiki_id_changed? && parent.present?
115 unless parent.wiki_id == wiki_id
116 unless parent.wiki_id == wiki_id
116 self.parent_id = nil
117 self.parent_id = nil
117 end
118 end
118 end
119 end
119 end
120 end
120 private :handle_rename_or_move
121 private :handle_rename_or_move
121
122
122 # Moves child pages if page was moved
123 # Moves child pages if page was moved
123 def handle_children_move
124 def handle_children_move
124 if !new_record? && wiki_id_changed?
125 if !new_record? && wiki_id_changed?
125 children.each do |child|
126 children.each do |child|
126 child.wiki_id = wiki_id
127 child.wiki_id = wiki_id
127 child.redirect_existing_links = redirect_existing_links
128 child.redirect_existing_links = redirect_existing_links
128 unless child.save
129 unless child.save
129 WikiPage.where(:id => child.id).update_all :parent_id => nil
130 WikiPage.where(:id => child.id).update_all :parent_id => nil
130 end
131 end
131 end
132 end
132 end
133 end
133 end
134 end
134 private :handle_children_move
135 private :handle_children_move
135
136
136 # Deletes redirects to this page
137 # Deletes redirects to this page
137 def delete_redirects
138 def delete_redirects
138 WikiRedirect.where(:redirects_to_wiki_id => wiki_id, :redirects_to => title).delete_all
139 WikiRedirect.where(:redirects_to_wiki_id => wiki_id, :redirects_to => title).delete_all
139 end
140 end
140
141
141 def pretty_title
142 def pretty_title
142 WikiPage.pretty_title(title)
143 WikiPage.pretty_title(title)
143 end
144 end
144
145
145 def content_for_version(version=nil)
146 def content_for_version(version=nil)
146 if content
147 if content
147 result = content.versions.find_by_version(version.to_i) if version
148 result = content.versions.find_by_version(version.to_i) if version
148 result ||= content
149 result ||= content
149 result
150 result
150 end
151 end
151 end
152 end
152
153
153 def diff(version_to=nil, version_from=nil)
154 def diff(version_to=nil, version_from=nil)
154 version_to = version_to ? version_to.to_i : self.content.version
155 version_to = version_to ? version_to.to_i : self.content.version
155 content_to = content.versions.find_by_version(version_to)
156 content_to = content.versions.find_by_version(version_to)
156 content_from = version_from ? content.versions.find_by_version(version_from.to_i) : content_to.try(:previous)
157 content_from = version_from ? content.versions.find_by_version(version_from.to_i) : content_to.try(:previous)
157 return nil unless content_to && content_from
158 return nil unless content_to && content_from
158
159
159 if content_from.version > content_to.version
160 if content_from.version > content_to.version
160 content_to, content_from = content_from, content_to
161 content_to, content_from = content_from, content_to
161 end
162 end
162
163
163 (content_to && content_from) ? WikiDiff.new(content_to, content_from) : nil
164 (content_to && content_from) ? WikiDiff.new(content_to, content_from) : nil
164 end
165 end
165
166
166 def annotate(version=nil)
167 def annotate(version=nil)
167 version = version ? version.to_i : self.content.version
168 version = version ? version.to_i : self.content.version
168 c = content.versions.find_by_version(version)
169 c = content.versions.find_by_version(version)
169 c ? WikiAnnotate.new(c) : nil
170 c ? WikiAnnotate.new(c) : nil
170 end
171 end
171
172
172 def self.pretty_title(str)
173 def self.pretty_title(str)
173 (str && str.is_a?(String)) ? str.tr('_', ' ') : str
174 (str && str.is_a?(String)) ? str.tr('_', ' ') : str
174 end
175 end
175
176
176 def project
177 def project
177 wiki.try(:project)
178 wiki.try(:project)
178 end
179 end
179
180
180 def text
181 def text
181 content.text if content
182 content.text if content
182 end
183 end
183
184
184 def updated_on
185 def updated_on
185 content_attribute(:updated_on)
186 content_attribute(:updated_on)
186 end
187 end
187
188
188 def version
189 def version
189 content_attribute(:version)
190 content_attribute(:version)
190 end
191 end
191
192
192 # Returns true if usr is allowed to edit the page, otherwise false
193 # Returns true if usr is allowed to edit the page, otherwise false
193 def editable_by?(usr)
194 def editable_by?(usr)
194 !protected? || usr.allowed_to?(:protect_wiki_pages, wiki.project)
195 !protected? || usr.allowed_to?(:protect_wiki_pages, wiki.project)
195 end
196 end
196
197
197 def attachments_deletable?(usr=User.current)
198 def attachments_deletable?(usr=User.current)
198 editable_by?(usr) && super(usr)
199 editable_by?(usr) && super(usr)
199 end
200 end
200
201
201 def parent_title
202 def parent_title
202 @parent_title || (self.parent && self.parent.pretty_title)
203 @parent_title || (self.parent && self.parent.pretty_title)
203 end
204 end
204
205
205 def parent_title=(t)
206 def parent_title=(t)
206 @parent_title = t
207 @parent_title = t
207 parent_page = t.blank? ? nil : self.wiki.find_page(t)
208 parent_page = t.blank? ? nil : self.wiki.find_page(t)
208 self.parent = parent_page
209 self.parent = parent_page
209 end
210 end
210
211
211 # Saves the page and its content if text was changed
212 # Saves the page and its content if text was changed
212 # Return true if the page was saved
213 # Return true if the page was saved
213 def save_with_content(content)
214 def save_with_content(content)
214 ret = nil
215 ret = nil
215 transaction do
216 transaction do
216 ret = save
217 ret = save
217 if content.text_changed?
218 if content.text_changed?
218 begin
219 begin
219 self.content = content
220 self.content = content
220 ret = ret && content.changed?
221 ret = ret && content.changed?
221 rescue ActiveRecord::RecordNotSaved
222 rescue ActiveRecord::RecordNotSaved
222 ret = false
223 ret = false
223 end
224 end
224 end
225 end
225 raise ActiveRecord::Rollback unless ret
226 raise ActiveRecord::Rollback unless ret
226 end
227 end
227 ret
228 ret
228 end
229 end
229
230
230 protected
231 protected
231
232
232 def validate_parent_title
233 def validate_parent_title
233 errors.add(:parent_title, :invalid) if !@parent_title.blank? && parent.nil?
234 errors.add(:parent_title, :invalid) if !@parent_title.blank? && parent.nil?
234 errors.add(:parent_title, :circular_dependency) if parent && (parent == self || parent.ancestors.include?(self))
235 errors.add(:parent_title, :circular_dependency) if parent && (parent == self || parent.ancestors.include?(self))
235 if parent_id_changed? && parent && (parent.wiki_id != wiki_id)
236 if parent_id_changed? && parent && (parent.wiki_id != wiki_id)
236 errors.add(:parent_title, :not_same_project)
237 errors.add(:parent_title, :not_same_project)
237 end
238 end
238 end
239 end
239
240
240 private
241 private
241
242
242 def content_attribute(name)
243 def content_attribute(name)
243 (association(:content).loaded? ? content : content_without_text).try(name)
244 (association(:content).loaded? ? content : content_without_text).try(name)
244 end
245 end
245 end
246 end
246
247
247 class WikiDiff < Redmine::Helpers::Diff
248 class WikiDiff < Redmine::Helpers::Diff
248 attr_reader :content_to, :content_from
249 attr_reader :content_to, :content_from
249
250
250 def initialize(content_to, content_from)
251 def initialize(content_to, content_from)
251 @content_to = content_to
252 @content_to = content_to
252 @content_from = content_from
253 @content_from = content_from
253 super(content_to.text, content_from.text)
254 super(content_to.text, content_from.text)
254 end
255 end
255 end
256 end
256
257
257 class WikiAnnotate
258 class WikiAnnotate
258 attr_reader :lines, :content
259 attr_reader :lines, :content
259
260
260 def initialize(content)
261 def initialize(content)
261 @content = content
262 @content = content
262 current = content
263 current = content
263 current_lines = current.text.split(/\r?\n/)
264 current_lines = current.text.split(/\r?\n/)
264 @lines = current_lines.collect {|t| [nil, nil, t]}
265 @lines = current_lines.collect {|t| [nil, nil, t]}
265 positions = []
266 positions = []
266 current_lines.size.times {|i| positions << i}
267 current_lines.size.times {|i| positions << i}
267 while (current.previous)
268 while (current.previous)
268 d = current.previous.text.split(/\r?\n/).diff(current.text.split(/\r?\n/)).diffs.flatten
269 d = current.previous.text.split(/\r?\n/).diff(current.text.split(/\r?\n/)).diffs.flatten
269 d.each_slice(3) do |s|
270 d.each_slice(3) do |s|
270 sign, line = s[0], s[1]
271 sign, line = s[0], s[1]
271 if sign == '+' && positions[line] && positions[line] != -1
272 if sign == '+' && positions[line] && positions[line] != -1
272 if @lines[positions[line]][0].nil?
273 if @lines[positions[line]][0].nil?
273 @lines[positions[line]][0] = current.version
274 @lines[positions[line]][0] = current.version
274 @lines[positions[line]][1] = current.author
275 @lines[positions[line]][1] = current.author
275 end
276 end
276 end
277 end
277 end
278 end
278 d.each_slice(3) do |s|
279 d.each_slice(3) do |s|
279 sign, line = s[0], s[1]
280 sign, line = s[0], s[1]
280 if sign == '-'
281 if sign == '-'
281 positions.insert(line, -1)
282 positions.insert(line, -1)
282 else
283 else
283 positions[line] = nil
284 positions[line] = nil
284 end
285 end
285 end
286 end
286 positions.compact!
287 positions.compact!
287 # Stop if every line is annotated
288 # Stop if every line is annotated
288 break unless @lines.detect { |line| line[0].nil? }
289 break unless @lines.detect { |line| line[0].nil? }
289 current = current.previous
290 current = current.previous
290 end
291 end
291 @lines.each { |line|
292 @lines.each { |line|
292 line[0] ||= current.version
293 line[0] ||= current.version
293 # if the last known version is > 1 (eg. history was cleared), we don't know the author
294 # if the last known version is > 1 (eg. history was cleared), we don't know the author
294 line[1] ||= current.author if current.version == 1
295 line[1] ||= current.author if current.version == 1
295 }
296 }
296 end
297 end
297 end
298 end
General Comments 0
You need to be logged in to leave comments. Login now