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