##// END OF EJS Templates
Merged r15989 and r15991 (#24283)....
Jean-Philippe Lang -
r15621:308133be4532
parent child
Show More
@@ -1,292 +1,293
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::SubclassFactory
19 include Redmine::SubclassFactory
20
20
21 has_many :enumerations,
21 has_many :enumerations,
22 lambda { order(:position) },
22 lambda { order(:position) },
23 :class_name => 'CustomFieldEnumeration',
23 :class_name => 'CustomFieldEnumeration',
24 :dependent => :delete_all
24 :dependent => :delete_all
25 has_many :custom_values, :dependent => :delete_all
25 has_many :custom_values, :dependent => :delete_all
26 has_and_belongs_to_many :roles, :join_table => "#{table_name_prefix}custom_fields_roles#{table_name_suffix}", :foreign_key => "custom_field_id"
26 has_and_belongs_to_many :roles, :join_table => "#{table_name_prefix}custom_fields_roles#{table_name_suffix}", :foreign_key => "custom_field_id"
27 acts_as_positioned
27 acts_as_positioned
28 serialize :possible_values
28 serialize :possible_values
29 store :format_store
29 store :format_store
30
30
31 validates_presence_of :name, :field_format
31 validates_presence_of :name, :field_format
32 validates_uniqueness_of :name, :scope => :type
32 validates_uniqueness_of :name, :scope => :type
33 validates_length_of :name, :maximum => 30
33 validates_length_of :name, :maximum => 30
34 validates_length_of :regexp, maximum: 30
34 validates_inclusion_of :field_format, :in => Proc.new { Redmine::FieldFormat.available_formats }
35 validates_inclusion_of :field_format, :in => Proc.new { Redmine::FieldFormat.available_formats }
35 validate :validate_custom_field
36 validate :validate_custom_field
36 attr_protected :id
37 attr_protected :id
37
38
38 before_validation :set_searchable
39 before_validation :set_searchable
39 before_save do |field|
40 before_save do |field|
40 field.format.before_custom_field_save(field)
41 field.format.before_custom_field_save(field)
41 end
42 end
42 after_save :handle_multiplicity_change
43 after_save :handle_multiplicity_change
43 after_save do |field|
44 after_save do |field|
44 if field.visible_changed? && field.visible
45 if field.visible_changed? && field.visible
45 field.roles.clear
46 field.roles.clear
46 end
47 end
47 end
48 end
48
49
49 scope :sorted, lambda { order(:position) }
50 scope :sorted, lambda { order(:position) }
50 scope :visible, lambda {|*args|
51 scope :visible, lambda {|*args|
51 user = args.shift || User.current
52 user = args.shift || User.current
52 if user.admin?
53 if user.admin?
53 # nop
54 # nop
54 elsif user.memberships.any?
55 elsif user.memberships.any?
55 where("#{table_name}.visible = ? OR #{table_name}.id IN (SELECT DISTINCT cfr.custom_field_id FROM #{Member.table_name} m" +
56 where("#{table_name}.visible = ? OR #{table_name}.id IN (SELECT DISTINCT cfr.custom_field_id FROM #{Member.table_name} m" +
56 " INNER JOIN #{MemberRole.table_name} mr ON mr.member_id = m.id" +
57 " INNER JOIN #{MemberRole.table_name} mr ON mr.member_id = m.id" +
57 " INNER JOIN #{table_name_prefix}custom_fields_roles#{table_name_suffix} cfr ON cfr.role_id = mr.role_id" +
58 " INNER JOIN #{table_name_prefix}custom_fields_roles#{table_name_suffix} cfr ON cfr.role_id = mr.role_id" +
58 " WHERE m.user_id = ?)",
59 " WHERE m.user_id = ?)",
59 true, user.id)
60 true, user.id)
60 else
61 else
61 where(:visible => true)
62 where(:visible => true)
62 end
63 end
63 }
64 }
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 def format
70 def format
70 @format ||= Redmine::FieldFormat.find(field_format)
71 @format ||= Redmine::FieldFormat.find(field_format)
71 end
72 end
72
73
73 def field_format=(arg)
74 def field_format=(arg)
74 # cannot change format of a saved custom field
75 # cannot change format of a saved custom field
75 if new_record?
76 if new_record?
76 @format = nil
77 @format = nil
77 super
78 super
78 end
79 end
79 end
80 end
80
81
81 def set_searchable
82 def set_searchable
82 # make sure these fields are not searchable
83 # make sure these fields are not searchable
83 self.searchable = false unless format.class.searchable_supported
84 self.searchable = false unless format.class.searchable_supported
84 # make sure only these fields can have multiple values
85 # make sure only these fields can have multiple values
85 self.multiple = false unless format.class.multiple_supported
86 self.multiple = false unless format.class.multiple_supported
86 true
87 true
87 end
88 end
88
89
89 def validate_custom_field
90 def validate_custom_field
90 format.validate_custom_field(self).each do |attribute, message|
91 format.validate_custom_field(self).each do |attribute, message|
91 errors.add attribute, message
92 errors.add attribute, message
92 end
93 end
93
94
94 if regexp.present?
95 if regexp.present?
95 begin
96 begin
96 Regexp.new(regexp)
97 Regexp.new(regexp)
97 rescue
98 rescue
98 errors.add(:regexp, :invalid)
99 errors.add(:regexp, :invalid)
99 end
100 end
100 end
101 end
101
102
102 if default_value.present?
103 if default_value.present?
103 validate_field_value(default_value).each do |message|
104 validate_field_value(default_value).each do |message|
104 errors.add :default_value, message
105 errors.add :default_value, message
105 end
106 end
106 end
107 end
107 end
108 end
108
109
109 def possible_custom_value_options(custom_value)
110 def possible_custom_value_options(custom_value)
110 format.possible_custom_value_options(custom_value)
111 format.possible_custom_value_options(custom_value)
111 end
112 end
112
113
113 def possible_values_options(object=nil)
114 def possible_values_options(object=nil)
114 if object.is_a?(Array)
115 if object.is_a?(Array)
115 object.map {|o| format.possible_values_options(self, o)}.reduce(:&) || []
116 object.map {|o| format.possible_values_options(self, o)}.reduce(:&) || []
116 else
117 else
117 format.possible_values_options(self, object) || []
118 format.possible_values_options(self, object) || []
118 end
119 end
119 end
120 end
120
121
121 def possible_values
122 def possible_values
122 values = read_attribute(:possible_values)
123 values = read_attribute(:possible_values)
123 if values.is_a?(Array)
124 if values.is_a?(Array)
124 values.each do |value|
125 values.each do |value|
125 value.to_s.force_encoding('UTF-8')
126 value.to_s.force_encoding('UTF-8')
126 end
127 end
127 values
128 values
128 else
129 else
129 []
130 []
130 end
131 end
131 end
132 end
132
133
133 # Makes possible_values accept a multiline string
134 # Makes possible_values accept a multiline string
134 def possible_values=(arg)
135 def possible_values=(arg)
135 if arg.is_a?(Array)
136 if arg.is_a?(Array)
136 values = arg.compact.map {|a| a.to_s.strip}.reject(&:blank?)
137 values = arg.compact.map {|a| a.to_s.strip}.reject(&:blank?)
137 write_attribute(:possible_values, values)
138 write_attribute(:possible_values, values)
138 else
139 else
139 self.possible_values = arg.to_s.split(/[\n\r]+/)
140 self.possible_values = arg.to_s.split(/[\n\r]+/)
140 end
141 end
141 end
142 end
142
143
143 def cast_value(value)
144 def cast_value(value)
144 format.cast_value(self, value)
145 format.cast_value(self, value)
145 end
146 end
146
147
147 def value_from_keyword(keyword, customized)
148 def value_from_keyword(keyword, customized)
148 format.value_from_keyword(self, keyword, customized)
149 format.value_from_keyword(self, keyword, customized)
149 end
150 end
150
151
151 # Returns the options hash used to build a query filter for the field
152 # Returns the options hash used to build a query filter for the field
152 def query_filter_options(query)
153 def query_filter_options(query)
153 format.query_filter_options(self, query)
154 format.query_filter_options(self, query)
154 end
155 end
155
156
156 def totalable?
157 def totalable?
157 format.totalable_supported
158 format.totalable_supported
158 end
159 end
159
160
160 # Returns a ORDER BY clause that can used to sort customized
161 # Returns a ORDER BY clause that can used to sort customized
161 # objects by their value of the custom field.
162 # objects by their value of the custom field.
162 # Returns nil if the custom field can not be used for sorting.
163 # Returns nil if the custom field can not be used for sorting.
163 def order_statement
164 def order_statement
164 return nil if multiple?
165 return nil if multiple?
165 format.order_statement(self)
166 format.order_statement(self)
166 end
167 end
167
168
168 # Returns a GROUP BY clause that can used to group by custom value
169 # Returns a GROUP BY clause that can used to group by custom value
169 # Returns nil if the custom field can not be used for grouping.
170 # Returns nil if the custom field can not be used for grouping.
170 def group_statement
171 def group_statement
171 return nil if multiple?
172 return nil if multiple?
172 format.group_statement(self)
173 format.group_statement(self)
173 end
174 end
174
175
175 def join_for_order_statement
176 def join_for_order_statement
176 format.join_for_order_statement(self)
177 format.join_for_order_statement(self)
177 end
178 end
178
179
179 def visibility_by_project_condition(project_key=nil, user=User.current, id_column=nil)
180 def visibility_by_project_condition(project_key=nil, user=User.current, id_column=nil)
180 if visible? || user.admin?
181 if visible? || user.admin?
181 "1=1"
182 "1=1"
182 elsif user.anonymous?
183 elsif user.anonymous?
183 "1=0"
184 "1=0"
184 else
185 else
185 project_key ||= "#{self.class.customized_class.table_name}.project_id"
186 project_key ||= "#{self.class.customized_class.table_name}.project_id"
186 id_column ||= id
187 id_column ||= id
187 "#{project_key} IN (SELECT DISTINCT m.project_id FROM #{Member.table_name} m" +
188 "#{project_key} IN (SELECT DISTINCT m.project_id FROM #{Member.table_name} m" +
188 " INNER JOIN #{MemberRole.table_name} mr ON mr.member_id = m.id" +
189 " INNER JOIN #{MemberRole.table_name} mr ON mr.member_id = m.id" +
189 " INNER JOIN #{table_name_prefix}custom_fields_roles#{table_name_suffix} cfr ON cfr.role_id = mr.role_id" +
190 " INNER JOIN #{table_name_prefix}custom_fields_roles#{table_name_suffix} cfr ON cfr.role_id = mr.role_id" +
190 " WHERE m.user_id = #{user.id} AND cfr.custom_field_id = #{id_column})"
191 " WHERE m.user_id = #{user.id} AND cfr.custom_field_id = #{id_column})"
191 end
192 end
192 end
193 end
193
194
194 def self.visibility_condition
195 def self.visibility_condition
195 if user.admin?
196 if user.admin?
196 "1=1"
197 "1=1"
197 elsif user.anonymous?
198 elsif user.anonymous?
198 "#{table_name}.visible"
199 "#{table_name}.visible"
199 else
200 else
200 "#{project_key} IN (SELECT DISTINCT m.project_id FROM #{Member.table_name} m" +
201 "#{project_key} IN (SELECT DISTINCT m.project_id FROM #{Member.table_name} m" +
201 " INNER JOIN #{MemberRole.table_name} mr ON mr.member_id = m.id" +
202 " INNER JOIN #{MemberRole.table_name} mr ON mr.member_id = m.id" +
202 " INNER JOIN #{table_name_prefix}custom_fields_roles#{table_name_suffix} cfr ON cfr.role_id = mr.role_id" +
203 " INNER JOIN #{table_name_prefix}custom_fields_roles#{table_name_suffix} cfr ON cfr.role_id = mr.role_id" +
203 " WHERE m.user_id = #{user.id} AND cfr.custom_field_id = #{id})"
204 " WHERE m.user_id = #{user.id} AND cfr.custom_field_id = #{id})"
204 end
205 end
205 end
206 end
206
207
207 def <=>(field)
208 def <=>(field)
208 position <=> field.position
209 position <=> field.position
209 end
210 end
210
211
211 # Returns the class that values represent
212 # Returns the class that values represent
212 def value_class
213 def value_class
213 format.target_class if format.respond_to?(:target_class)
214 format.target_class if format.respond_to?(:target_class)
214 end
215 end
215
216
216 def self.customized_class
217 def self.customized_class
217 self.name =~ /^(.+)CustomField$/
218 self.name =~ /^(.+)CustomField$/
218 $1.constantize rescue nil
219 $1.constantize rescue nil
219 end
220 end
220
221
221 # to move in project_custom_field
222 # to move in project_custom_field
222 def self.for_all
223 def self.for_all
223 where(:is_for_all => true).order('position').to_a
224 where(:is_for_all => true).order('position').to_a
224 end
225 end
225
226
226 def type_name
227 def type_name
227 nil
228 nil
228 end
229 end
229
230
230 # Returns the error messages for the given value
231 # Returns the error messages for the given value
231 # or an empty array if value is a valid value for the custom field
232 # or an empty array if value is a valid value for the custom field
232 def validate_custom_value(custom_value)
233 def validate_custom_value(custom_value)
233 value = custom_value.value
234 value = custom_value.value
234 errs = []
235 errs = []
235 if value.is_a?(Array)
236 if value.is_a?(Array)
236 if !multiple?
237 if !multiple?
237 errs << ::I18n.t('activerecord.errors.messages.invalid')
238 errs << ::I18n.t('activerecord.errors.messages.invalid')
238 end
239 end
239 if is_required? && value.detect(&:present?).nil?
240 if is_required? && value.detect(&:present?).nil?
240 errs << ::I18n.t('activerecord.errors.messages.blank')
241 errs << ::I18n.t('activerecord.errors.messages.blank')
241 end
242 end
242 else
243 else
243 if is_required? && value.blank?
244 if is_required? && value.blank?
244 errs << ::I18n.t('activerecord.errors.messages.blank')
245 errs << ::I18n.t('activerecord.errors.messages.blank')
245 end
246 end
246 end
247 end
247 errs += format.validate_custom_value(custom_value)
248 errs += format.validate_custom_value(custom_value)
248 errs
249 errs
249 end
250 end
250
251
251 # Returns the error messages for the default custom field value
252 # Returns the error messages for the default custom field value
252 def validate_field_value(value)
253 def validate_field_value(value)
253 validate_custom_value(CustomFieldValue.new(:custom_field => self, :value => value))
254 validate_custom_value(CustomFieldValue.new(:custom_field => self, :value => value))
254 end
255 end
255
256
256 # Returns true if value is a valid value for the custom field
257 # Returns true if value is a valid value for the custom field
257 def valid_field_value?(value)
258 def valid_field_value?(value)
258 validate_field_value(value).empty?
259 validate_field_value(value).empty?
259 end
260 end
260
261
261 def format_in?(*args)
262 def format_in?(*args)
262 args.include?(field_format)
263 args.include?(field_format)
263 end
264 end
264
265
265 def self.human_attribute_name(attribute_key_name, *args)
266 def self.human_attribute_name(attribute_key_name, *args)
266 attr_name = attribute_key_name.to_s
267 attr_name = attribute_key_name.to_s
267 if attr_name == 'url_pattern'
268 if attr_name == 'url_pattern'
268 attr_name = "url"
269 attr_name = "url"
269 end
270 end
270 super(attr_name, *args)
271 super(attr_name, *args)
271 end
272 end
272
273
273 protected
274 protected
274
275
275 # Removes multiple values for the custom field after setting the multiple attribute to false
276 # Removes multiple values for the custom field after setting the multiple attribute to false
276 # We kepp the value with the highest id for each customized object
277 # We kepp the value with the highest id for each customized object
277 def handle_multiplicity_change
278 def handle_multiplicity_change
278 if !new_record? && multiple_was && !multiple
279 if !new_record? && multiple_was && !multiple
279 ids = custom_values.
280 ids = custom_values.
280 where("EXISTS(SELECT 1 FROM #{CustomValue.table_name} cve WHERE cve.custom_field_id = #{CustomValue.table_name}.custom_field_id" +
281 where("EXISTS(SELECT 1 FROM #{CustomValue.table_name} cve WHERE cve.custom_field_id = #{CustomValue.table_name}.custom_field_id" +
281 " AND cve.customized_type = #{CustomValue.table_name}.customized_type AND cve.customized_id = #{CustomValue.table_name}.customized_id" +
282 " AND cve.customized_type = #{CustomValue.table_name}.customized_type AND cve.customized_id = #{CustomValue.table_name}.customized_id" +
282 " AND cve.id > #{CustomValue.table_name}.id)").
283 " AND cve.id > #{CustomValue.table_name}.id)").
283 pluck(:id)
284 pluck(:id)
284
285
285 if ids.any?
286 if ids.any?
286 custom_values.where(:id => ids).delete_all
287 custom_values.where(:id => ids).delete_all
287 end
288 end
288 end
289 end
289 end
290 end
290 end
291 end
291
292
292 require_dependency 'redmine/field_format'
293 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).uniq.pluck(:committer, :user_id)
288 @committers ||= Changeset.where(:repository_id => id).uniq.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,923 +1,924
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 :login, :admin, :password, :password_confirmation, :hashed_password
103 attr_protected :login, :admin, :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).uniq
144 joins(:email_addresses).where("LOWER(#{EmailAddress.table_name}.address) IN (?)", addresses).uniq
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 'status',
700 safe_attributes 'status',
700 'auth_source_id',
701 'auth_source_id',
701 'generate_password',
702 'generate_password',
702 'must_change_passwd',
703 'must_change_passwd',
703 :if => lambda {|user, current_user| current_user.admin?}
704 :if => lambda {|user, current_user| current_user.admin?}
704
705
705 safe_attributes 'group_ids',
706 safe_attributes 'group_ids',
706 :if => lambda {|user, current_user| current_user.admin? && !user.new_record?}
707 :if => lambda {|user, current_user| current_user.admin? && !user.new_record?}
707
708
708 # Utility method to help check if a user should be notified about an
709 # Utility method to help check if a user should be notified about an
709 # event.
710 # event.
710 #
711 #
711 # TODO: only supports Issue events currently
712 # TODO: only supports Issue events currently
712 def notify_about?(object)
713 def notify_about?(object)
713 if mail_notification == 'all'
714 if mail_notification == 'all'
714 true
715 true
715 elsif mail_notification.blank? || mail_notification == 'none'
716 elsif mail_notification.blank? || mail_notification == 'none'
716 false
717 false
717 else
718 else
718 case object
719 case object
719 when Issue
720 when Issue
720 case mail_notification
721 case mail_notification
721 when 'selected', 'only_my_events'
722 when 'selected', 'only_my_events'
722 # user receives notifications for created/assigned issues on unselected projects
723 # user receives notifications for created/assigned issues on unselected projects
723 object.author == self || is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.assigned_to_was)
724 object.author == self || is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.assigned_to_was)
724 when 'only_assigned'
725 when 'only_assigned'
725 is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.assigned_to_was)
726 is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.assigned_to_was)
726 when 'only_owner'
727 when 'only_owner'
727 object.author == self
728 object.author == self
728 end
729 end
729 when News
730 when News
730 # always send to project members except when mail_notification is set to 'none'
731 # always send to project members except when mail_notification is set to 'none'
731 true
732 true
732 end
733 end
733 end
734 end
734 end
735 end
735
736
736 def self.current=(user)
737 def self.current=(user)
737 RequestStore.store[:current_user] = user
738 RequestStore.store[:current_user] = user
738 end
739 end
739
740
740 def self.current
741 def self.current
741 RequestStore.store[:current_user] ||= User.anonymous
742 RequestStore.store[:current_user] ||= User.anonymous
742 end
743 end
743
744
744 # Returns the anonymous user. If the anonymous user does not exist, it is created. There can be only
745 # Returns the anonymous user. If the anonymous user does not exist, it is created. There can be only
745 # one anonymous user per database.
746 # one anonymous user per database.
746 def self.anonymous
747 def self.anonymous
747 anonymous_user = AnonymousUser.first
748 anonymous_user = AnonymousUser.first
748 if anonymous_user.nil?
749 if anonymous_user.nil?
749 anonymous_user = AnonymousUser.create(:lastname => 'Anonymous', :firstname => '', :login => '', :status => 0)
750 anonymous_user = AnonymousUser.create(:lastname => 'Anonymous', :firstname => '', :login => '', :status => 0)
750 raise 'Unable to create the anonymous user.' if anonymous_user.new_record?
751 raise 'Unable to create the anonymous user.' if anonymous_user.new_record?
751 end
752 end
752 anonymous_user
753 anonymous_user
753 end
754 end
754
755
755 # Salts all existing unsalted passwords
756 # Salts all existing unsalted passwords
756 # It changes password storage scheme from SHA1(password) to SHA1(salt + SHA1(password))
757 # It changes password storage scheme from SHA1(password) to SHA1(salt + SHA1(password))
757 # This method is used in the SaltPasswords migration and is to be kept as is
758 # This method is used in the SaltPasswords migration and is to be kept as is
758 def self.salt_unsalted_passwords!
759 def self.salt_unsalted_passwords!
759 transaction do
760 transaction do
760 User.where("salt IS NULL OR salt = ''").find_each do |user|
761 User.where("salt IS NULL OR salt = ''").find_each do |user|
761 next if user.hashed_password.blank?
762 next if user.hashed_password.blank?
762 salt = User.generate_salt
763 salt = User.generate_salt
763 hashed_password = User.hash_password("#{salt}#{user.hashed_password}")
764 hashed_password = User.hash_password("#{salt}#{user.hashed_password}")
764 User.where(:id => user.id).update_all(:salt => salt, :hashed_password => hashed_password)
765 User.where(:id => user.id).update_all(:salt => salt, :hashed_password => hashed_password)
765 end
766 end
766 end
767 end
767 end
768 end
768
769
769 protected
770 protected
770
771
771 def validate_password_length
772 def validate_password_length
772 return if password.blank? && generate_password?
773 return if password.blank? && generate_password?
773 # Password length validation based on setting
774 # Password length validation based on setting
774 if !password.nil? && password.size < Setting.password_min_length.to_i
775 if !password.nil? && password.size < Setting.password_min_length.to_i
775 errors.add(:password, :too_short, :count => Setting.password_min_length.to_i)
776 errors.add(:password, :too_short, :count => Setting.password_min_length.to_i)
776 end
777 end
777 end
778 end
778
779
779 def instantiate_email_address
780 def instantiate_email_address
780 email_address || build_email_address
781 email_address || build_email_address
781 end
782 end
782
783
783 private
784 private
784
785
785 def generate_password_if_needed
786 def generate_password_if_needed
786 if generate_password? && auth_source.nil?
787 if generate_password? && auth_source.nil?
787 length = [Setting.password_min_length.to_i + 2, 10].max
788 length = [Setting.password_min_length.to_i + 2, 10].max
788 random_password(length)
789 random_password(length)
789 end
790 end
790 end
791 end
791
792
792 # Delete all outstanding password reset tokens on password change.
793 # Delete all outstanding password reset tokens on password change.
793 # Delete the autologin tokens on password change to prohibit session leakage.
794 # Delete the autologin tokens on password change to prohibit session leakage.
794 # This helps to keep the account secure in case the associated email account
795 # This helps to keep the account secure in case the associated email account
795 # was compromised.
796 # was compromised.
796 def destroy_tokens
797 def destroy_tokens
797 if hashed_password_changed? || (status_changed? && !active?)
798 if hashed_password_changed? || (status_changed? && !active?)
798 tokens = ['recovery', 'autologin', 'session']
799 tokens = ['recovery', 'autologin', 'session']
799 Token.where(:user_id => id, :action => tokens).delete_all
800 Token.where(:user_id => id, :action => tokens).delete_all
800 end
801 end
801 end
802 end
802
803
803 # Removes references that are not handled by associations
804 # Removes references that are not handled by associations
804 # Things that are not deleted are reassociated with the anonymous user
805 # Things that are not deleted are reassociated with the anonymous user
805 def remove_references_before_destroy
806 def remove_references_before_destroy
806 return if self.id.nil?
807 return if self.id.nil?
807
808
808 substitute = User.anonymous
809 substitute = User.anonymous
809 Attachment.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
810 Attachment.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
810 Comment.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
811 Comment.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
811 Issue.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
812 Issue.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
812 Issue.where(['assigned_to_id = ?', id]).update_all('assigned_to_id = NULL')
813 Issue.where(['assigned_to_id = ?', id]).update_all('assigned_to_id = NULL')
813 Journal.where(['user_id = ?', id]).update_all(['user_id = ?', substitute.id])
814 Journal.where(['user_id = ?', id]).update_all(['user_id = ?', substitute.id])
814 JournalDetail.
815 JournalDetail.
815 where(["property = 'attr' AND prop_key = 'assigned_to_id' AND old_value = ?", id.to_s]).
816 where(["property = 'attr' AND prop_key = 'assigned_to_id' AND old_value = ?", id.to_s]).
816 update_all(['old_value = ?', substitute.id.to_s])
817 update_all(['old_value = ?', substitute.id.to_s])
817 JournalDetail.
818 JournalDetail.
818 where(["property = 'attr' AND prop_key = 'assigned_to_id' AND value = ?", id.to_s]).
819 where(["property = 'attr' AND prop_key = 'assigned_to_id' AND value = ?", id.to_s]).
819 update_all(['value = ?', substitute.id.to_s])
820 update_all(['value = ?', substitute.id.to_s])
820 Message.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
821 Message.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
821 News.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
822 News.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
822 # Remove private queries and keep public ones
823 # Remove private queries and keep public ones
823 ::Query.delete_all ['user_id = ? AND visibility = ?', id, ::Query::VISIBILITY_PRIVATE]
824 ::Query.delete_all ['user_id = ? AND visibility = ?', id, ::Query::VISIBILITY_PRIVATE]
824 ::Query.where(['user_id = ?', id]).update_all(['user_id = ?', substitute.id])
825 ::Query.where(['user_id = ?', id]).update_all(['user_id = ?', substitute.id])
825 TimeEntry.where(['user_id = ?', id]).update_all(['user_id = ?', substitute.id])
826 TimeEntry.where(['user_id = ?', id]).update_all(['user_id = ?', substitute.id])
826 Token.delete_all ['user_id = ?', id]
827 Token.delete_all ['user_id = ?', id]
827 Watcher.delete_all ['user_id = ?', id]
828 Watcher.delete_all ['user_id = ?', id]
828 WikiContent.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
829 WikiContent.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
829 WikiContent::Version.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
830 WikiContent::Version.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
830 end
831 end
831
832
832 # Return password digest
833 # Return password digest
833 def self.hash_password(clear_password)
834 def self.hash_password(clear_password)
834 Digest::SHA1.hexdigest(clear_password || "")
835 Digest::SHA1.hexdigest(clear_password || "")
835 end
836 end
836
837
837 # Returns a 128bits random salt as a hex string (32 chars long)
838 # Returns a 128bits random salt as a hex string (32 chars long)
838 def self.generate_salt
839 def self.generate_salt
839 Redmine::Utils.random_hex(16)
840 Redmine::Utils.random_hex(16)
840 end
841 end
841
842
842 # Send a security notification to all admins if the user has gained/lost admin privileges
843 # Send a security notification to all admins if the user has gained/lost admin privileges
843 def deliver_security_notification
844 def deliver_security_notification
844 options = {
845 options = {
845 field: :field_admin,
846 field: :field_admin,
846 value: login,
847 value: login,
847 title: :label_user_plural,
848 title: :label_user_plural,
848 url: {controller: 'users', action: 'index'}
849 url: {controller: 'users', action: 'index'}
849 }
850 }
850
851
851 deliver = false
852 deliver = false
852 if (admin? && id_changed? && active?) || # newly created admin
853 if (admin? && id_changed? && active?) || # newly created admin
853 (admin? && admin_changed? && active?) || # regular user became admin
854 (admin? && admin_changed? && active?) || # regular user became admin
854 (admin? && status_changed? && active?) # locked admin became active again
855 (admin? && status_changed? && active?) # locked admin became active again
855
856
856 deliver = true
857 deliver = true
857 options[:message] = :mail_body_security_notification_add
858 options[:message] = :mail_body_security_notification_add
858
859
859 elsif (admin? && destroyed? && active?) || # active admin user was deleted
860 elsif (admin? && destroyed? && active?) || # active admin user was deleted
860 (!admin? && admin_changed? && active?) || # admin is no longer admin
861 (!admin? && admin_changed? && active?) || # admin is no longer admin
861 (admin? && status_changed? && !active?) # admin was locked
862 (admin? && status_changed? && !active?) # admin was locked
862
863
863 deliver = true
864 deliver = true
864 options[:message] = :mail_body_security_notification_remove
865 options[:message] = :mail_body_security_notification_remove
865 end
866 end
866
867
867 if deliver
868 if deliver
868 users = User.active.where(admin: true).to_a
869 users = User.active.where(admin: true).to_a
869 Mailer.security_notification(users, options).deliver
870 Mailer.security_notification(users, options).deliver
870 end
871 end
871 end
872 end
872 end
873 end
873
874
874 class AnonymousUser < User
875 class AnonymousUser < User
875 validate :validate_anonymous_uniqueness, :on => :create
876 validate :validate_anonymous_uniqueness, :on => :create
876
877
877 self.valid_statuses = [STATUS_ANONYMOUS]
878 self.valid_statuses = [STATUS_ANONYMOUS]
878
879
879 def validate_anonymous_uniqueness
880 def validate_anonymous_uniqueness
880 # There should be only one AnonymousUser in the database
881 # There should be only one AnonymousUser in the database
881 errors.add :base, 'An anonymous user already exists.' if AnonymousUser.exists?
882 errors.add :base, 'An anonymous user already exists.' if AnonymousUser.exists?
882 end
883 end
883
884
884 def available_custom_fields
885 def available_custom_fields
885 []
886 []
886 end
887 end
887
888
888 # Overrides a few properties
889 # Overrides a few properties
889 def logged?; false end
890 def logged?; false end
890 def admin; false end
891 def admin; false end
891 def name(*args); I18n.t(:label_user_anonymous) end
892 def name(*args); I18n.t(:label_user_anonymous) end
892 def mail=(*args); nil end
893 def mail=(*args); nil end
893 def mail; nil end
894 def mail; nil end
894 def time_zone; nil end
895 def time_zone; nil end
895 def rss_key; nil end
896 def rss_key; nil end
896
897
897 def pref
898 def pref
898 UserPreference.new(:user => self)
899 UserPreference.new(:user => self)
899 end
900 end
900
901
901 # Returns the user's bult-in role
902 # Returns the user's bult-in role
902 def builtin_role
903 def builtin_role
903 @builtin_role ||= Role.anonymous
904 @builtin_role ||= Role.anonymous
904 end
905 end
905
906
906 def membership(*args)
907 def membership(*args)
907 nil
908 nil
908 end
909 end
909
910
910 def member_of?(*args)
911 def member_of?(*args)
911 false
912 false
912 end
913 end
913
914
914 # Anonymous user can not be destroyed
915 # Anonymous user can not be destroyed
915 def destroy
916 def destroy
916 false
917 false
917 end
918 end
918
919
919 protected
920 protected
920
921
921 def instantiate_email_address
922 def instantiate_email_address
922 end
923 end
923 end
924 end
@@ -1,314 +1,314
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 before_destroy :nullify_projects_default_version
22 before_destroy :nullify_projects_default_version
23
23
24 belongs_to :project
24 belongs_to :project
25 has_many :fixed_issues, :class_name => 'Issue', :foreign_key => 'fixed_version_id', :dependent => :nullify
25 has_many :fixed_issues, :class_name => 'Issue', :foreign_key => 'fixed_version_id', :dependent => :nullify
26 acts_as_customizable
26 acts_as_customizable
27 acts_as_attachable :view_permission => :view_files,
27 acts_as_attachable :view_permission => :view_files,
28 :edit_permission => :manage_files,
28 :edit_permission => :manage_files,
29 :delete_permission => :manage_files
29 :delete_permission => :manage_files
30
30
31 VERSION_STATUSES = %w(open locked closed)
31 VERSION_STATUSES = %w(open locked closed)
32 VERSION_SHARINGS = %w(none descendants hierarchy tree system)
32 VERSION_SHARINGS = %w(none descendants hierarchy tree system)
33
33
34 validates_presence_of :name
34 validates_presence_of :name
35 validates_uniqueness_of :name, :scope => [:project_id]
35 validates_uniqueness_of :name, :scope => [:project_id]
36 validates_length_of :name, :maximum => 60
36 validates_length_of :name, :maximum => 60
37 validates_length_of :description, :maximum => 255
37 validates_length_of :description, :wiki_page_title, :maximum => 255
38 validates :effective_date, :date => true
38 validates :effective_date, :date => true
39 validates_inclusion_of :status, :in => VERSION_STATUSES
39 validates_inclusion_of :status, :in => VERSION_STATUSES
40 validates_inclusion_of :sharing, :in => VERSION_SHARINGS
40 validates_inclusion_of :sharing, :in => VERSION_SHARINGS
41 attr_protected :id
41 attr_protected :id
42
42
43 scope :named, lambda {|arg| where("LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip)}
43 scope :named, lambda {|arg| where("LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip)}
44 scope :open, lambda { where(:status => 'open') }
44 scope :open, lambda { where(:status => 'open') }
45 scope :visible, lambda {|*args|
45 scope :visible, lambda {|*args|
46 joins(:project).
46 joins(:project).
47 where(Project.allowed_to_condition(args.first || User.current, :view_issues))
47 where(Project.allowed_to_condition(args.first || User.current, :view_issues))
48 }
48 }
49
49
50 safe_attributes 'name',
50 safe_attributes 'name',
51 'description',
51 'description',
52 'effective_date',
52 'effective_date',
53 'due_date',
53 'due_date',
54 'wiki_page_title',
54 'wiki_page_title',
55 'status',
55 'status',
56 'sharing',
56 'sharing',
57 'custom_field_values',
57 'custom_field_values',
58 'custom_fields'
58 'custom_fields'
59
59
60 # Returns true if +user+ or current user is allowed to view the version
60 # Returns true if +user+ or current user is allowed to view the version
61 def visible?(user=User.current)
61 def visible?(user=User.current)
62 user.allowed_to?(:view_issues, self.project)
62 user.allowed_to?(:view_issues, self.project)
63 end
63 end
64
64
65 # Version files have same visibility as project files
65 # Version files have same visibility as project files
66 def attachments_visible?(*args)
66 def attachments_visible?(*args)
67 project.present? && project.attachments_visible?(*args)
67 project.present? && project.attachments_visible?(*args)
68 end
68 end
69
69
70 def attachments_deletable?(usr=User.current)
70 def attachments_deletable?(usr=User.current)
71 project.present? && project.attachments_deletable?(usr)
71 project.present? && project.attachments_deletable?(usr)
72 end
72 end
73
73
74 def start_date
74 def start_date
75 @start_date ||= fixed_issues.minimum('start_date')
75 @start_date ||= fixed_issues.minimum('start_date')
76 end
76 end
77
77
78 def due_date
78 def due_date
79 effective_date
79 effective_date
80 end
80 end
81
81
82 def due_date=(arg)
82 def due_date=(arg)
83 self.effective_date=(arg)
83 self.effective_date=(arg)
84 end
84 end
85
85
86 # Returns the total estimated time for this version
86 # Returns the total estimated time for this version
87 # (sum of leaves estimated_hours)
87 # (sum of leaves estimated_hours)
88 def estimated_hours
88 def estimated_hours
89 @estimated_hours ||= fixed_issues.sum(:estimated_hours).to_f
89 @estimated_hours ||= fixed_issues.sum(:estimated_hours).to_f
90 end
90 end
91
91
92 # Returns the total reported time for this version
92 # Returns the total reported time for this version
93 def spent_hours
93 def spent_hours
94 @spent_hours ||= TimeEntry.joins(:issue).where("#{Issue.table_name}.fixed_version_id = ?", id).sum(:hours).to_f
94 @spent_hours ||= TimeEntry.joins(:issue).where("#{Issue.table_name}.fixed_version_id = ?", id).sum(:hours).to_f
95 end
95 end
96
96
97 def closed?
97 def closed?
98 status == 'closed'
98 status == 'closed'
99 end
99 end
100
100
101 def open?
101 def open?
102 status == 'open'
102 status == 'open'
103 end
103 end
104
104
105 # Returns true if the version is completed: closed or due date reached and no open issues
105 # Returns true if the version is completed: closed or due date reached and no open issues
106 def completed?
106 def completed?
107 closed? || (effective_date && (effective_date < User.current.today) && (open_issues_count == 0))
107 closed? || (effective_date && (effective_date < User.current.today) && (open_issues_count == 0))
108 end
108 end
109
109
110 def behind_schedule?
110 def behind_schedule?
111 if completed_percent == 100
111 if completed_percent == 100
112 return false
112 return false
113 elsif due_date && start_date
113 elsif due_date && start_date
114 done_date = start_date + ((due_date - start_date+1)* completed_percent/100).floor
114 done_date = start_date + ((due_date - start_date+1)* completed_percent/100).floor
115 return done_date <= User.current.today
115 return done_date <= User.current.today
116 else
116 else
117 false # No issues so it's not late
117 false # No issues so it's not late
118 end
118 end
119 end
119 end
120
120
121 # Returns the completion percentage of this version based on the amount of open/closed issues
121 # Returns the completion percentage of this version based on the amount of open/closed issues
122 # and the time spent on the open issues.
122 # and the time spent on the open issues.
123 def completed_percent
123 def completed_percent
124 if issues_count == 0
124 if issues_count == 0
125 0
125 0
126 elsif open_issues_count == 0
126 elsif open_issues_count == 0
127 100
127 100
128 else
128 else
129 issues_progress(false) + issues_progress(true)
129 issues_progress(false) + issues_progress(true)
130 end
130 end
131 end
131 end
132
132
133 # Returns the percentage of issues that have been marked as 'closed'.
133 # Returns the percentage of issues that have been marked as 'closed'.
134 def closed_percent
134 def closed_percent
135 if issues_count == 0
135 if issues_count == 0
136 0
136 0
137 else
137 else
138 issues_progress(false)
138 issues_progress(false)
139 end
139 end
140 end
140 end
141
141
142 # Returns true if the version is overdue: due date reached and some open issues
142 # Returns true if the version is overdue: due date reached and some open issues
143 def overdue?
143 def overdue?
144 effective_date && (effective_date < User.current.today) && (open_issues_count > 0)
144 effective_date && (effective_date < User.current.today) && (open_issues_count > 0)
145 end
145 end
146
146
147 # Returns assigned issues count
147 # Returns assigned issues count
148 def issues_count
148 def issues_count
149 load_issue_counts
149 load_issue_counts
150 @issue_count
150 @issue_count
151 end
151 end
152
152
153 # Returns the total amount of open issues for this version.
153 # Returns the total amount of open issues for this version.
154 def open_issues_count
154 def open_issues_count
155 load_issue_counts
155 load_issue_counts
156 @open_issues_count
156 @open_issues_count
157 end
157 end
158
158
159 # Returns the total amount of closed issues for this version.
159 # Returns the total amount of closed issues for this version.
160 def closed_issues_count
160 def closed_issues_count
161 load_issue_counts
161 load_issue_counts
162 @closed_issues_count
162 @closed_issues_count
163 end
163 end
164
164
165 def wiki_page
165 def wiki_page
166 if project.wiki && !wiki_page_title.blank?
166 if project.wiki && !wiki_page_title.blank?
167 @wiki_page ||= project.wiki.find_page(wiki_page_title)
167 @wiki_page ||= project.wiki.find_page(wiki_page_title)
168 end
168 end
169 @wiki_page
169 @wiki_page
170 end
170 end
171
171
172 def to_s; name end
172 def to_s; name end
173
173
174 def to_s_with_project
174 def to_s_with_project
175 "#{project} - #{name}"
175 "#{project} - #{name}"
176 end
176 end
177
177
178 # Versions are sorted by effective_date and name
178 # Versions are sorted by effective_date and name
179 # Those with no effective_date are at the end, sorted by name
179 # Those with no effective_date are at the end, sorted by name
180 def <=>(version)
180 def <=>(version)
181 if self.effective_date
181 if self.effective_date
182 if version.effective_date
182 if version.effective_date
183 if self.effective_date == version.effective_date
183 if self.effective_date == version.effective_date
184 name == version.name ? id <=> version.id : name <=> version.name
184 name == version.name ? id <=> version.id : name <=> version.name
185 else
185 else
186 self.effective_date <=> version.effective_date
186 self.effective_date <=> version.effective_date
187 end
187 end
188 else
188 else
189 -1
189 -1
190 end
190 end
191 else
191 else
192 if version.effective_date
192 if version.effective_date
193 1
193 1
194 else
194 else
195 name == version.name ? id <=> version.id : name <=> version.name
195 name == version.name ? id <=> version.id : name <=> version.name
196 end
196 end
197 end
197 end
198 end
198 end
199
199
200 def css_classes
200 def css_classes
201 [
201 [
202 completed? ? 'version-completed' : 'version-incompleted',
202 completed? ? 'version-completed' : 'version-incompleted',
203 "version-#{status}"
203 "version-#{status}"
204 ].join(' ')
204 ].join(' ')
205 end
205 end
206
206
207 def self.fields_for_order_statement(table=nil)
207 def self.fields_for_order_statement(table=nil)
208 table ||= table_name
208 table ||= table_name
209 ["(CASE WHEN #{table}.effective_date IS NULL THEN 1 ELSE 0 END)", "#{table}.effective_date", "#{table}.name", "#{table}.id"]
209 ["(CASE WHEN #{table}.effective_date IS NULL THEN 1 ELSE 0 END)", "#{table}.effective_date", "#{table}.name", "#{table}.id"]
210 end
210 end
211
211
212 scope :sorted, lambda { order(fields_for_order_statement) }
212 scope :sorted, lambda { order(fields_for_order_statement) }
213
213
214 # Returns the sharings that +user+ can set the version to
214 # Returns the sharings that +user+ can set the version to
215 def allowed_sharings(user = User.current)
215 def allowed_sharings(user = User.current)
216 VERSION_SHARINGS.select do |s|
216 VERSION_SHARINGS.select do |s|
217 if sharing == s
217 if sharing == s
218 true
218 true
219 else
219 else
220 case s
220 case s
221 when 'system'
221 when 'system'
222 # Only admin users can set a systemwide sharing
222 # Only admin users can set a systemwide sharing
223 user.admin?
223 user.admin?
224 when 'hierarchy', 'tree'
224 when 'hierarchy', 'tree'
225 # Only users allowed to manage versions of the root project can
225 # Only users allowed to manage versions of the root project can
226 # set sharing to hierarchy or tree
226 # set sharing to hierarchy or tree
227 project.nil? || user.allowed_to?(:manage_versions, project.root)
227 project.nil? || user.allowed_to?(:manage_versions, project.root)
228 else
228 else
229 true
229 true
230 end
230 end
231 end
231 end
232 end
232 end
233 end
233 end
234
234
235 # Returns true if the version is shared, otherwise false
235 # Returns true if the version is shared, otherwise false
236 def shared?
236 def shared?
237 sharing != 'none'
237 sharing != 'none'
238 end
238 end
239
239
240 def deletable?
240 def deletable?
241 fixed_issues.empty? && !referenced_by_a_custom_field?
241 fixed_issues.empty? && !referenced_by_a_custom_field?
242 end
242 end
243
243
244 private
244 private
245
245
246 def load_issue_counts
246 def load_issue_counts
247 unless @issue_count
247 unless @issue_count
248 @open_issues_count = 0
248 @open_issues_count = 0
249 @closed_issues_count = 0
249 @closed_issues_count = 0
250 fixed_issues.group(:status).count.each do |status, count|
250 fixed_issues.group(:status).count.each do |status, count|
251 if status.is_closed?
251 if status.is_closed?
252 @closed_issues_count += count
252 @closed_issues_count += count
253 else
253 else
254 @open_issues_count += count
254 @open_issues_count += count
255 end
255 end
256 end
256 end
257 @issue_count = @open_issues_count + @closed_issues_count
257 @issue_count = @open_issues_count + @closed_issues_count
258 end
258 end
259 end
259 end
260
260
261 # Update the issue's fixed versions. Used if a version's sharing changes.
261 # Update the issue's fixed versions. Used if a version's sharing changes.
262 def update_issues_from_sharing_change
262 def update_issues_from_sharing_change
263 if sharing_changed?
263 if sharing_changed?
264 if VERSION_SHARINGS.index(sharing_was).nil? ||
264 if VERSION_SHARINGS.index(sharing_was).nil? ||
265 VERSION_SHARINGS.index(sharing).nil? ||
265 VERSION_SHARINGS.index(sharing).nil? ||
266 VERSION_SHARINGS.index(sharing_was) > VERSION_SHARINGS.index(sharing)
266 VERSION_SHARINGS.index(sharing_was) > VERSION_SHARINGS.index(sharing)
267 Issue.update_versions_from_sharing_change self
267 Issue.update_versions_from_sharing_change self
268 end
268 end
269 end
269 end
270 end
270 end
271
271
272 # Returns the average estimated time of assigned issues
272 # Returns the average estimated time of assigned issues
273 # or 1 if no issue has an estimated time
273 # or 1 if no issue has an estimated time
274 # Used to weight unestimated issues in progress calculation
274 # Used to weight unestimated issues in progress calculation
275 def estimated_average
275 def estimated_average
276 if @estimated_average.nil?
276 if @estimated_average.nil?
277 average = fixed_issues.average(:estimated_hours).to_f
277 average = fixed_issues.average(:estimated_hours).to_f
278 if average == 0
278 if average == 0
279 average = 1
279 average = 1
280 end
280 end
281 @estimated_average = average
281 @estimated_average = average
282 end
282 end
283 @estimated_average
283 @estimated_average
284 end
284 end
285
285
286 # Returns the total progress of open or closed issues. The returned percentage takes into account
286 # Returns the total progress of open or closed issues. The returned percentage takes into account
287 # the amount of estimated time set for this version.
287 # the amount of estimated time set for this version.
288 #
288 #
289 # Examples:
289 # Examples:
290 # issues_progress(true) => returns the progress percentage for open issues.
290 # issues_progress(true) => returns the progress percentage for open issues.
291 # issues_progress(false) => returns the progress percentage for closed issues.
291 # issues_progress(false) => returns the progress percentage for closed issues.
292 def issues_progress(open)
292 def issues_progress(open)
293 @issues_progress ||= {}
293 @issues_progress ||= {}
294 @issues_progress[open] ||= begin
294 @issues_progress[open] ||= begin
295 progress = 0
295 progress = 0
296 if issues_count > 0
296 if issues_count > 0
297 ratio = open ? 'done_ratio' : 100
297 ratio = open ? 'done_ratio' : 100
298
298
299 done = fixed_issues.open(open).sum("COALESCE(estimated_hours, #{estimated_average}) * #{ratio}").to_f
299 done = fixed_issues.open(open).sum("COALESCE(estimated_hours, #{estimated_average}) * #{ratio}").to_f
300 progress = done / (estimated_average * issues_count)
300 progress = done / (estimated_average * issues_count)
301 end
301 end
302 progress
302 progress
303 end
303 end
304 end
304 end
305
305
306 def referenced_by_a_custom_field?
306 def referenced_by_a_custom_field?
307 CustomValue.joins(:custom_field).
307 CustomValue.joins(:custom_field).
308 where(:value => id.to_s, :custom_fields => {:field_format => 'version'}).any?
308 where(:value => id.to_s, :custom_fields => {:field_format => 'version'}).any?
309 end
309 end
310
310
311 def nullify_projects_default_version
311 def nullify_projects_default_version
312 Project.where(:default_version_id => id).update_all(:default_version_id => nil)
312 Project.where(:default_version_id => id).update_all(:default_version_id => nil)
313 end
313 end
314 end
314 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 :start_page, 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