To check out this repository please hg clone the following URL, or open the URL using EasyMercurial or your preferred Mercurial client.

Statistics Download as Zip
| Branch: | Tag: | Revision:

root / app / models @ 1533:59e13100ea95

1 441:cbce1fd3b1b7 Chris
# Redmine - project management software
2 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
3 0:513646585e45 Chris
#
4
# This program is free software; you can redistribute it and/or
5
# modify it under the terms of the GNU General Public License
6
# as published by the Free Software Foundation; either version 2
7
# of the License, or (at your option) any later version.
8 441:cbce1fd3b1b7 Chris
#
9 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
# GNU General Public License for more details.
13 441:cbce1fd3b1b7 Chris
#
14 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
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.
17
18
require "digest/md5"
19 1464:261b3d9a4903 Chris
require "fileutils"
20 0:513646585e45 Chris
21
class Attachment < ActiveRecord::Base
22
  belongs_to :container, :polymorphic => true
23
  belongs_to :author, :class_name => "User", :foreign_key => "author_id"
24 441:cbce1fd3b1b7 Chris
25 1115:433d4f72a19b Chris
  validates_presence_of :filename, :author
26 0:513646585e45 Chris
  validates_length_of :filename, :maximum => 255
27
  validates_length_of :disk_filename, :maximum => 255
28 1115:433d4f72a19b Chris
  validates_length_of :description, :maximum => 255
29 909:cbb26bc654de Chris
  validate :validate_max_file_size
30 0:513646585e45 Chris
31
  acts_as_event :title => :filename,
32
                :url => Proc.new {|o| {:controller => 'attachments', :action => 'download', :id => o.id, :filename => o.filename}}
33
34
  acts_as_activity_provider :type => 'files',
35
                            :permission => :view_files,
36
                            :author_key => :author_id,
37 441:cbce1fd3b1b7 Chris
                            :find_options => {:select => "#{Attachment.table_name}.*",
38 0:513646585e45 Chris
                                              :joins => "LEFT JOIN #{Version.table_name} ON #{Attachment.table_name}.container_type='Version' AND #{Version.table_name}.id = #{Attachment.table_name}.container_id " +
39
                                                        "LEFT JOIN #{Project.table_name} ON #{Version.table_name}.project_id = #{Project.table_name}.id OR ( #{Attachment.table_name}.container_type='Project' AND #{Attachment.table_name}.container_id = #{Project.table_name}.id )"}
40 441:cbce1fd3b1b7 Chris
41 0:513646585e45 Chris
  acts_as_activity_provider :type => 'documents',
42
                            :permission => :view_documents,
43
                            :author_key => :author_id,
44 441:cbce1fd3b1b7 Chris
                            :find_options => {:select => "#{Attachment.table_name}.*",
45 0:513646585e45 Chris
                                              :joins => "LEFT JOIN #{Document.table_name} ON #{Attachment.table_name}.container_type='Document' AND #{Document.table_name}.id = #{Attachment.table_name}.container_id " +
46
                                                        "LEFT JOIN #{Project.table_name} ON #{Document.table_name}.project_id = #{Project.table_name}.id"}
47
48
  cattr_accessor :storage_path
49 1115:433d4f72a19b Chris
  @@storage_path = Redmine::Configuration['attachments_storage_path'] || File.join(Rails.root, "files")
50
51
  cattr_accessor :thumbnails_storage_path
52
  @@thumbnails_storage_path = File.join(Rails.root, "tmp", "thumbnails")
53 441:cbce1fd3b1b7 Chris
54 909:cbb26bc654de Chris
  before_save :files_to_final_location
55
  after_destroy :delete_from_disk
56
57 1115:433d4f72a19b Chris
  # Returns an unsaved copy of the attachment
58
  def copy(attributes=nil)
59
    copy = self.class.new
60
    copy.attributes = self.attributes.dup.except("id", "downloads")
61
    copy.attributes = attributes if attributes
62
    copy
63
  end
64
65 909:cbb26bc654de Chris
  def validate_max_file_size
66 1115:433d4f72a19b Chris
    if @temp_file && self.filesize > Setting.attachment_max_size.to_i.kilobytes
67
      errors.add(:base, l(:error_attachment_too_big, :max_size => Setting.attachment_max_size.to_i.kilobytes))
68 0:513646585e45 Chris
    end
69
  end
70
71
  def file=(incoming_file)
72
    unless incoming_file.nil?
73
      @temp_file = incoming_file
74
      if @temp_file.size > 0
75 1115:433d4f72a19b Chris
        if @temp_file.respond_to?(:original_filename)
76
          self.filename = @temp_file.original_filename
77
          self.filename.force_encoding("UTF-8") if filename.respond_to?(:force_encoding)
78
        end
79
        if @temp_file.respond_to?(:content_type)
80
          self.content_type = @temp_file.content_type.to_s.chomp
81
        end
82
        if content_type.blank? && filename.present?
83 0:513646585e45 Chris
          self.content_type = Redmine::MimeType.of(filename)
84
        end
85
        self.filesize = @temp_file.size
86
      end
87
    end
88
  end
89 1115:433d4f72a19b Chris
90 0:513646585e45 Chris
  def file
91
    nil
92
  end
93
94 1115:433d4f72a19b Chris
  def filename=(arg)
95
    write_attribute :filename, sanitize_filename(arg.to_s)
96
    filename
97
  end
98
99 0:513646585e45 Chris
  # Copies the temporary file to its final location
100
  # and computes its MD5 hash
101 909:cbb26bc654de Chris
  def files_to_final_location
102 0:513646585e45 Chris
    if @temp_file && (@temp_file.size > 0)
103 1464:261b3d9a4903 Chris
      self.disk_directory = target_directory
104
      self.disk_filename = Attachment.disk_filename(filename, disk_directory)
105
      logger.info("Saving attachment '#{self.diskfile}' (#{@temp_file.size} bytes)") if logger
106
      path = File.dirname(diskfile)
107
      unless File.directory?(path)
108
        FileUtils.mkdir_p(path)
109
      end
110 0:513646585e45 Chris
      md5 = Digest::MD5.new
111 441:cbce1fd3b1b7 Chris
      File.open(diskfile, "wb") do |f|
112 1115:433d4f72a19b Chris
        if @temp_file.respond_to?(:read)
113
          buffer = ""
114
          while (buffer = @temp_file.read(8192))
115
            f.write(buffer)
116
            md5.update(buffer)
117
          end
118
        else
119
          f.write(@temp_file)
120
          md5.update(@temp_file)
121 0:513646585e45 Chris
        end
122
      end
123
      self.digest = md5.hexdigest
124
    end
125 909:cbb26bc654de Chris
    @temp_file = nil
126 0:513646585e45 Chris
    # Don't save the content type if it's longer than the authorized length
127
    if self.content_type && self.content_type.length > 255
128
      self.content_type = nil
129
    end
130
  end
131
132 1115:433d4f72a19b Chris
  # Deletes the file from the file system if it's not referenced by other attachments
133 909:cbb26bc654de Chris
  def delete_from_disk
134 1115:433d4f72a19b Chris
    if Attachment.where("disk_filename = ? AND id <> ?", disk_filename, id).empty?
135
      delete_from_disk!
136
    end
137 0:513646585e45 Chris
  end
138
139
  # Returns file's location on disk
140
  def diskfile
141 1464:261b3d9a4903 Chris
    File.join(self.class.storage_path, disk_directory.to_s, disk_filename.to_s)
142 1115:433d4f72a19b Chris
  end
143
144
  def title
145
    title = filename.to_s
146
    if description.present?
147
      title << " (#{description})"
148
    end
149
    title
150 0:513646585e45 Chris
  end
151 441:cbce1fd3b1b7 Chris
152 0:513646585e45 Chris
  def increment_download
153
    increment!(:downloads)
154
  end
155
156
  def project
157 1115:433d4f72a19b Chris
    container.try(:project)
158 0:513646585e45 Chris
  end
159 441:cbce1fd3b1b7 Chris
160 0:513646585e45 Chris
  def visible?(user=User.current)
161 1464:261b3d9a4903 Chris
    if container_id
162
      container && container.attachments_visible?(user)
163
    else
164
      author == user
165
    end
166 0:513646585e45 Chris
  end
167 441:cbce1fd3b1b7 Chris
168 0:513646585e45 Chris
  def deletable?(user=User.current)
169 1464:261b3d9a4903 Chris
    if container_id
170
      container && container.attachments_deletable?(user)
171
    else
172
      author == user
173
    end
174 0:513646585e45 Chris
  end
175 441:cbce1fd3b1b7 Chris
176 0:513646585e45 Chris
  def image?
177 1115:433d4f72a19b Chris
    !!(self.filename =~ /\.(bmp|gif|jpg|jpe|jpeg|png)$/i)
178
  end
179
180
  def thumbnailable?
181
    image?
182
  end
183
184
  # Returns the full path the attachment thumbnail, or nil
185
  # if the thumbnail cannot be generated.
186
  def thumbnail(options={})
187
    if thumbnailable? && readable?
188
      size = options[:size].to_i
189
      if size > 0
190
        # Limit the number of thumbnails per image
191
        size = (size / 50) * 50
192
        # Maximum thumbnail size
193
        size = 800 if size > 800
194
      else
195
        size = Setting.thumbnails_size.to_i
196
      end
197
      size = 100 unless size > 0
198
      target = File.join(self.class.thumbnails_storage_path, "#{id}_#{digest}_#{size}.thumb")
199
200
      begin
201
        Redmine::Thumbnail.generate(self.diskfile, target, size)
202
      rescue => e
203
        logger.error "An error occured while generating thumbnail for #{disk_filename} to #{target}\nException was: #{e.message}" if logger
204
        return nil
205
      end
206
    end
207
  end
208
209
  # Deletes all thumbnails
210
  def self.clear_thumbnails
211
    Dir.glob(File.join(thumbnails_storage_path, "*.thumb")).each do |file|
212
      File.delete file
213
    end
214 0:513646585e45 Chris
  end
215 441:cbce1fd3b1b7 Chris
216 0:513646585e45 Chris
  def is_text?
217
    Redmine::MimeType.is_type?('text', filename)
218
  end
219 441:cbce1fd3b1b7 Chris
220 0:513646585e45 Chris
  def is_diff?
221
    self.filename =~ /\.(patch|diff)$/i
222
  end
223 441:cbce1fd3b1b7 Chris
224 0:513646585e45 Chris
  # Returns true if the file is readable
225
  def readable?
226
    File.readable?(diskfile)
227
  end
228
229 1115:433d4f72a19b Chris
  # Returns the attachment token
230
  def token
231
    "#{id}.#{digest}"
232
  end
233
234
  # Finds an attachment that matches the given token and that has no container
235
  def self.find_by_token(token)
236
    if token.to_s =~ /^(\d+)\.([0-9a-f]+)$/
237
      attachment_id, attachment_digest = $1, $2
238
      attachment = Attachment.where(:id => attachment_id, :digest => attachment_digest).first
239
      if attachment && attachment.container.nil?
240
        attachment
241
      end
242
    end
243
  end
244
245 0:513646585e45 Chris
  # Bulk attaches a set of files to an object
246
  #
247
  # Returns a Hash of the results:
248
  # :files => array of the attached files
249
  # :unsaved => array of the files that could not be attached
250
  def self.attach_files(obj, attachments)
251 1115:433d4f72a19b Chris
    result = obj.save_attachments(attachments, User.current)
252
    obj.attach_saved_attachments
253
    result
254 0:513646585e45 Chris
  end
255 441:cbce1fd3b1b7 Chris
256 909:cbb26bc654de Chris
  def self.latest_attach(attachments, filename)
257 1115:433d4f72a19b Chris
    attachments.sort_by(&:created_on).reverse.detect {
258 909:cbb26bc654de Chris
      |att| att.filename.downcase == filename.downcase
259
     }
260
  end
261
262 1115:433d4f72a19b Chris
  def self.prune(age=1.day)
263
    Attachment.where("created_on < ? AND (container_type IS NULL OR container_type = '')", Time.now - age).destroy_all
264
  end
265
266 1464:261b3d9a4903 Chris
  # Moves an existing attachment to its target directory
267
  def move_to_target_directory!
268
    return unless !new_record? & readable?
269
270
    src = diskfile
271
    self.disk_directory = target_directory
272
    dest = diskfile
273
274
    return if src == dest
275
276
    if !FileUtils.mkdir_p(File.dirname(dest))
277
      logger.error "Could not create directory #{File.dirname(dest)}" if logger
278
      return
279
    end
280
281
    if !FileUtils.mv(src, dest)
282
      logger.error "Could not move attachment from #{src} to #{dest}" if logger
283
      return
284
    end
285
286
    update_column :disk_directory, disk_directory
287
  end
288
289
  # Moves existing attachments that are stored at the root of the files
290
  # directory (ie. created before Redmine 2.3) to their target subdirectories
291
  def self.move_from_root_to_target_directory
292
    Attachment.where("disk_directory IS NULL OR disk_directory = ''").find_each do |attachment|
293
      attachment.move_to_target_directory!
294
    end
295
  end
296
297 1115:433d4f72a19b Chris
  private
298
299
  # Physically deletes the file from the file system
300
  def delete_from_disk!
301
    if disk_filename.present? && File.exist?(diskfile)
302
      File.delete(diskfile)
303
    end
304
  end
305
306 0:513646585e45 Chris
  def sanitize_filename(value)
307
    # get only the filename, not the whole path
308 1464:261b3d9a4903 Chris
    just_filename = value.gsub(/\A.*(\\|\/)/m, '')
309 0:513646585e45 Chris
310 909:cbb26bc654de Chris
    # Finally, replace invalid characters with underscore
311 1464:261b3d9a4903 Chris
    @filename = just_filename.gsub(/[\/\?\%\*\:\|\"\'<>\n\r]+/, '_')
312 0:513646585e45 Chris
  end
313 441:cbce1fd3b1b7 Chris
314 1464:261b3d9a4903 Chris
  # Returns the subdirectory in which the attachment will be saved
315
  def target_directory
316
    time = created_on || DateTime.now
317
    time.strftime("%Y/%m")
318
  end
319
320
  # Returns an ASCII or hashed filename that do not
321
  # exists yet in the given subdirectory
322
  def self.disk_filename(filename, directory=nil)
323 0:513646585e45 Chris
    timestamp = DateTime.now.strftime("%y%m%d%H%M%S")
324
    ascii = ''
325
    if filename =~ %r{^[a-zA-Z0-9_\.\-]*$}
326
      ascii = filename
327
    else
328
      ascii = Digest::MD5.hexdigest(filename)
329
      # keep the extension if any
330
      ascii << $1 if filename =~ %r{(\.[a-zA-Z0-9]+)$}
331
    end
332 1464:261b3d9a4903 Chris
    while File.exist?(File.join(storage_path, directory.to_s, "#{timestamp}_#{ascii}"))
333 0:513646585e45 Chris
      timestamp.succ!
334
    end
335
    "#{timestamp}_#{ascii}"
336
  end
337
end
338 909:cbb26bc654de Chris
# Redmine - project management software
339 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
340 0:513646585e45 Chris
#
341
# This program is free software; you can redistribute it and/or
342
# modify it under the terms of the GNU General Public License
343
# as published by the Free Software Foundation; either version 2
344
# of the License, or (at your option) any later version.
345 909:cbb26bc654de Chris
#
346 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
347
# but WITHOUT ANY WARRANTY; without even the implied warranty of
348
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
349
# GNU General Public License for more details.
350 909:cbb26bc654de Chris
#
351 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
352
# along with this program; if not, write to the Free Software
353
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
354
355 1115:433d4f72a19b Chris
# Generic exception for when the AuthSource can not be reached
356
# (eg. can not connect to the LDAP)
357
class AuthSourceException < Exception; end
358
class AuthSourceTimeoutException < AuthSourceException; end
359
360 0:513646585e45 Chris
class AuthSource < ActiveRecord::Base
361 1115:433d4f72a19b Chris
  include Redmine::SubclassFactory
362 245:051f544170fe Chris
  include Redmine::Ciphering
363 909:cbb26bc654de Chris
364 0:513646585e45 Chris
  has_many :users
365 909:cbb26bc654de Chris
366 0:513646585e45 Chris
  validates_presence_of :name
367
  validates_uniqueness_of :name
368
  validates_length_of :name, :maximum => 60
369
370
  def authenticate(login, password)
371
  end
372 909:cbb26bc654de Chris
373 0:513646585e45 Chris
  def test_connection
374
  end
375 909:cbb26bc654de Chris
376 0:513646585e45 Chris
  def auth_method_name
377
    "Abstract"
378
  end
379 909:cbb26bc654de Chris
380 245:051f544170fe Chris
  def account_password
381
    read_ciphered_attribute(:account_password)
382
  end
383 909:cbb26bc654de Chris
384 245:051f544170fe Chris
  def account_password=(arg)
385
    write_ciphered_attribute(:account_password, arg)
386
  end
387 0:513646585e45 Chris
388 1464:261b3d9a4903 Chris
  def searchable?
389
    false
390
  end
391
392
  def self.search(q)
393
    results = []
394
    AuthSource.all.each do |source|
395
      begin
396
        if source.searchable?
397
          results += source.search(q)
398
        end
399
      rescue AuthSourceException => e
400
        logger.error "Error while searching users in #{source.name}: #{e.message}"
401
      end
402
    end
403
    results
404
  end
405
406 0:513646585e45 Chris
  def allow_password_changes?
407
    self.class.allow_password_changes?
408
  end
409
410
  # Does this auth source backend allow password changes?
411
  def self.allow_password_changes?
412
    false
413
  end
414
415
  # Try to authenticate a user not yet registered against available sources
416
  def self.authenticate(login, password)
417 1517:dffacf8a6908 Chris
    AuthSource.where(:onthefly_register => true).each do |source|
418 0:513646585e45 Chris
      begin
419
        logger.debug "Authenticating '#{login}' against '#{source.name}'" if logger && logger.debug?
420
        attrs = source.authenticate(login, password)
421
      rescue => e
422
        logger.error "Error during authentication: #{e.message}"
423
        attrs = nil
424
      end
425
      return attrs if attrs
426
    end
427
    return nil
428
  end
429
end
430 909:cbb26bc654de Chris
# Redmine - project management software
431 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
432 0:513646585e45 Chris
#
433
# This program is free software; you can redistribute it and/or
434
# modify it under the terms of the GNU General Public License
435
# as published by the Free Software Foundation; either version 2
436
# of the License, or (at your option) any later version.
437 909:cbb26bc654de Chris
#
438 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
439
# but WITHOUT ANY WARRANTY; without even the implied warranty of
440
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
441
# GNU General Public License for more details.
442 909:cbb26bc654de Chris
#
443 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
444
# along with this program; if not, write to the Free Software
445
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
446
447
require 'net/ldap'
448 1115:433d4f72a19b Chris
require 'net/ldap/dn'
449
require 'timeout'
450 0:513646585e45 Chris
451 909:cbb26bc654de Chris
class AuthSourceLdap < AuthSource
452 0:513646585e45 Chris
  validates_presence_of :host, :port, :attr_login
453 245:051f544170fe Chris
  validates_length_of :name, :host, :maximum => 60, :allow_nil => true
454 1115:433d4f72a19b Chris
  validates_length_of :account, :account_password, :base_dn, :filter, :maximum => 255, :allow_blank => true
455 0:513646585e45 Chris
  validates_length_of :attr_login, :attr_firstname, :attr_lastname, :attr_mail, :maximum => 30, :allow_nil => true
456
  validates_numericality_of :port, :only_integer => true
457 1115:433d4f72a19b Chris
  validates_numericality_of :timeout, :only_integer => true, :allow_blank => true
458
  validate :validate_filter
459 909:cbb26bc654de Chris
460 0:513646585e45 Chris
  before_validation :strip_ldap_attributes
461 909:cbb26bc654de Chris
462 1115:433d4f72a19b Chris
  def initialize(attributes=nil, *args)
463
    super
464 0:513646585e45 Chris
    self.port = 389 if self.port == 0
465
  end
466 909:cbb26bc654de Chris
467 0:513646585e45 Chris
  def authenticate(login, password)
468
    return nil if login.blank? || password.blank?
469 909:cbb26bc654de Chris
470 1115:433d4f72a19b Chris
    with_timeout do
471
      attrs = get_user_dn(login, password)
472
      if attrs && attrs[:dn] && authenticate_dn(attrs[:dn], password)
473
        logger.debug "Authentication successful for '#{login}'" if logger && logger.debug?
474
        return attrs.except(:dn)
475
      end
476 0:513646585e45 Chris
    end
477 1115:433d4f72a19b Chris
  rescue Net::LDAP::LdapError => e
478
    raise AuthSourceException.new(e.message)
479 0:513646585e45 Chris
  end
480
481
  # test the connection to the LDAP
482
  def test_connection
483 1115:433d4f72a19b Chris
    with_timeout do
484
      ldap_con = initialize_ldap_con(self.account, self.account_password)
485
      ldap_con.open { }
486
    end
487
  rescue Net::LDAP::LdapError => e
488
    raise AuthSourceException.new(e.message)
489 0:513646585e45 Chris
  end
490 909:cbb26bc654de Chris
491 0:513646585e45 Chris
  def auth_method_name
492
    "LDAP"
493
  end
494 909:cbb26bc654de Chris
495 1464:261b3d9a4903 Chris
  # Returns true if this source can be searched for users
496
  def searchable?
497
    !account.to_s.include?("$login") && %w(login firstname lastname mail).all? {|a| send("attr_#{a}?")}
498
  end
499
500
  # Searches the source for users and returns an array of results
501
  def search(q)
502
    q = q.to_s.strip
503
    return [] unless searchable? && q.present?
504
505
    results = []
506
    search_filter = base_filter & Net::LDAP::Filter.begins(self.attr_login, q)
507
    ldap_con = initialize_ldap_con(self.account, self.account_password)
508
    ldap_con.search(:base => self.base_dn,
509
                    :filter => search_filter,
510
                    :attributes => ['dn', self.attr_login, self.attr_firstname, self.attr_lastname, self.attr_mail],
511
                    :size => 10) do |entry|
512
      attrs = get_user_attributes_from_ldap_entry(entry)
513
      attrs[:login] = AuthSourceLdap.get_attr(entry, self.attr_login)
514
      results << attrs
515
    end
516
    results
517
  rescue Net::LDAP::LdapError => e
518
    raise AuthSourceException.new(e.message)
519
  end
520
521 0:513646585e45 Chris
  private
522 909:cbb26bc654de Chris
523 1115:433d4f72a19b Chris
  def with_timeout(&block)
524
    timeout = self.timeout
525
    timeout = 20 unless timeout && timeout > 0
526
    Timeout.timeout(timeout) do
527
      return yield
528
    end
529
  rescue Timeout::Error => e
530
    raise AuthSourceTimeoutException.new(e.message)
531
  end
532
533
  def ldap_filter
534
    if filter.present?
535
      Net::LDAP::Filter.construct(filter)
536
    end
537
  rescue Net::LDAP::LdapError
538
    nil
539
  end
540
541 1464:261b3d9a4903 Chris
  def base_filter
542
    filter = Net::LDAP::Filter.eq("objectClass", "*")
543
    if f = ldap_filter
544
      filter = filter & f
545
    end
546
    filter
547
  end
548
549 1115:433d4f72a19b Chris
  def validate_filter
550
    if filter.present? && ldap_filter.nil?
551
      errors.add(:filter, :invalid)
552
    end
553
  end
554
555 0:513646585e45 Chris
  def strip_ldap_attributes
556
    [:attr_login, :attr_firstname, :attr_lastname, :attr_mail].each do |attr|
557
      write_attribute(attr, read_attribute(attr).strip) unless read_attribute(attr).nil?
558
    end
559
  end
560 909:cbb26bc654de Chris
561 0:513646585e45 Chris
  def initialize_ldap_con(ldap_user, ldap_password)
562
    options = { :host => self.host,
563
                :port => self.port,
564
                :encryption => (self.tls ? :simple_tls : nil)
565
              }
566
    options.merge!(:auth => { :method => :simple, :username => ldap_user, :password => ldap_password }) unless ldap_user.blank? && ldap_password.blank?
567
    Net::LDAP.new options
568
  end
569
570
  def get_user_attributes_from_ldap_entry(entry)
571
    {
572
     :dn => entry.dn,
573
     :firstname => AuthSourceLdap.get_attr(entry, self.attr_firstname),
574
     :lastname => AuthSourceLdap.get_attr(entry, self.attr_lastname),
575
     :mail => AuthSourceLdap.get_attr(entry, self.attr_mail),
576
     :auth_source_id => self.id
577
    }
578
  end
579
580
  # Return the attributes needed for the LDAP search.  It will only
581
  # include the user attributes if on-the-fly registration is enabled
582
  def search_attributes
583
    if onthefly_register?
584
      ['dn', self.attr_firstname, self.attr_lastname, self.attr_mail]
585
    else
586
      ['dn']
587
    end
588
  end
589
590
  # Check if a DN (user record) authenticates with the password
591
  def authenticate_dn(dn, password)
592
    if dn.present? && password.present?
593
      initialize_ldap_con(dn, password).bind
594
    end
595
  end
596
597
  # Get the user's dn and any attributes for them, given their login
598 1115:433d4f72a19b Chris
  def get_user_dn(login, password)
599
    ldap_con = nil
600
    if self.account && self.account.include?("$login")
601
      ldap_con = initialize_ldap_con(self.account.sub("$login", Net::LDAP::DN.escape(login)), password)
602
    else
603
      ldap_con = initialize_ldap_con(self.account, self.account_password)
604
    end
605 0:513646585e45 Chris
    attrs = {}
606 1464:261b3d9a4903 Chris
    search_filter = base_filter & Net::LDAP::Filter.eq(self.attr_login, login)
607 1115:433d4f72a19b Chris
608 909:cbb26bc654de Chris
    ldap_con.search( :base => self.base_dn,
609 1115:433d4f72a19b Chris
                     :filter => search_filter,
610 0:513646585e45 Chris
                     :attributes=> search_attributes) do |entry|
611
612
      if onthefly_register?
613
        attrs = get_user_attributes_from_ldap_entry(entry)
614
      else
615
        attrs = {:dn => entry.dn}
616
      end
617
618
      logger.debug "DN found for #{login}: #{attrs[:dn]}" if logger && logger.debug?
619
    end
620
621
    attrs
622
  end
623 909:cbb26bc654de Chris
624 0:513646585e45 Chris
  def self.get_attr(entry, attr_name)
625
    if !attr_name.blank?
626
      entry[attr_name].is_a?(Array) ? entry[attr_name].first : entry[attr_name]
627
    end
628
  end
629
end
630 909:cbb26bc654de Chris
# Redmine - project management software
631 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
632 0:513646585e45 Chris
#
633
# This program is free software; you can redistribute it and/or
634
# modify it under the terms of the GNU General Public License
635
# as published by the Free Software Foundation; either version 2
636
# of the License, or (at your option) any later version.
637 909:cbb26bc654de Chris
#
638 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
639
# but WITHOUT ANY WARRANTY; without even the implied warranty of
640
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
641
# GNU General Public License for more details.
642 909:cbb26bc654de Chris
#
643 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
644
# along with this program; if not, write to the Free Software
645
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
646
647
class Board < ActiveRecord::Base
648 929:5f33065ddc4b Chris
  include Redmine::SafeAttributes
649 0:513646585e45 Chris
  belongs_to :project
650
  has_many :topics, :class_name => 'Message', :conditions => "#{Message.table_name}.parent_id IS NULL", :order => "#{Message.table_name}.created_on DESC"
651 119:8661b858af72 Chris
  has_many :messages, :dependent => :destroy, :order => "#{Message.table_name}.created_on DESC"
652 0:513646585e45 Chris
  belongs_to :last_message, :class_name => 'Message', :foreign_key => :last_message_id
653 1115:433d4f72a19b Chris
  acts_as_tree :dependent => :nullify
654
  acts_as_list :scope => '(project_id = #{project_id} AND parent_id #{parent_id ? "= #{parent_id}" : "IS NULL"})'
655 0:513646585e45 Chris
  acts_as_watchable
656 909:cbb26bc654de Chris
657 0:513646585e45 Chris
  validates_presence_of :name, :description
658
  validates_length_of :name, :maximum => 30
659
  validates_length_of :description, :maximum => 255
660 1115:433d4f72a19b Chris
  validate :validate_board
661 909:cbb26bc654de Chris
662 1464:261b3d9a4903 Chris
  scope :visible, lambda {|*args|
663
    includes(:project).where(Project.allowed_to_condition(args.shift || User.current, :view_messages, *args))
664
  }
665 909:cbb26bc654de Chris
666 1115:433d4f72a19b Chris
  safe_attributes 'name', 'description', 'parent_id', 'move_to'
667 929:5f33065ddc4b Chris
668 0:513646585e45 Chris
  def visible?(user=User.current)
669
    !user.nil? && user.allowed_to?(:view_messages, project)
670
  end
671 909:cbb26bc654de Chris
672 1115:433d4f72a19b Chris
  def reload(*args)
673
    @valid_parents = nil
674
    super
675
  end
676
677 0:513646585e45 Chris
  def to_s
678
    name
679
  end
680 909:cbb26bc654de Chris
681 1115:433d4f72a19b Chris
  def valid_parents
682
    @valid_parents ||= project.boards - self_and_descendants
683
  end
684
685 0:513646585e45 Chris
  def reset_counters!
686
    self.class.reset_counters!(id)
687
  end
688 909:cbb26bc654de Chris
689 0:513646585e45 Chris
  # Updates topics_count, messages_count and last_message_id attributes for +board_id+
690
  def self.reset_counters!(board_id)
691
    board_id = board_id.to_i
692 1517:dffacf8a6908 Chris
    where(["id = ?", board_id]).
693
      update_all("topics_count = (SELECT COUNT(*) FROM #{Message.table_name} WHERE board_id=#{board_id} AND parent_id IS NULL)," +
694 0:513646585e45 Chris
               " messages_count = (SELECT COUNT(*) FROM #{Message.table_name} WHERE board_id=#{board_id})," +
695 1517:dffacf8a6908 Chris
               " last_message_id = (SELECT MAX(id) FROM #{Message.table_name} WHERE board_id=#{board_id})")
696 0:513646585e45 Chris
  end
697 1115:433d4f72a19b Chris
698
  def self.board_tree(boards, parent_id=nil, level=0)
699
    tree = []
700
    boards.select {|board| board.parent_id == parent_id}.sort_by(&:position).each do |board|
701
      tree << [board, level]
702
      tree += board_tree(boards, board.id, level+1)
703
    end
704
    if block_given?
705
      tree.each do |board, level|
706
        yield board, level
707
      end
708
    end
709
    tree
710
  end
711
712
  protected
713
714
  def validate_board
715
    if parent_id && parent_id_changed?
716
      errors.add(:parent_id, :invalid) unless valid_parents.include?(parent)
717
    end
718
  end
719 0:513646585e45 Chris
end
720 441:cbce1fd3b1b7 Chris
# Redmine - project management software
721 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
722 0:513646585e45 Chris
#
723
# This program is free software; you can redistribute it and/or
724
# modify it under the terms of the GNU General Public License
725
# as published by the Free Software Foundation; either version 2
726
# of the License, or (at your option) any later version.
727 441:cbce1fd3b1b7 Chris
#
728 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
729
# but WITHOUT ANY WARRANTY; without even the implied warranty of
730
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
731
# GNU General Public License for more details.
732 441:cbce1fd3b1b7 Chris
#
733 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
734
# along with this program; if not, write to the Free Software
735
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
736
737
class Change < ActiveRecord::Base
738
  belongs_to :changeset
739 441:cbce1fd3b1b7 Chris
740 0:513646585e45 Chris
  validates_presence_of :changeset_id, :action, :path
741 1:cca12e1c1fd4 Chris
  before_save :init_path
742 909:cbb26bc654de Chris
  before_validation :replace_invalid_utf8_of_path
743 441:cbce1fd3b1b7 Chris
744 0:513646585e45 Chris
  def relative_path
745
    changeset.repository.relative_path(path)
746
  end
747 441:cbce1fd3b1b7 Chris
748 909:cbb26bc654de Chris
  def replace_invalid_utf8_of_path
749 441:cbce1fd3b1b7 Chris
    self.path      = Redmine::CodesetUtil.replace_invalid_utf8(self.path)
750
    self.from_path = Redmine::CodesetUtil.replace_invalid_utf8(self.from_path)
751
  end
752
753 1:cca12e1c1fd4 Chris
  def init_path
754
    self.path ||= ""
755 0:513646585e45 Chris
  end
756
end
757
# Redmine - project management software
758 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
759 0:513646585e45 Chris
#
760
# This program is free software; you can redistribute it and/or
761
# modify it under the terms of the GNU General Public License
762
# as published by the Free Software Foundation; either version 2
763
# of the License, or (at your option) any later version.
764 441:cbce1fd3b1b7 Chris
#
765 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
766
# but WITHOUT ANY WARRANTY; without even the implied warranty of
767
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
768
# GNU General Public License for more details.
769 441:cbce1fd3b1b7 Chris
#
770 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
771
# along with this program; if not, write to the Free Software
772
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
773
774
class Changeset < ActiveRecord::Base
775
  belongs_to :repository
776
  belongs_to :user
777 1115:433d4f72a19b Chris
  has_many :filechanges, :class_name => 'Change', :dependent => :delete_all
778 0:513646585e45 Chris
  has_and_belongs_to_many :issues
779 909:cbb26bc654de Chris
  has_and_belongs_to_many :parents,
780
                          :class_name => "Changeset",
781
                          :join_table => "#{table_name_prefix}changeset_parents#{table_name_suffix}",
782
                          :association_foreign_key => 'parent_id', :foreign_key => 'changeset_id'
783
  has_and_belongs_to_many :children,
784
                          :class_name => "Changeset",
785
                          :join_table => "#{table_name_prefix}changeset_parents#{table_name_suffix}",
786
                          :association_foreign_key => 'changeset_id', :foreign_key => 'parent_id'
787 0:513646585e45 Chris
788 1115:433d4f72a19b Chris
  acts_as_event :title => Proc.new {|o| o.title},
789 0:513646585e45 Chris
                :description => :long_comments,
790
                :datetime => :committed_on,
791 1115:433d4f72a19b Chris
                :url => Proc.new {|o| {:controller => 'repositories', :action => 'revision', :id => o.repository.project, :repository_id => o.repository.identifier_param, :rev => o.identifier}}
792 441:cbce1fd3b1b7 Chris
793 0:513646585e45 Chris
  acts_as_searchable :columns => 'comments',
794
                     :include => {:repository => :project},
795
                     :project_key => "#{Repository.table_name}.project_id",
796
                     :date_column => 'committed_on'
797 441:cbce1fd3b1b7 Chris
798 0:513646585e45 Chris
  acts_as_activity_provider :timestamp => "#{table_name}.committed_on",
799
                            :author_key => :user_id,
800
                            :find_options => {:include => [:user, {:repository => :project}]}
801 441:cbce1fd3b1b7 Chris
802 0:513646585e45 Chris
  validates_presence_of :repository_id, :revision, :committed_on, :commit_date
803
  validates_uniqueness_of :revision, :scope => :repository_id
804
  validates_uniqueness_of :scmid, :scope => :repository_id, :allow_nil => true
805 441:cbce1fd3b1b7 Chris
806 1464:261b3d9a4903 Chris
  scope :visible, lambda {|*args|
807
    includes(:repository => :project).where(Project.allowed_to_condition(args.shift || User.current, :view_changesets, *args))
808
  }
809 441:cbce1fd3b1b7 Chris
810 909:cbb26bc654de Chris
  after_create :scan_for_issues
811
  before_create :before_create_cs
812
813 0:513646585e45 Chris
  def revision=(r)
814
    write_attribute :revision, (r.nil? ? nil : r.to_s)
815
  end
816 119:8661b858af72 Chris
817
  # Returns the identifier of this changeset; depending on repository backends
818
  def identifier
819
    if repository.class.respond_to? :changeset_identifier
820
      repository.class.changeset_identifier self
821
    else
822
      revision.to_s
823
    end
824
  end
825 0:513646585e45 Chris
826
  def committed_on=(date)
827
    self.commit_date = date
828
    super
829
  end
830 119:8661b858af72 Chris
831
  # Returns the readable identifier
832
  def format_identifier
833
    if repository.class.respond_to? :format_changeset_identifier
834
      repository.class.format_changeset_identifier self
835
    else
836
      identifier
837
    end
838
  end
839 441:cbce1fd3b1b7 Chris
840 0:513646585e45 Chris
  def project
841
    repository.project
842
  end
843 441:cbce1fd3b1b7 Chris
844 0:513646585e45 Chris
  def author
845
    user || committer.to_s.split('<').first
846
  end
847 441:cbce1fd3b1b7 Chris
848 909:cbb26bc654de Chris
  def before_create_cs
849 245:051f544170fe Chris
    self.committer = self.class.to_utf8(self.committer, repository.repo_log_encoding)
850 441:cbce1fd3b1b7 Chris
    self.comments  = self.class.normalize_comments(
851
                       self.comments, repository.repo_log_encoding)
852 245:051f544170fe Chris
    self.user = repository.find_committer_user(self.committer)
853 0:513646585e45 Chris
  end
854 245:051f544170fe Chris
855 909:cbb26bc654de Chris
  def scan_for_issues
856 0:513646585e45 Chris
    scan_comment_for_issue_ids
857
  end
858 441:cbce1fd3b1b7 Chris
859 119:8661b858af72 Chris
  TIMELOG_RE = /
860
    (
861 245:051f544170fe Chris
    ((\d+)(h|hours?))((\d+)(m|min)?)?
862
    |
863
    ((\d+)(h|hours?|m|min))
864 119:8661b858af72 Chris
    |
865
    (\d+):(\d+)
866
    |
867 245:051f544170fe Chris
    (\d+([\.,]\d+)?)h?
868 119:8661b858af72 Chris
    )
869
    /x
870 441:cbce1fd3b1b7 Chris
871 0:513646585e45 Chris
  def scan_comment_for_issue_ids
872
    return if comments.blank?
873
    # keywords used to reference issues
874
    ref_keywords = Setting.commit_ref_keywords.downcase.split(",").collect(&:strip)
875 119:8661b858af72 Chris
    ref_keywords_any = ref_keywords.delete('*')
876 0:513646585e45 Chris
    # keywords used to fix issues
877 1464:261b3d9a4903 Chris
    fix_keywords = Setting.commit_update_keywords_array.map {|r| r['keywords']}.flatten.compact
878 441:cbce1fd3b1b7 Chris
879 0:513646585e45 Chris
    kw_regexp = (ref_keywords + fix_keywords).collect{|kw| Regexp.escape(kw)}.join("|")
880 441:cbce1fd3b1b7 Chris
881 0:513646585e45 Chris
    referenced_issues = []
882 441:cbce1fd3b1b7 Chris
883 119:8661b858af72 Chris
    comments.scan(/([\s\(\[,-]|^)((#{kw_regexp})[\s:]+)?(#\d+(\s+@#{TIMELOG_RE})?([\s,;&]+#\d+(\s+@#{TIMELOG_RE})?)*)(?=[[:punct:]]|\s|<|$)/i) do |match|
884 1464:261b3d9a4903 Chris
      action, refs = match[2].to_s.downcase, match[3]
885 119:8661b858af72 Chris
      next unless action.present? || ref_keywords_any
886 441:cbce1fd3b1b7 Chris
887 119:8661b858af72 Chris
      refs.scan(/#(\d+)(\s+@#{TIMELOG_RE})?/).each do |m|
888
        issue, hours = find_referenced_issue_by_id(m[0].to_i), m[2]
889
        if issue
890
          referenced_issues << issue
891 1464:261b3d9a4903 Chris
          # Don't update issues or log time when importing old commits
892
          unless repository.created_on && committed_on && committed_on < repository.created_on
893
            fix_issue(issue, action) if fix_keywords.include?(action)
894
            log_time(issue, hours) if hours && Setting.commit_logtime_enabled?
895
          end
896 0:513646585e45 Chris
        end
897
      end
898
    end
899 441:cbce1fd3b1b7 Chris
900 0:513646585e45 Chris
    referenced_issues.uniq!
901
    self.issues = referenced_issues unless referenced_issues.empty?
902
  end
903 441:cbce1fd3b1b7 Chris
904 0:513646585e45 Chris
  def short_comments
905
    @short_comments || split_comments.first
906
  end
907 441:cbce1fd3b1b7 Chris
908 0:513646585e45 Chris
  def long_comments
909
    @long_comments || split_comments.last
910
  end
911 119:8661b858af72 Chris
912 929:5f33065ddc4b Chris
  def text_tag(ref_project=nil)
913 1494:e248c7af89ec Chris
    repo = ""
914
    if repository && repository.identifier.present?
915
      repo = "#{repository.identifier}|"
916
    end
917 929:5f33065ddc4b Chris
    tag = if scmid?
918 1494:e248c7af89ec Chris
      "commit:#{repo}#{scmid}"
919 119:8661b858af72 Chris
    else
920 1494:e248c7af89ec Chris
      "#{repo}r#{revision}"
921 1115:433d4f72a19b Chris
    end
922 929:5f33065ddc4b Chris
    if ref_project && project && ref_project != project
923 1115:433d4f72a19b Chris
      tag = "#{project.identifier}:#{tag}"
924 929:5f33065ddc4b Chris
    end
925
    tag
926 119:8661b858af72 Chris
  end
927 441:cbce1fd3b1b7 Chris
928 1115:433d4f72a19b Chris
  # Returns the title used for the changeset in the activity/search results
929
  def title
930
    repo = (repository && repository.identifier.present?) ? " (#{repository.identifier})" : ''
931
    comm = short_comments.blank? ? '' : (': ' + short_comments)
932
    "#{l(:label_revision)} #{format_identifier}#{repo}#{comm}"
933
  end
934
935 0:513646585e45 Chris
  # Returns the previous changeset
936
  def previous
937 1115:433d4f72a19b Chris
    @previous ||= Changeset.where(["id < ? AND repository_id = ?", id, repository_id]).order('id DESC').first
938 0:513646585e45 Chris
  end
939
940
  # Returns the next changeset
941
  def next
942 1115:433d4f72a19b Chris
    @next ||= Changeset.where(["id > ? AND repository_id = ?", id, repository_id]).order('id ASC').first
943 0:513646585e45 Chris
  end
944 441:cbce1fd3b1b7 Chris
945 0:513646585e45 Chris
  # Creates a new Change from it's common parameters
946
  def create_change(change)
947 441:cbce1fd3b1b7 Chris
    Change.create(:changeset     => self,
948
                  :action        => change[:action],
949
                  :path          => change[:path],
950
                  :from_path     => change[:from_path],
951 0:513646585e45 Chris
                  :from_revision => change[:from_revision])
952
  end
953 245:051f544170fe Chris
954 119:8661b858af72 Chris
  # Finds an issue that can be referenced by the commit message
955
  def find_referenced_issue_by_id(id)
956
    return nil if id.blank?
957 1517:dffacf8a6908 Chris
    issue = Issue.includes(:project).where(:id => id.to_i).first
958 1115:433d4f72a19b Chris
    if Setting.commit_cross_project_ref?
959
      # all issues can be referenced/fixed
960
    elsif issue
961
      # issue that belong to the repository project, a subproject or a parent project only
962 441:cbce1fd3b1b7 Chris
      unless issue.project &&
963
                (project == issue.project || project.is_ancestor_of?(issue.project) ||
964
                 project.is_descendant_of?(issue.project))
965 119:8661b858af72 Chris
        issue = nil
966
      end
967
    end
968
    issue
969
  end
970 441:cbce1fd3b1b7 Chris
971 1115:433d4f72a19b Chris
  private
972
973 1464:261b3d9a4903 Chris
  # Updates the +issue+ according to +action+
974
  def fix_issue(issue, action)
975 119:8661b858af72 Chris
    # the issue may have been updated by the closure of another one (eg. duplicate)
976
    issue.reload
977
    # don't change the status is the issue is closed
978
    return if issue.status && issue.status.is_closed?
979 441:cbce1fd3b1b7 Chris
980 1464:261b3d9a4903 Chris
    journal = issue.init_journal(user || User.anonymous,
981
                                 ll(Setting.default_language,
982
                                    :text_status_changed_by_changeset,
983
                                    text_tag(issue.project)))
984
    rule = Setting.commit_update_keywords_array.detect do |rule|
985
      rule['keywords'].include?(action) &&
986
        (rule['if_tracker_id'].blank? || rule['if_tracker_id'] == issue.tracker_id.to_s)
987
    end
988
    if rule
989
      issue.assign_attributes rule.slice(*Issue.attribute_names)
990 119:8661b858af72 Chris
    end
991
    Redmine::Hook.call_hook(:model_changeset_scan_commit_for_issue_ids_pre_issue_update,
992 1464:261b3d9a4903 Chris
                            { :changeset => self, :issue => issue, :action => action })
993 119:8661b858af72 Chris
    unless issue.save
994
      logger.warn("Issue ##{issue.id} could not be saved by changeset #{id}: #{issue.errors.full_messages}") if logger
995
    end
996
    issue
997
  end
998 441:cbce1fd3b1b7 Chris
999 119:8661b858af72 Chris
  def log_time(issue, hours)
1000
    time_entry = TimeEntry.new(
1001
      :user => user,
1002
      :hours => hours,
1003
      :issue => issue,
1004
      :spent_on => commit_date,
1005 929:5f33065ddc4b Chris
      :comments => l(:text_time_logged_by_changeset, :value => text_tag(issue.project),
1006 441:cbce1fd3b1b7 Chris
                     :locale => Setting.default_language)
1007 119:8661b858af72 Chris
      )
1008
    time_entry.activity = log_time_activity unless log_time_activity.nil?
1009 441:cbce1fd3b1b7 Chris
1010 119:8661b858af72 Chris
    unless time_entry.save
1011
      logger.warn("TimeEntry could not be created by changeset #{id}: #{time_entry.errors.full_messages}") if logger
1012
    end
1013
    time_entry
1014
  end
1015 441:cbce1fd3b1b7 Chris
1016 119:8661b858af72 Chris
  def log_time_activity
1017
    if Setting.commit_logtime_activity_id.to_i > 0
1018
      TimeEntryActivity.find_by_id(Setting.commit_logtime_activity_id.to_i)
1019
    end
1020 0:513646585e45 Chris
  end
1021 441:cbce1fd3b1b7 Chris
1022 0:513646585e45 Chris
  def split_comments
1023
    comments =~ /\A(.+?)\r?\n(.*)$/m
1024
    @short_comments = $1 || comments
1025
    @long_comments = $2.to_s.strip
1026
    return @short_comments, @long_comments
1027
  end
1028
1029 245:051f544170fe Chris
  public
1030 119:8661b858af72 Chris
1031 245:051f544170fe Chris
  # Strips and reencodes a commit log before insertion into the database
1032
  def self.normalize_comments(str, encoding)
1033
    Changeset.to_utf8(str.to_s.strip, encoding)
1034
  end
1035
1036
  def self.to_utf8(str, encoding)
1037 909:cbb26bc654de Chris
    Redmine::CodesetUtil.to_utf8(str, encoding)
1038 0:513646585e45 Chris
  end
1039
end
1040 909:cbb26bc654de Chris
# Redmine - project management software
1041 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
1042 0:513646585e45 Chris
#
1043
# This program is free software; you can redistribute it and/or
1044
# modify it under the terms of the GNU General Public License
1045
# as published by the Free Software Foundation; either version 2
1046
# of the License, or (at your option) any later version.
1047 909:cbb26bc654de Chris
#
1048 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
1049
# but WITHOUT ANY WARRANTY; without even the implied warranty of
1050
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
1051
# GNU General Public License for more details.
1052 909:cbb26bc654de Chris
#
1053 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
1054
# along with this program; if not, write to the Free Software
1055
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
1056
1057
class Comment < ActiveRecord::Base
1058 929:5f33065ddc4b Chris
  include Redmine::SafeAttributes
1059 0:513646585e45 Chris
  belongs_to :commented, :polymorphic => true, :counter_cache => true
1060
  belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
1061
1062
  validates_presence_of :commented, :author, :comments
1063 929:5f33065ddc4b Chris
1064 1464:261b3d9a4903 Chris
  after_create :send_notification
1065
1066 929:5f33065ddc4b Chris
  safe_attributes 'comments'
1067 1464:261b3d9a4903 Chris
1068
  private
1069
1070
  def send_notification
1071
    mailer_method = "#{commented.class.name.underscore}_comment_added"
1072
    if Setting.notified_events.include?(mailer_method)
1073
      Mailer.send(mailer_method, self).deliver
1074
    end
1075
  end
1076 0:513646585e45 Chris
end
1077 441:cbce1fd3b1b7 Chris
# Redmine - project management software
1078 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
1079 0:513646585e45 Chris
#
1080
# This program is free software; you can redistribute it and/or
1081
# modify it under the terms of the GNU General Public License
1082
# as published by the Free Software Foundation; either version 2
1083
# of the License, or (at your option) any later version.
1084 909:cbb26bc654de Chris
#
1085 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
1086
# but WITHOUT ANY WARRANTY; without even the implied warranty of
1087
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
1088
# GNU General Public License for more details.
1089 909:cbb26bc654de Chris
#
1090 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
1091
# along with this program; if not, write to the Free Software
1092
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
1093
1094
class CustomField < ActiveRecord::Base
1095 1115:433d4f72a19b Chris
  include Redmine::SubclassFactory
1096
1097 0:513646585e45 Chris
  has_many :custom_values, :dependent => :delete_all
1098 1464:261b3d9a4903 Chris
  has_and_belongs_to_many :roles, :join_table => "#{table_name_prefix}custom_fields_roles#{table_name_suffix}", :foreign_key => "custom_field_id"
1099 0:513646585e45 Chris
  acts_as_list :scope => 'type = \'#{self.class}\''
1100
  serialize :possible_values
1101 1517:dffacf8a6908 Chris
  store :format_store
1102 909:cbb26bc654de Chris
1103 0:513646585e45 Chris
  validates_presence_of :name, :field_format
1104
  validates_uniqueness_of :name, :scope => :type
1105
  validates_length_of :name, :maximum => 30
1106 1517:dffacf8a6908 Chris
  validates_inclusion_of :field_format, :in => Proc.new { Redmine::FieldFormat.available_formats }
1107 1464:261b3d9a4903 Chris
  validate :validate_custom_field
1108 0:513646585e45 Chris
1109 1115:433d4f72a19b Chris
  before_validation :set_searchable
1110 1517:dffacf8a6908 Chris
  before_save do |field|
1111
    field.format.before_custom_field_save(field)
1112
  end
1113 1464:261b3d9a4903 Chris
  after_save :handle_multiplicity_change
1114
  after_save do |field|
1115
    if field.visible_changed? && field.visible
1116
      field.roles.clear
1117
    end
1118
  end
1119 909:cbb26bc654de Chris
1120 1464:261b3d9a4903 Chris
  scope :sorted, lambda { order("#{table_name}.position ASC") }
1121
  scope :visible, lambda {|*args|
1122
    user = args.shift || User.current
1123
    if user.admin?
1124
      # nop
1125
    elsif user.memberships.any?
1126
      where("#{table_name}.visible = ? OR #{table_name}.id IN (SELECT DISTINCT cfr.custom_field_id FROM #{Member.table_name} m" +
1127
        " INNER JOIN #{MemberRole.table_name} mr ON mr.member_id = m.id" +
1128
        " INNER JOIN #{table_name_prefix}custom_fields_roles#{table_name_suffix} cfr ON cfr.role_id = mr.role_id" +
1129
        " WHERE m.user_id = ?)",
1130
        true, user.id)
1131
    else
1132
      where(:visible => true)
1133
    end
1134
  }
1135 1115:433d4f72a19b Chris
1136 1464:261b3d9a4903 Chris
  def visible_by?(project, user=User.current)
1137
    visible? || user.admin?
1138
  end
1139 1115:433d4f72a19b Chris
1140 1517:dffacf8a6908 Chris
  def format
1141
    @format ||= Redmine::FieldFormat.find(field_format)
1142
  end
1143
1144 1115:433d4f72a19b Chris
  def field_format=(arg)
1145
    # cannot change format of a saved custom field
1146 1517:dffacf8a6908 Chris
    if new_record?
1147
      @format = nil
1148
      super
1149
    end
1150 0:513646585e45 Chris
  end
1151 909:cbb26bc654de Chris
1152 1115:433d4f72a19b Chris
  def set_searchable
1153 0:513646585e45 Chris
    # make sure these fields are not searchable
1154 1517:dffacf8a6908 Chris
    self.searchable = false unless format.class.searchable_supported
1155 1115:433d4f72a19b Chris
    # make sure only these fields can have multiple values
1156 1517:dffacf8a6908 Chris
    self.multiple = false unless format.class.multiple_supported
1157 0:513646585e45 Chris
    true
1158
  end
1159 909:cbb26bc654de Chris
1160 1115:433d4f72a19b Chris
  def validate_custom_field
1161 1517:dffacf8a6908 Chris
    format.validate_custom_field(self).each do |attribute, message|
1162
      errors.add attribute, message
1163 0:513646585e45 Chris
    end
1164 909:cbb26bc654de Chris
1165
    if regexp.present?
1166
      begin
1167
        Regexp.new(regexp)
1168
      rescue
1169
        errors.add(:regexp, :invalid)
1170
      end
1171
    end
1172
1173 1517:dffacf8a6908 Chris
    if default_value.present?
1174
      validate_field_value(default_value).each do |message|
1175
        errors.add :default_value, message
1176
      end
1177 1115:433d4f72a19b Chris
    end
1178 0:513646585e45 Chris
  end
1179 909:cbb26bc654de Chris
1180 1517:dffacf8a6908 Chris
  def possible_custom_value_options(custom_value)
1181
    format.possible_custom_value_options(custom_value)
1182
  end
1183
1184
  def possible_values_options(object=nil)
1185
    if object.is_a?(Array)
1186
      object.map {|o| format.possible_values_options(self, o)}.reduce(:&) || []
1187 441:cbce1fd3b1b7 Chris
    else
1188 1517:dffacf8a6908 Chris
      format.possible_values_options(self, object) || []
1189 441:cbce1fd3b1b7 Chris
    end
1190
  end
1191 909:cbb26bc654de Chris
1192 1517:dffacf8a6908 Chris
  def possible_values
1193
    values = read_attribute(:possible_values)
1194
    if values.is_a?(Array)
1195
      values.each do |value|
1196
        value.force_encoding('UTF-8') if value.respond_to?(:force_encoding)
1197
      end
1198
      values
1199 441:cbce1fd3b1b7 Chris
    else
1200 1517:dffacf8a6908 Chris
      []
1201 441:cbce1fd3b1b7 Chris
    end
1202
  end
1203 909:cbb26bc654de Chris
1204 0:513646585e45 Chris
  # Makes possible_values accept a multiline string
1205
  def possible_values=(arg)
1206
    if arg.is_a?(Array)
1207 1517:dffacf8a6908 Chris
      values = arg.compact.collect(&:strip).select {|v| !v.blank?}
1208
      write_attribute(:possible_values, values)
1209 0:513646585e45 Chris
    else
1210
      self.possible_values = arg.to_s.split(/[\n\r]+/)
1211
    end
1212
  end
1213 909:cbb26bc654de Chris
1214 0:513646585e45 Chris
  def cast_value(value)
1215 1517:dffacf8a6908 Chris
    format.cast_value(self, value)
1216 0:513646585e45 Chris
  end
1217 909:cbb26bc654de Chris
1218 1115:433d4f72a19b Chris
  def value_from_keyword(keyword, customized)
1219
    possible_values_options = possible_values_options(customized)
1220
    if possible_values_options.present?
1221
      keyword = keyword.to_s.downcase
1222
      if v = possible_values_options.detect {|text, id| text.downcase == keyword}
1223
        if v.is_a?(Array)
1224
          v.last
1225
        else
1226
          v
1227
        end
1228
      end
1229
    else
1230
      keyword
1231
    end
1232
  end
1233 1464:261b3d9a4903 Chris
1234 0:513646585e45 Chris
  # Returns a ORDER BY clause that can used to sort customized
1235
  # objects by their value of the custom field.
1236 1115:433d4f72a19b Chris
  # Returns nil if the custom field can not be used for sorting.
1237 0:513646585e45 Chris
  def order_statement
1238 1115:433d4f72a19b Chris
    return nil if multiple?
1239 1517:dffacf8a6908 Chris
    format.order_statement(self)
1240 0:513646585e45 Chris
  end
1241
1242 1115:433d4f72a19b Chris
  # Returns a GROUP BY clause that can used to group by custom value
1243
  # Returns nil if the custom field can not be used for grouping.
1244 1464:261b3d9a4903 Chris
  def group_statement
1245 1115:433d4f72a19b Chris
    return nil if multiple?
1246 1517:dffacf8a6908 Chris
    format.group_statement(self)
1247 1115:433d4f72a19b Chris
  end
1248
1249
  def join_for_order_statement
1250 1517:dffacf8a6908 Chris
    format.join_for_order_statement(self)
1251 1115:433d4f72a19b Chris
  end
1252
1253 1517:dffacf8a6908 Chris
  def visibility_by_project_condition(project_key=nil, user=User.current, id_column=nil)
1254 1464:261b3d9a4903 Chris
    if visible? || user.admin?
1255
      "1=1"
1256
    elsif user.anonymous?
1257
      "1=0"
1258
    else
1259
      project_key ||= "#{self.class.customized_class.table_name}.project_id"
1260 1517:dffacf8a6908 Chris
      id_column ||= id
1261 1464:261b3d9a4903 Chris
      "#{project_key} IN (SELECT DISTINCT m.project_id FROM #{Member.table_name} m" +
1262
        " INNER JOIN #{MemberRole.table_name} mr ON mr.member_id = m.id" +
1263
        " INNER JOIN #{table_name_prefix}custom_fields_roles#{table_name_suffix} cfr ON cfr.role_id = mr.role_id" +
1264 1517:dffacf8a6908 Chris
        " WHERE m.user_id = #{user.id} AND cfr.custom_field_id = #{id_column})"
1265 1464:261b3d9a4903 Chris
    end
1266
  end
1267
1268
  def self.visibility_condition
1269
    if user.admin?
1270
      "1=1"
1271
    elsif user.anonymous?
1272
      "#{table_name}.visible"
1273
    else
1274
      "#{project_key} IN (SELECT DISTINCT m.project_id FROM #{Member.table_name} m" +
1275
        " INNER JOIN #{MemberRole.table_name} mr ON mr.member_id = m.id" +
1276
        " INNER JOIN #{table_name_prefix}custom_fields_roles#{table_name_suffix} cfr ON cfr.role_id = mr.role_id" +
1277
        " WHERE m.user_id = #{user.id} AND cfr.custom_field_id = #{id})"
1278
    end
1279
  end
1280
1281 0:513646585e45 Chris
  def <=>(field)
1282
    position <=> field.position
1283
  end
1284 909:cbb26bc654de Chris
1285 1115:433d4f72a19b Chris
  # Returns the class that values represent
1286
  def value_class
1287 1517:dffacf8a6908 Chris
    format.target_class if format.respond_to?(:target_class)
1288 1115:433d4f72a19b Chris
  end
1289
1290 0:513646585e45 Chris
  def self.customized_class
1291
    self.name =~ /^(.+)CustomField$/
1292 1464:261b3d9a4903 Chris
    $1.constantize rescue nil
1293 0:513646585e45 Chris
  end
1294 909:cbb26bc654de Chris
1295 0:513646585e45 Chris
  # to move in project_custom_field
1296
  def self.for_all
1297 1464:261b3d9a4903 Chris
    where(:is_for_all => true).order('position').all
1298 0:513646585e45 Chris
  end
1299 909:cbb26bc654de Chris
1300 0:513646585e45 Chris
  def type_name
1301
    nil
1302
  end
1303 1115:433d4f72a19b Chris
1304
  # Returns the error messages for the given value
1305
  # or an empty array if value is a valid value for the custom field
1306 1517:dffacf8a6908 Chris
  def validate_custom_value(custom_value)
1307
    value = custom_value.value
1308 1115:433d4f72a19b Chris
    errs = []
1309
    if value.is_a?(Array)
1310
      if !multiple?
1311
        errs << ::I18n.t('activerecord.errors.messages.invalid')
1312
      end
1313
      if is_required? && value.detect(&:present?).nil?
1314
        errs << ::I18n.t('activerecord.errors.messages.blank')
1315
      end
1316
    else
1317
      if is_required? && value.blank?
1318
        errs << ::I18n.t('activerecord.errors.messages.blank')
1319
      end
1320
    end
1321 1517:dffacf8a6908 Chris
    errs += format.validate_custom_value(custom_value)
1322 1115:433d4f72a19b Chris
    errs
1323
  end
1324
1325 1517:dffacf8a6908 Chris
  # Returns the error messages for the default custom field value
1326
  def validate_field_value(value)
1327
    validate_custom_value(CustomValue.new(:custom_field => self, :value => value))
1328
  end
1329
1330 1115:433d4f72a19b Chris
  # Returns true if value is a valid value for the custom field
1331
  def valid_field_value?(value)
1332
    validate_field_value(value).empty?
1333
  end
1334
1335
  def format_in?(*args)
1336
    args.include?(field_format)
1337
  end
1338
1339
  protected
1340
1341 1464:261b3d9a4903 Chris
  # Removes multiple values for the custom field after setting the multiple attribute to false
1342
  # We kepp the value with the highest id for each customized object
1343
  def handle_multiplicity_change
1344
    if !new_record? && multiple_was && !multiple
1345
      ids = custom_values.
1346
        where("EXISTS(SELECT 1 FROM #{CustomValue.table_name} cve WHERE cve.custom_field_id = #{CustomValue.table_name}.custom_field_id" +
1347
          " AND cve.customized_type = #{CustomValue.table_name}.customized_type AND cve.customized_id = #{CustomValue.table_name}.customized_id" +
1348
          " AND cve.id > #{CustomValue.table_name}.id)").
1349
        pluck(:id)
1350
1351
      if ids.any?
1352
        custom_values.where(:id => ids).delete_all
1353
      end
1354
    end
1355
  end
1356 0:513646585e45 Chris
end
1357 1517:dffacf8a6908 Chris
1358
require_dependency 'redmine/field_format'
1359 1115:433d4f72a19b Chris
# Redmine - project management software
1360 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
1361 1115:433d4f72a19b Chris
#
1362
# This program is free software; you can redistribute it and/or
1363
# modify it under the terms of the GNU General Public License
1364
# as published by the Free Software Foundation; either version 2
1365
# of the License, or (at your option) any later version.
1366
#
1367
# This program is distributed in the hope that it will be useful,
1368
# but WITHOUT ANY WARRANTY; without even the implied warranty of
1369
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
1370
# GNU General Public License for more details.
1371
#
1372
# You should have received a copy of the GNU General Public License
1373
# along with this program; if not, write to the Free Software
1374
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
1375
1376
class CustomFieldValue
1377 1517:dffacf8a6908 Chris
  attr_accessor :custom_field, :customized, :value, :value_was
1378
1379
  def initialize(attributes={})
1380
    attributes.each do |name, v|
1381
      send "#{name}=", v
1382
    end
1383
  end
1384 1115:433d4f72a19b Chris
1385
  def custom_field_id
1386
    custom_field.id
1387
  end
1388
1389
  def true?
1390
    self.value == '1'
1391
  end
1392
1393
  def editable?
1394
    custom_field.editable?
1395
  end
1396
1397
  def visible?
1398
    custom_field.visible?
1399
  end
1400
1401
  def required?
1402
    custom_field.is_required?
1403
  end
1404
1405
  def to_s
1406
    value.to_s
1407
  end
1408
1409
  def validate_value
1410 1517:dffacf8a6908 Chris
    custom_field.validate_custom_value(self).each do |message|
1411 1115:433d4f72a19b Chris
      customized.errors.add(:base, custom_field.name + ' ' + message)
1412
    end
1413
  end
1414
end
1415 909:cbb26bc654de Chris
# Redmine - project management software
1416 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
1417 0:513646585e45 Chris
#
1418
# This program is free software; you can redistribute it and/or
1419
# modify it under the terms of the GNU General Public License
1420
# as published by the Free Software Foundation; either version 2
1421
# of the License, or (at your option) any later version.
1422 909:cbb26bc654de Chris
#
1423 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
1424
# but WITHOUT ANY WARRANTY; without even the implied warranty of
1425
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
1426
# GNU General Public License for more details.
1427 909:cbb26bc654de Chris
#
1428 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
1429
# along with this program; if not, write to the Free Software
1430
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
1431
1432
class CustomValue < ActiveRecord::Base
1433
  belongs_to :custom_field
1434
  belongs_to :customized, :polymorphic => true
1435
1436 1115:433d4f72a19b Chris
  def initialize(attributes=nil, *args)
1437
    super
1438 0:513646585e45 Chris
    if new_record? && custom_field && (customized_type.blank? || (customized && customized.new_record?))
1439
      self.value ||= custom_field.default_value
1440
    end
1441
  end
1442 909:cbb26bc654de Chris
1443 0:513646585e45 Chris
  # Returns true if the boolean custom value is true
1444
  def true?
1445
    self.value == '1'
1446
  end
1447 909:cbb26bc654de Chris
1448 0:513646585e45 Chris
  def editable?
1449
    custom_field.editable?
1450
  end
1451 909:cbb26bc654de Chris
1452 37:94944d00e43c chris
  def visible?
1453
    custom_field.visible?
1454
  end
1455 909:cbb26bc654de Chris
1456 0:513646585e45 Chris
  def required?
1457
    custom_field.is_required?
1458
  end
1459 909:cbb26bc654de Chris
1460 0:513646585e45 Chris
  def to_s
1461
    value.to_s
1462
  end
1463
end
1464 1115:433d4f72a19b Chris
# Redmine - project management software
1465 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
1466 0:513646585e45 Chris
#
1467
# This program is free software; you can redistribute it and/or
1468
# modify it under the terms of the GNU General Public License
1469
# as published by the Free Software Foundation; either version 2
1470
# of the License, or (at your option) any later version.
1471 441:cbce1fd3b1b7 Chris
#
1472 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
1473
# but WITHOUT ANY WARRANTY; without even the implied warranty of
1474
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
1475
# GNU General Public License for more details.
1476 441:cbce1fd3b1b7 Chris
#
1477 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
1478
# along with this program; if not, write to the Free Software
1479
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
1480
1481
class Document < ActiveRecord::Base
1482 929:5f33065ddc4b Chris
  include Redmine::SafeAttributes
1483 0:513646585e45 Chris
  belongs_to :project
1484
  belongs_to :category, :class_name => "DocumentCategory", :foreign_key => "category_id"
1485 1464:261b3d9a4903 Chris
  acts_as_attachable :delete_permission => :delete_documents
1486 0:513646585e45 Chris
1487
  acts_as_searchable :columns => ['title', "#{table_name}.description"], :include => :project
1488
  acts_as_event :title => Proc.new {|o| "#{l(:label_document)}: #{o.title}"},
1489 1464:261b3d9a4903 Chris
                :author => Proc.new {|o| o.attachments.reorder("#{Attachment.table_name}.created_on ASC").first.try(:author) },
1490 0:513646585e45 Chris
                :url => Proc.new {|o| {:controller => 'documents', :action => 'show', :id => o.id}}
1491
  acts_as_activity_provider :find_options => {:include => :project}
1492 441:cbce1fd3b1b7 Chris
1493 0:513646585e45 Chris
  validates_presence_of :project, :title, :category
1494
  validates_length_of :title, :maximum => 60
1495 441:cbce1fd3b1b7 Chris
1496 1464:261b3d9a4903 Chris
  after_create :send_notification
1497
1498
  scope :visible, lambda {|*args|
1499
    includes(:project).where(Project.allowed_to_condition(args.shift || User.current, :view_documents, *args))
1500
  }
1501 441:cbce1fd3b1b7 Chris
1502 929:5f33065ddc4b Chris
  safe_attributes 'category_id', 'title', 'description'
1503
1504 0:513646585e45 Chris
  def visible?(user=User.current)
1505
    !user.nil? && user.allowed_to?(:view_documents, project)
1506
  end
1507 441:cbce1fd3b1b7 Chris
1508 1115:433d4f72a19b Chris
  def initialize(attributes=nil, *args)
1509
    super
1510 0:513646585e45 Chris
    if new_record?
1511
      self.category ||= DocumentCategory.default
1512
    end
1513
  end
1514 441:cbce1fd3b1b7 Chris
1515 0:513646585e45 Chris
  def updated_on
1516
    unless @updated_on
1517 1115:433d4f72a19b Chris
      a = attachments.last
1518 0:513646585e45 Chris
      @updated_on = (a && a.created_on) || created_on
1519
    end
1520
    @updated_on
1521
  end
1522 1464:261b3d9a4903 Chris
1523
  private
1524
1525
  def send_notification
1526
    if Setting.notified_events.include?('document_added')
1527
      Mailer.document_added(self).deliver
1528
    end
1529
  end
1530 0:513646585e45 Chris
end
1531 441:cbce1fd3b1b7 Chris
# Redmine - project management software
1532 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
1533 0:513646585e45 Chris
#
1534
# This program is free software; you can redistribute it and/or
1535
# modify it under the terms of the GNU General Public License
1536
# as published by the Free Software Foundation; either version 2
1537
# of the License, or (at your option) any later version.
1538 441:cbce1fd3b1b7 Chris
#
1539 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
1540
# but WITHOUT ANY WARRANTY; without even the implied warranty of
1541
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
1542
# GNU General Public License for more details.
1543 441:cbce1fd3b1b7 Chris
#
1544 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
1545
# along with this program; if not, write to the Free Software
1546
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
1547
1548
class DocumentCategory < Enumeration
1549
  has_many :documents, :foreign_key => 'category_id'
1550
1551
  OptionName = :enumeration_doc_categories
1552
1553
  def option_name
1554
    OptionName
1555
  end
1556
1557
  def objects_count
1558
    documents.count
1559
  end
1560
1561
  def transfer_relations(to)
1562
    documents.update_all("category_id = #{to.id}")
1563
  end
1564 1115:433d4f72a19b Chris
1565
  def self.default
1566
    d = super
1567
    d = first if d.nil?
1568
    d
1569
  end
1570 0:513646585e45 Chris
end
1571 441:cbce1fd3b1b7 Chris
# Redmine - project management software
1572 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
1573 0:513646585e45 Chris
#
1574
# This program is free software; you can redistribute it and/or
1575
# modify it under the terms of the GNU General Public License
1576
# as published by the Free Software Foundation; either version 2
1577
# of the License, or (at your option) any later version.
1578 441:cbce1fd3b1b7 Chris
#
1579 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
1580
# but WITHOUT ANY WARRANTY; without even the implied warranty of
1581
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
1582
# GNU General Public License for more details.
1583 441:cbce1fd3b1b7 Chris
#
1584 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
1585
# along with this program; if not, write to the Free Software
1586
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
1587
1588
class DocumentCategoryCustomField < CustomField
1589
  def type_name
1590
    :enumeration_doc_categories
1591
  end
1592
end
1593
# Redmine - project management software
1594 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
1595 0:513646585e45 Chris
#
1596
# This program is free software; you can redistribute it and/or
1597
# modify it under the terms of the GNU General Public License
1598
# as published by the Free Software Foundation; either version 2
1599
# of the License, or (at your option) any later version.
1600 441:cbce1fd3b1b7 Chris
#
1601 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
1602
# but WITHOUT ANY WARRANTY; without even the implied warranty of
1603
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
1604
# GNU General Public License for more details.
1605 441:cbce1fd3b1b7 Chris
#
1606 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
1607
# along with this program; if not, write to the Free Software
1608
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
1609
1610
class EnabledModule < ActiveRecord::Base
1611
  belongs_to :project
1612 1517:dffacf8a6908 Chris
  acts_as_watchable
1613 441:cbce1fd3b1b7 Chris
1614 0:513646585e45 Chris
  validates_presence_of :name
1615
  validates_uniqueness_of :name, :scope => :project_id
1616 441:cbce1fd3b1b7 Chris
1617 0:513646585e45 Chris
  after_create :module_enabled
1618 441:cbce1fd3b1b7 Chris
1619 0:513646585e45 Chris
  private
1620 441:cbce1fd3b1b7 Chris
1621 0:513646585e45 Chris
  # after_create callback used to do things when a module is enabled
1622
  def module_enabled
1623
    case name
1624
    when 'wiki'
1625
      # Create a wiki with a default start page
1626
      if project && project.wiki.nil?
1627
        Wiki.create(:project => project, :start_page => 'Wiki')
1628
      end
1629
    end
1630
  end
1631
end
1632 909:cbb26bc654de Chris
# Redmine - project management software
1633 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
1634 0:513646585e45 Chris
#
1635
# This program is free software; you can redistribute it and/or
1636
# modify it under the terms of the GNU General Public License
1637
# as published by the Free Software Foundation; either version 2
1638
# of the License, or (at your option) any later version.
1639 909:cbb26bc654de Chris
#
1640 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
1641
# but WITHOUT ANY WARRANTY; without even the implied warranty of
1642
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
1643
# GNU General Public License for more details.
1644 909:cbb26bc654de Chris
#
1645 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
1646
# along with this program; if not, write to the Free Software
1647
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
1648
1649
class Enumeration < ActiveRecord::Base
1650 1115:433d4f72a19b Chris
  include Redmine::SubclassFactory
1651
1652 0:513646585e45 Chris
  default_scope :order => "#{Enumeration.table_name}.position ASC"
1653 909:cbb26bc654de Chris
1654 0:513646585e45 Chris
  belongs_to :project
1655 909:cbb26bc654de Chris
1656 0:513646585e45 Chris
  acts_as_list :scope => 'type = \'#{type}\''
1657
  acts_as_customizable
1658 1464:261b3d9a4903 Chris
  acts_as_tree :order => "#{Enumeration.table_name}.position ASC"
1659 0:513646585e45 Chris
1660
  before_destroy :check_integrity
1661 909:cbb26bc654de Chris
  before_save    :check_default
1662
1663 1115:433d4f72a19b Chris
  attr_protected :type
1664
1665 0:513646585e45 Chris
  validates_presence_of :name
1666
  validates_uniqueness_of :name, :scope => [:type, :project_id]
1667
  validates_length_of :name, :maximum => 30
1668
1669 1464:261b3d9a4903 Chris
  scope :shared, lambda { where(:project_id => nil) }
1670
  scope :sorted, lambda { order("#{table_name}.position ASC") }
1671
  scope :active, lambda { where(:active => true) }
1672
  scope :system, lambda { where(:project_id => nil) }
1673 1115:433d4f72a19b Chris
  scope :named, lambda {|arg| where("LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip)}
1674 0:513646585e45 Chris
1675
  def self.default
1676
    # Creates a fake default scope so Enumeration.default will check
1677
    # it's type.  STI subclasses will automatically add their own
1678
    # types to the finder.
1679
    if self.descends_from_active_record?
1680 1115:433d4f72a19b Chris
      where(:is_default => true, :type => 'Enumeration').first
1681 0:513646585e45 Chris
    else
1682
      # STI classes are
1683 1115:433d4f72a19b Chris
      where(:is_default => true).first
1684 0:513646585e45 Chris
    end
1685
  end
1686 909:cbb26bc654de Chris
1687 0:513646585e45 Chris
  # Overloaded on concrete classes
1688
  def option_name
1689
    nil
1690
  end
1691
1692 909:cbb26bc654de Chris
  def check_default
1693 0:513646585e45 Chris
    if is_default? && is_default_changed?
1694 1517:dffacf8a6908 Chris
      Enumeration.where({:type => type}).update_all({:is_default => false})
1695 0:513646585e45 Chris
    end
1696
  end
1697 909:cbb26bc654de Chris
1698 0:513646585e45 Chris
  # Overloaded on concrete classes
1699
  def objects_count
1700
    0
1701
  end
1702 909:cbb26bc654de Chris
1703 0:513646585e45 Chris
  def in_use?
1704
    self.objects_count != 0
1705
  end
1706
1707
  # Is this enumeration overiding a system level enumeration?
1708
  def is_override?
1709
    !self.parent.nil?
1710
  end
1711 909:cbb26bc654de Chris
1712 0:513646585e45 Chris
  alias :destroy_without_reassign :destroy
1713 909:cbb26bc654de Chris
1714 0:513646585e45 Chris
  # Destroy the enumeration
1715
  # If a enumeration is specified, objects are reassigned
1716
  def destroy(reassign_to = nil)
1717
    if reassign_to && reassign_to.is_a?(Enumeration)
1718
      self.transfer_relations(reassign_to)
1719
    end
1720
    destroy_without_reassign
1721
  end
1722 909:cbb26bc654de Chris
1723 0:513646585e45 Chris
  def <=>(enumeration)
1724
    position <=> enumeration.position
1725
  end
1726 909:cbb26bc654de Chris
1727 0:513646585e45 Chris
  def to_s; name end
1728
1729
  # Returns the Subclasses of Enumeration.  Each Subclass needs to be
1730
  # required in development mode.
1731
  #
1732
  # Note: subclasses is protected in ActiveRecord
1733
  def self.get_subclasses
1734 1115:433d4f72a19b Chris
    subclasses
1735 0:513646585e45 Chris
  end
1736
1737
  # Does the +new+ Hash override the previous Enumeration?
1738
  def self.overridding_change?(new, previous)
1739
    if (same_active_state?(new['active'], previous.active)) && same_custom_values?(new,previous)
1740
      return false
1741
    else
1742
      return true
1743
    end
1744
  end
1745
1746
  # Does the +new+ Hash have the same custom values as the previous Enumeration?
1747
  def self.same_custom_values?(new, previous)
1748
    previous.custom_field_values.each do |custom_value|
1749
      if custom_value.value != new["custom_field_values"][custom_value.custom_field_id.to_s]
1750
        return false
1751
      end
1752
    end
1753
1754
    return true
1755
  end
1756 909:cbb26bc654de Chris
1757 0:513646585e45 Chris
  # Are the new and previous fields equal?
1758
  def self.same_active_state?(new, previous)
1759
    new = (new == "1" ? true : false)
1760
    return new == previous
1761
  end
1762 909:cbb26bc654de Chris
1763 0:513646585e45 Chris
private
1764
  def check_integrity
1765
    raise "Can't delete enumeration" if self.in_use?
1766
  end
1767
1768
end
1769
1770
# Force load the subclasses in development mode
1771
require_dependency 'time_entry_activity'
1772
require_dependency 'document_category'
1773
require_dependency 'issue_priority'
1774
# Redmine - project management software
1775 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
1776 0:513646585e45 Chris
#
1777
# This program is free software; you can redistribute it and/or
1778
# modify it under the terms of the GNU General Public License
1779
# as published by the Free Software Foundation; either version 2
1780
# of the License, or (at your option) any later version.
1781 909:cbb26bc654de Chris
#
1782 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
1783
# but WITHOUT ANY WARRANTY; without even the implied warranty of
1784
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
1785
# GNU General Public License for more details.
1786 909:cbb26bc654de Chris
#
1787 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
1788
# along with this program; if not, write to the Free Software
1789
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
1790
1791
class Group < Principal
1792 1115:433d4f72a19b Chris
  include Redmine::SafeAttributes
1793
1794 1517:dffacf8a6908 Chris
  has_and_belongs_to_many :users,
1795
                          :join_table   => "#{table_name_prefix}groups_users#{table_name_suffix}",
1796
                          :after_add => :user_added,
1797
                          :after_remove => :user_removed
1798 909:cbb26bc654de Chris
1799 0:513646585e45 Chris
  acts_as_customizable
1800 909:cbb26bc654de Chris
1801 0:513646585e45 Chris
  validates_presence_of :lastname
1802
  validates_uniqueness_of :lastname, :case_sensitive => false
1803 1464:261b3d9a4903 Chris
  validates_length_of :lastname, :maximum => 255
1804 909:cbb26bc654de Chris
1805
  before_destroy :remove_references_before_destroy
1806
1807 1464:261b3d9a4903 Chris
  scope :sorted, lambda { order("#{table_name}.lastname ASC") }
1808
  scope :named, lambda {|arg| where("LOWER(#{table_name}.lastname) = LOWER(?)", arg.to_s.strip)}
1809 1115:433d4f72a19b Chris
1810
  safe_attributes 'name',
1811
    'user_ids',
1812
    'custom_field_values',
1813
    'custom_fields',
1814
    :if => lambda {|group, user| user.admin?}
1815
1816 0:513646585e45 Chris
  def to_s
1817
    lastname.to_s
1818
  end
1819 909:cbb26bc654de Chris
1820 1115:433d4f72a19b Chris
  def name
1821
    lastname
1822
  end
1823
1824
  def name=(arg)
1825
    self.lastname = arg
1826
  end
1827 909:cbb26bc654de Chris
1828 0:513646585e45 Chris
  def user_added(user)
1829
    members.each do |member|
1830 119:8661b858af72 Chris
      next if member.project.nil?
1831 0:513646585e45 Chris
      user_member = Member.find_by_project_id_and_user_id(member.project_id, user.id) || Member.new(:project_id => member.project_id, :user_id => user.id)
1832
      member.member_roles.each do |member_role|
1833
        user_member.member_roles << MemberRole.new(:role => member_role.role, :inherited_from => member_role.id)
1834
      end
1835
      user_member.save!
1836
    end
1837
  end
1838 909:cbb26bc654de Chris
1839 0:513646585e45 Chris
  def user_removed(user)
1840
    members.each do |member|
1841 1464:261b3d9a4903 Chris
      MemberRole.
1842
        includes(:member).
1843
        where("#{Member.table_name}.user_id = ? AND #{MemberRole.table_name}.inherited_from IN (?)", user.id, member.member_role_ids).
1844
        each(&:destroy)
1845 0:513646585e45 Chris
    end
1846
  end
1847 909:cbb26bc654de Chris
1848 1115:433d4f72a19b Chris
  def self.human_attribute_name(attribute_key_name, *args)
1849
    attr_name = attribute_key_name.to_s
1850 909:cbb26bc654de Chris
    if attr_name == 'lastname'
1851
      attr_name = "name"
1852
    end
1853 1115:433d4f72a19b Chris
    super(attr_name, *args)
1854 909:cbb26bc654de Chris
  end
1855
1856
  private
1857
1858
  # Removes references that are not handled by associations
1859
  def remove_references_before_destroy
1860
    return if self.id.nil?
1861
1862 1517:dffacf8a6908 Chris
    Issue.where(['assigned_to_id = ?', id]).update_all('assigned_to_id = NULL')
1863 909:cbb26bc654de Chris
  end
1864 0:513646585e45 Chris
end
1865
# Redmine - project management software
1866 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
1867 0:513646585e45 Chris
#
1868
# This program is free software; you can redistribute it and/or
1869
# modify it under the terms of the GNU General Public License
1870
# as published by the Free Software Foundation; either version 2
1871
# of the License, or (at your option) any later version.
1872 909:cbb26bc654de Chris
#
1873 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
1874
# but WITHOUT ANY WARRANTY; without even the implied warranty of
1875
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
1876
# GNU General Public License for more details.
1877 909:cbb26bc654de Chris
#
1878 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
1879
# along with this program; if not, write to the Free Software
1880
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
1881
1882
class GroupCustomField < CustomField
1883
  def type_name
1884
    :label_group_plural
1885
  end
1886
end
1887 98:596803cb34fc luis
class Institution < ActiveRecord::Base
1888
end
1889 245:051f544170fe Chris
# Redmine - project management software
1890 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
1891 0:513646585e45 Chris
#
1892
# This program is free software; you can redistribute it and/or
1893
# modify it under the terms of the GNU General Public License
1894
# as published by the Free Software Foundation; either version 2
1895
# of the License, or (at your option) any later version.
1896 441:cbce1fd3b1b7 Chris
#
1897 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
1898
# but WITHOUT ANY WARRANTY; without even the implied warranty of
1899
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
1900
# GNU General Public License for more details.
1901 441:cbce1fd3b1b7 Chris
#
1902 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
1903
# along with this program; if not, write to the Free Software
1904
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
1905
1906
class Issue < ActiveRecord::Base
1907 117:af80e5618e9b Chris
  include Redmine::SafeAttributes
1908 1115:433d4f72a19b Chris
  include Redmine::Utils::DateCalculation
1909 1464:261b3d9a4903 Chris
  include Redmine::I18n
1910 441:cbce1fd3b1b7 Chris
1911 0:513646585e45 Chris
  belongs_to :project
1912
  belongs_to :tracker
1913
  belongs_to :status, :class_name => 'IssueStatus', :foreign_key => 'status_id'
1914
  belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
1915 909:cbb26bc654de Chris
  belongs_to :assigned_to, :class_name => 'Principal', :foreign_key => 'assigned_to_id'
1916 0:513646585e45 Chris
  belongs_to :fixed_version, :class_name => 'Version', :foreign_key => 'fixed_version_id'
1917
  belongs_to :priority, :class_name => 'IssuePriority', :foreign_key => 'priority_id'
1918
  belongs_to :category, :class_name => 'IssueCategory', :foreign_key => 'category_id'
1919
1920
  has_many :journals, :as => :journalized, :dependent => :destroy
1921 1115:433d4f72a19b Chris
  has_many :visible_journals,
1922
    :class_name => 'Journal',
1923
    :as => :journalized,
1924
    :conditions => Proc.new {
1925
      ["(#{Journal.table_name}.private_notes = ? OR (#{Project.allowed_to_condition(User.current, :view_private_notes)}))", false]
1926
    },
1927
    :readonly => true
1928
1929 1517:dffacf8a6908 Chris
  has_many :time_entries, :dependent => :destroy
1930 0:513646585e45 Chris
  has_and_belongs_to_many :changesets, :order => "#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC"
1931 441:cbce1fd3b1b7 Chris
1932 0:513646585e45 Chris
  has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
1933
  has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
1934 441:cbce1fd3b1b7 Chris
1935 210:0579821a129a Chris
  acts_as_nested_set :scope => 'root_id', :dependent => :destroy
1936 909:cbb26bc654de Chris
  acts_as_attachable :after_add => :attachment_added, :after_remove => :attachment_removed
1937 0:513646585e45 Chris
  acts_as_customizable
1938
  acts_as_watchable
1939
  acts_as_searchable :columns => ['subject', "#{table_name}.description", "#{Journal.table_name}.notes"],
1940 1115:433d4f72a19b Chris
                     :include => [:project, :visible_journals],
1941 0:513646585e45 Chris
                     # sort by id so that limited eager loading doesn't break with postgresql
1942
                     :order_column => "#{table_name}.id"
1943
  acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
1944
                :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
1945
                :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
1946 441:cbce1fd3b1b7 Chris
1947 0:513646585e45 Chris
  acts_as_activity_provider :find_options => {:include => [:project, :author, :tracker]},
1948
                            :author_key => :author_id
1949
1950
  DONE_RATIO_OPTIONS = %w(issue_field issue_status)
1951
1952
  attr_reader :current_journal
1953 1115:433d4f72a19b Chris
  delegate :notes, :notes=, :private_notes, :private_notes=, :to => :current_journal, :allow_nil => true
1954 0:513646585e45 Chris
1955
  validates_presence_of :subject, :priority, :project, :tracker, :author, :status
1956
1957
  validates_length_of :subject, :maximum => 255
1958
  validates_inclusion_of :done_ratio, :in => 0..100
1959 1464:261b3d9a4903 Chris
  validates :estimated_hours, :numericality => {:greater_than_or_equal_to => 0, :allow_nil => true, :message => :invalid}
1960
  validates :start_date, :date => true
1961
  validates :due_date, :date => true
1962 1115:433d4f72a19b Chris
  validate :validate_issue, :validate_required_fields
1963 0:513646585e45 Chris
1964 1464:261b3d9a4903 Chris
  scope :visible, lambda {|*args|
1965
    includes(:project).where(Issue.visible_condition(args.shift || User.current, *args))
1966
  }
1967 441:cbce1fd3b1b7 Chris
1968 1115:433d4f72a19b Chris
  scope :open, lambda {|*args|
1969
    is_closed = args.size > 0 ? !args.first : false
1970 1464:261b3d9a4903 Chris
    includes(:status).where("#{IssueStatus.table_name}.is_closed = ?", is_closed)
1971 22:40f7cfd4df19 chris
  }
1972
1973 1464:261b3d9a4903 Chris
  scope :recently_updated, lambda { order("#{Issue.table_name}.updated_on DESC") }
1974
  scope :on_active_project, lambda {
1975
    includes(:status, :project, :tracker).where("#{Project.table_name}.status = ?", Project::STATUS_ACTIVE)
1976
  }
1977
  scope :fixed_version, lambda {|versions|
1978
    ids = [versions].flatten.compact.map {|v| v.is_a?(Version) ? v.id : v}
1979
    ids.any? ? where(:fixed_version_id => ids) : where('1=0')
1980
  }
1981 0:513646585e45 Chris
1982
  before_create :default_assign
1983 1464:261b3d9a4903 Chris
  before_save :close_duplicates, :update_done_ratio_from_issue_status,
1984
              :force_updated_on_change, :update_closed_on, :set_assigned_to_was
1985 1115:433d4f72a19b Chris
  after_save {|issue| issue.send :after_project_change if !issue.id_changed? && issue.project_id_changed?}
1986 1464:261b3d9a4903 Chris
  after_save :reschedule_following_issues, :update_nested_set_attributes,
1987
             :update_parent_attributes, :create_journal
1988 1115:433d4f72a19b Chris
  # Should be after_create but would be called before previous after_save callbacks
1989
  after_save :after_create_from_copy
1990 0:513646585e45 Chris
  after_destroy :update_parent_attributes
1991 1464:261b3d9a4903 Chris
  after_create :send_notification
1992
  # Keep it at the end of after_save callbacks
1993
  after_save :clear_assigned_to_was
1994 441:cbce1fd3b1b7 Chris
1995
  # Returns a SQL conditions string used to find all issues visible by the specified user
1996
  def self.visible_condition(user, options={})
1997
    Project.allowed_to_condition(user, :view_issues, options) do |role, user|
1998 1115:433d4f72a19b Chris
      if user.logged?
1999
        case role.issues_visibility
2000
        when 'all'
2001
          nil
2002
        when 'default'
2003 1464:261b3d9a4903 Chris
          user_ids = [user.id] + user.groups.map(&:id).compact
2004 1115:433d4f72a19b Chris
          "(#{table_name}.is_private = #{connection.quoted_false} OR #{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
2005
        when 'own'
2006 1464:261b3d9a4903 Chris
          user_ids = [user.id] + user.groups.map(&:id).compact
2007 1115:433d4f72a19b Chris
          "(#{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
2008
        else
2009
          '1=0'
2010
        end
2011 441:cbce1fd3b1b7 Chris
      else
2012 1115:433d4f72a19b Chris
        "(#{table_name}.is_private = #{connection.quoted_false})"
2013 441:cbce1fd3b1b7 Chris
      end
2014
    end
2015
  end
2016
2017 0:513646585e45 Chris
  # Returns true if usr or current user is allowed to view the issue
2018
  def visible?(usr=nil)
2019 441:cbce1fd3b1b7 Chris
    (usr || User.current).allowed_to?(:view_issues, self.project) do |role, user|
2020 1115:433d4f72a19b Chris
      if user.logged?
2021
        case role.issues_visibility
2022
        when 'all'
2023
          true
2024
        when 'default'
2025
          !self.is_private? || (self.author == user || user.is_or_belongs_to?(assigned_to))
2026
        when 'own'
2027
          self.author == user || user.is_or_belongs_to?(assigned_to)
2028
        else
2029
          false
2030
        end
2031 441:cbce1fd3b1b7 Chris
      else
2032 1115:433d4f72a19b Chris
        !self.is_private?
2033 441:cbce1fd3b1b7 Chris
      end
2034
    end
2035 0:513646585e45 Chris
  end
2036 441:cbce1fd3b1b7 Chris
2037 1464:261b3d9a4903 Chris
  # Returns true if user or current user is allowed to edit or add a note to the issue
2038
  def editable?(user=User.current)
2039
    user.allowed_to?(:edit_issues, project) || user.allowed_to?(:add_issue_notes, project)
2040
  end
2041
2042 1115:433d4f72a19b Chris
  def initialize(attributes=nil, *args)
2043
    super
2044 0:513646585e45 Chris
    if new_record?
2045
      # set default values for new records only
2046
      self.status ||= IssueStatus.default
2047
      self.priority ||= IssuePriority.default
2048 1115:433d4f72a19b Chris
      self.watcher_user_ids = []
2049 0:513646585e45 Chris
    end
2050
  end
2051 441:cbce1fd3b1b7 Chris
2052 1464:261b3d9a4903 Chris
  def create_or_update
2053
    super
2054
  ensure
2055
    @status_was = nil
2056
  end
2057
  private :create_or_update
2058
2059 1115:433d4f72a19b Chris
  # AR#Persistence#destroy would raise and RecordNotFound exception
2060
  # if the issue was already deleted or updated (non matching lock_version).
2061
  # This is a problem when bulk deleting issues or deleting a project
2062
  # (because an issue may already be deleted if its parent was deleted
2063
  # first).
2064
  # The issue is reloaded by the nested_set before being deleted so
2065
  # the lock_version condition should not be an issue but we handle it.
2066
  def destroy
2067
    super
2068
  rescue ActiveRecord::RecordNotFound
2069
    # Stale or already deleted
2070
    begin
2071
      reload
2072
    rescue ActiveRecord::RecordNotFound
2073
      # The issue was actually already deleted
2074
      @destroyed = true
2075
      return freeze
2076
    end
2077
    # The issue was stale, retry to destroy
2078
    super
2079
  end
2080
2081 1464:261b3d9a4903 Chris
  alias :base_reload :reload
2082 1115:433d4f72a19b Chris
  def reload(*args)
2083
    @workflow_rule_by_attribute = nil
2084
    @assignable_versions = nil
2085 1464:261b3d9a4903 Chris
    @relations = nil
2086
    base_reload(*args)
2087 1115:433d4f72a19b Chris
  end
2088
2089 0:513646585e45 Chris
  # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
2090
  def available_custom_fields
2091 1517:dffacf8a6908 Chris
    (project && tracker) ? (project.all_issue_custom_fields & tracker.custom_fields) : []
2092 0:513646585e45 Chris
  end
2093 441:cbce1fd3b1b7 Chris
2094 1464:261b3d9a4903 Chris
  def visible_custom_field_values(user=nil)
2095
    user_real = user || User.current
2096
    custom_field_values.select do |value|
2097
      value.custom_field.visible_by?(project, user_real)
2098
    end
2099
  end
2100
2101 1115:433d4f72a19b Chris
  # Copies attributes from another issue, arg can be an id or an Issue
2102
  def copy_from(arg, options={})
2103 0:513646585e45 Chris
    issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg)
2104
    self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
2105
    self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
2106
    self.status = issue.status
2107 1115:433d4f72a19b Chris
    self.author = User.current
2108
    unless options[:attachments] == false
2109
      self.attachments = issue.attachments.map do |attachement|
2110
        attachement.copy(:container => self)
2111
      end
2112
    end
2113
    @copied_from = issue
2114
    @copy_options = options
2115 0:513646585e45 Chris
    self
2116
  end
2117 441:cbce1fd3b1b7 Chris
2118 1115:433d4f72a19b Chris
  # Returns an unsaved copy of the issue
2119
  def copy(attributes=nil, copy_options={})
2120
    copy = self.class.new.copy_from(self, copy_options)
2121
    copy.attributes = attributes if attributes
2122
    copy
2123
  end
2124
2125
  # Returns true if the issue is a copy
2126
  def copy?
2127
    @copied_from.present?
2128
  end
2129
2130 0:513646585e45 Chris
  # Moves/copies an issue to a new project and tracker
2131
  # Returns the moved/copied issue on success, false on failure
2132 1115:433d4f72a19b Chris
  def move_to_project(new_project, new_tracker=nil, options={})
2133
    ActiveSupport::Deprecation.warn "Issue#move_to_project is deprecated, use #project= instead."
2134 441:cbce1fd3b1b7 Chris
2135 1115:433d4f72a19b Chris
    if options[:copy]
2136
      issue = self.copy
2137
    else
2138
      issue = self
2139
    end
2140 441:cbce1fd3b1b7 Chris
2141 1115:433d4f72a19b Chris
    issue.init_journal(User.current, options[:notes])
2142
2143
    # Preserve previous behaviour
2144
    # #move_to_project doesn't change tracker automatically
2145
    issue.send :project=, new_project, true
2146 0:513646585e45 Chris
    if new_tracker
2147
      issue.tracker = new_tracker
2148
    end
2149
    # Allow bulk setting of attributes on the issue
2150
    if options[:attributes]
2151
      issue.attributes = options[:attributes]
2152
    end
2153 441:cbce1fd3b1b7 Chris
2154 1115:433d4f72a19b Chris
    issue.save ? issue : false
2155 0:513646585e45 Chris
  end
2156
2157
  def status_id=(sid)
2158
    self.status = nil
2159 1115:433d4f72a19b Chris
    result = write_attribute(:status_id, sid)
2160
    @workflow_rule_by_attribute = nil
2161
    result
2162 0:513646585e45 Chris
  end
2163 441:cbce1fd3b1b7 Chris
2164 0:513646585e45 Chris
  def priority_id=(pid)
2165
    self.priority = nil
2166
    write_attribute(:priority_id, pid)
2167
  end
2168
2169 1115:433d4f72a19b Chris
  def category_id=(cid)
2170
    self.category = nil
2171
    write_attribute(:category_id, cid)
2172
  end
2173
2174
  def fixed_version_id=(vid)
2175
    self.fixed_version = nil
2176
    write_attribute(:fixed_version_id, vid)
2177
  end
2178
2179 0:513646585e45 Chris
  def tracker_id=(tid)
2180
    self.tracker = nil
2181
    result = write_attribute(:tracker_id, tid)
2182
    @custom_field_values = nil
2183 1115:433d4f72a19b Chris
    @workflow_rule_by_attribute = nil
2184 0:513646585e45 Chris
    result
2185
  end
2186 909:cbb26bc654de Chris
2187 1115:433d4f72a19b Chris
  def project_id=(project_id)
2188
    if project_id.to_s != self.project_id.to_s
2189
      self.project = (project_id.present? ? Project.find_by_id(project_id) : nil)
2190
    end
2191
  end
2192
2193
  def project=(project, keep_tracker=false)
2194
    project_was = self.project
2195
    write_attribute(:project_id, project ? project.id : nil)
2196
    association_instance_set('project', project)
2197
    if project_was && project && project_was != project
2198
      @assignable_versions = nil
2199
2200
      unless keep_tracker || project.trackers.include?(tracker)
2201
        self.tracker = project.trackers.first
2202
      end
2203
      # Reassign to the category with same name if any
2204
      if category
2205
        self.category = project.issue_categories.find_by_name(category.name)
2206
      end
2207
      # Keep the fixed_version if it's still valid in the new_project
2208
      if fixed_version && fixed_version.project != project && !project.shared_versions.include?(fixed_version)
2209
        self.fixed_version = nil
2210
      end
2211
      # Clear the parent task if it's no longer valid
2212
      unless valid_parent_project?
2213
        self.parent_issue_id = nil
2214
      end
2215
      @custom_field_values = nil
2216
    end
2217
  end
2218
2219 507:0c939c159af4 Chris
  def description=(arg)
2220
    if arg.is_a?(String)
2221
      arg = arg.gsub(/(\r\n|\n|\r)/, "\r\n")
2222
    end
2223
    write_attribute(:description, arg)
2224
  end
2225 441:cbce1fd3b1b7 Chris
2226 1115:433d4f72a19b Chris
  # Overrides assign_attributes so that project and tracker get assigned first
2227
  def assign_attributes_with_project_and_tracker_first(new_attributes, *args)
2228 0:513646585e45 Chris
    return if new_attributes.nil?
2229 1115:433d4f72a19b Chris
    attrs = new_attributes.dup
2230
    attrs.stringify_keys!
2231
2232
    %w(project project_id tracker tracker_id).each do |attr|
2233
      if attrs.has_key?(attr)
2234
        send "#{attr}=", attrs.delete(attr)
2235
      end
2236 0:513646585e45 Chris
    end
2237 1115:433d4f72a19b Chris
    send :assign_attributes_without_project_and_tracker_first, attrs, *args
2238 0:513646585e45 Chris
  end
2239
  # Do not redefine alias chain on reload (see #4838)
2240 1115:433d4f72a19b Chris
  alias_method_chain(:assign_attributes, :project_and_tracker_first) unless method_defined?(:assign_attributes_without_project_and_tracker_first)
2241 441:cbce1fd3b1b7 Chris
2242 0:513646585e45 Chris
  def estimated_hours=(h)
2243
    write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
2244
  end
2245 441:cbce1fd3b1b7 Chris
2246 1115:433d4f72a19b Chris
  safe_attributes 'project_id',
2247
    :if => lambda {|issue, user|
2248
      if issue.new_record?
2249
        issue.copy?
2250
      elsif user.allowed_to?(:move_issues, issue.project)
2251 1464:261b3d9a4903 Chris
        Issue.allowed_target_projects_on_move.count > 1
2252 1115:433d4f72a19b Chris
      end
2253
    }
2254
2255 117:af80e5618e9b Chris
  safe_attributes 'tracker_id',
2256
    'status_id',
2257
    'category_id',
2258
    'assigned_to_id',
2259
    'priority_id',
2260
    'fixed_version_id',
2261
    'subject',
2262
    'description',
2263
    'start_date',
2264
    'due_date',
2265
    'done_ratio',
2266
    'estimated_hours',
2267
    'custom_field_values',
2268
    'custom_fields',
2269
    'lock_version',
2270 1115:433d4f72a19b Chris
    'notes',
2271 117:af80e5618e9b Chris
    :if => lambda {|issue, user| issue.new_record? || user.allowed_to?(:edit_issues, issue.project) }
2272 441:cbce1fd3b1b7 Chris
2273 117:af80e5618e9b Chris
  safe_attributes 'status_id',
2274
    'assigned_to_id',
2275
    'fixed_version_id',
2276
    'done_ratio',
2277 1115:433d4f72a19b Chris
    'lock_version',
2278
    'notes',
2279 117:af80e5618e9b Chris
    :if => lambda {|issue, user| issue.new_statuses_allowed_to(user).any? }
2280 37:94944d00e43c chris
2281 1115:433d4f72a19b Chris
  safe_attributes 'notes',
2282
    :if => lambda {|issue, user| user.allowed_to?(:add_issue_notes, issue.project)}
2283
2284
  safe_attributes 'private_notes',
2285
    :if => lambda {|issue, user| !issue.new_record? && user.allowed_to?(:set_notes_private, issue.project)}
2286
2287
  safe_attributes 'watcher_user_ids',
2288
    :if => lambda {|issue, user| issue.new_record? && user.allowed_to?(:add_issue_watchers, issue.project)}
2289
2290 441:cbce1fd3b1b7 Chris
  safe_attributes 'is_private',
2291
    :if => lambda {|issue, user|
2292
      user.allowed_to?(:set_issues_private, issue.project) ||
2293
        (issue.author == user && user.allowed_to?(:set_own_issues_private, issue.project))
2294
    }
2295
2296 1115:433d4f72a19b Chris
  safe_attributes 'parent_issue_id',
2297
    :if => lambda {|issue, user| (issue.new_record? || user.allowed_to?(:edit_issues, issue.project)) &&
2298
      user.allowed_to?(:manage_subtasks, issue.project)}
2299
2300
  def safe_attribute_names(user=nil)
2301
    names = super
2302
    names -= disabled_core_fields
2303
    names -= read_only_attribute_names(user)
2304
    names
2305
  end
2306
2307 0:513646585e45 Chris
  # Safely sets attributes
2308
  # Should be called from controllers instead of #attributes=
2309
  # attr_accessible is too rough because we still want things like
2310
  # Issue.new(:project => foo) to work
2311
  def safe_attributes=(attrs, user=User.current)
2312 37:94944d00e43c chris
    return unless attrs.is_a?(Hash)
2313 441:cbce1fd3b1b7 Chris
2314 1115:433d4f72a19b Chris
    attrs = attrs.dup
2315 441:cbce1fd3b1b7 Chris
2316 1115:433d4f72a19b Chris
    # Project and Tracker must be set before since new_statuses_allowed_to depends on it.
2317
    if (p = attrs.delete('project_id')) && safe_attribute?('project_id')
2318 1464:261b3d9a4903 Chris
      if allowed_target_projects(user).where(:id => p.to_i).exists?
2319 1115:433d4f72a19b Chris
        self.project_id = p
2320
      end
2321
    end
2322
2323
    if (t = attrs.delete('tracker_id')) && safe_attribute?('tracker_id')
2324 37:94944d00e43c chris
      self.tracker_id = t
2325
    end
2326 441:cbce1fd3b1b7 Chris
2327 1115:433d4f72a19b Chris
    if (s = attrs.delete('status_id')) && safe_attribute?('status_id')
2328
      if new_statuses_allowed_to(user).collect(&:id).include?(s.to_i)
2329
        self.status_id = s
2330 0:513646585e45 Chris
      end
2331
    end
2332 441:cbce1fd3b1b7 Chris
2333 1115:433d4f72a19b Chris
    attrs = delete_unsafe_attributes(attrs, user)
2334
    return if attrs.empty?
2335
2336 0:513646585e45 Chris
    unless leaf?
2337
      attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
2338
    end
2339 441:cbce1fd3b1b7 Chris
2340 1115:433d4f72a19b Chris
    if attrs['parent_issue_id'].present?
2341
      s = attrs['parent_issue_id'].to_s
2342 1294:3e4c3460b6ca Chris
      unless (m = s.match(%r{\A#?(\d+)\z})) && (m[1] == parent_id.to_s || Issue.visible(user).exists?(m[1]))
2343 1115:433d4f72a19b Chris
        @invalid_parent_issue_id = attrs.delete('parent_issue_id')
2344 0:513646585e45 Chris
      end
2345
    end
2346 441:cbce1fd3b1b7 Chris
2347 1115:433d4f72a19b Chris
    if attrs['custom_field_values'].present?
2348 1464:261b3d9a4903 Chris
      editable_custom_field_ids = editable_custom_field_values(user).map {|v| v.custom_field_id.to_s}
2349
      # TODO: use #select when ruby1.8 support is dropped
2350
      attrs['custom_field_values'] = attrs['custom_field_values'].reject {|k, v| !editable_custom_field_ids.include?(k.to_s)}
2351 1115:433d4f72a19b Chris
    end
2352
2353
    if attrs['custom_fields'].present?
2354 1464:261b3d9a4903 Chris
      editable_custom_field_ids = editable_custom_field_values(user).map {|v| v.custom_field_id.to_s}
2355
      # TODO: use #select when ruby1.8 support is dropped
2356
      attrs['custom_fields'] = attrs['custom_fields'].reject {|c| !editable_custom_field_ids.include?(c['id'].to_s)}
2357 1115:433d4f72a19b Chris
    end
2358
2359
    # mass-assignment security bypass
2360
    assign_attributes attrs, :without_protection => true
2361 0:513646585e45 Chris
  end
2362 441:cbce1fd3b1b7 Chris
2363 1115:433d4f72a19b Chris
  def disabled_core_fields
2364
    tracker ? tracker.disabled_core_fields : []
2365
  end
2366
2367
  # Returns the custom_field_values that can be edited by the given user
2368
  def editable_custom_field_values(user=nil)
2369 1464:261b3d9a4903 Chris
    visible_custom_field_values(user).reject do |value|
2370 1115:433d4f72a19b Chris
      read_only_attribute_names(user).include?(value.custom_field_id.to_s)
2371
    end
2372
  end
2373
2374 1517:dffacf8a6908 Chris
  # Returns the custom fields that can be edited by the given user
2375
  def editable_custom_fields(user=nil)
2376
    editable_custom_field_values(user).map(&:custom_field).uniq
2377
  end
2378
2379 1115:433d4f72a19b Chris
  # Returns the names of attributes that are read-only for user or the current user
2380
  # For users with multiple roles, the read-only fields are the intersection of
2381
  # read-only fields of each role
2382
  # The result is an array of strings where sustom fields are represented with their ids
2383
  #
2384
  # Examples:
2385
  #   issue.read_only_attribute_names # => ['due_date', '2']
2386
  #   issue.read_only_attribute_names(user) # => []
2387
  def read_only_attribute_names(user=nil)
2388
    workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'readonly'}.keys
2389
  end
2390
2391
  # Returns the names of required attributes for user or the current user
2392
  # For users with multiple roles, the required fields are the intersection of
2393
  # required fields of each role
2394
  # The result is an array of strings where sustom fields are represented with their ids
2395
  #
2396
  # Examples:
2397
  #   issue.required_attribute_names # => ['due_date', '2']
2398
  #   issue.required_attribute_names(user) # => []
2399
  def required_attribute_names(user=nil)
2400
    workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'required'}.keys
2401
  end
2402
2403
  # Returns true if the attribute is required for user
2404
  def required_attribute?(name, user=nil)
2405
    required_attribute_names(user).include?(name.to_s)
2406
  end
2407
2408
  # Returns a hash of the workflow rule by attribute for the given user
2409
  #
2410
  # Examples:
2411
  #   issue.workflow_rule_by_attribute # => {'due_date' => 'required', 'start_date' => 'readonly'}
2412
  def workflow_rule_by_attribute(user=nil)
2413
    return @workflow_rule_by_attribute if @workflow_rule_by_attribute && user.nil?
2414
2415
    user_real = user || User.current
2416
    roles = user_real.admin ? Role.all : user_real.roles_for_project(project)
2417
    return {} if roles.empty?
2418
2419
    result = {}
2420
    workflow_permissions = WorkflowPermission.where(:tracker_id => tracker_id, :old_status_id => status_id, :role_id => roles.map(&:id)).all
2421
    if workflow_permissions.any?
2422
      workflow_rules = workflow_permissions.inject({}) do |h, wp|
2423
        h[wp.field_name] ||= []
2424
        h[wp.field_name] << wp.rule
2425
        h
2426
      end
2427
      workflow_rules.each do |attr, rules|
2428
        next if rules.size < roles.size
2429
        uniq_rules = rules.uniq
2430
        if uniq_rules.size == 1
2431
          result[attr] = uniq_rules.first
2432
        else
2433
          result[attr] = 'required'
2434
        end
2435
      end
2436
    end
2437
    @workflow_rule_by_attribute = result if user.nil?
2438
    result
2439
  end
2440
  private :workflow_rule_by_attribute
2441
2442 0:513646585e45 Chris
  def done_ratio
2443 37:94944d00e43c chris
    if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
2444 0:513646585e45 Chris
      status.default_done_ratio
2445
    else
2446
      read_attribute(:done_ratio)
2447
    end
2448
  end
2449
2450
  def self.use_status_for_done_ratio?
2451
    Setting.issue_done_ratio == 'issue_status'
2452
  end
2453
2454
  def self.use_field_for_done_ratio?
2455
    Setting.issue_done_ratio == 'issue_field'
2456
  end
2457 441:cbce1fd3b1b7 Chris
2458 909:cbb26bc654de Chris
  def validate_issue
2459 1464:261b3d9a4903 Chris
    if due_date && start_date && (start_date_changed? || due_date_changed?) && due_date < start_date
2460 0:513646585e45 Chris
      errors.add :due_date, :greater_than_start_date
2461
    end
2462 441:cbce1fd3b1b7 Chris
2463 1464:261b3d9a4903 Chris
    if start_date && start_date_changed? && soonest_start && start_date < soonest_start
2464
      errors.add :start_date, :earlier_than_minimum_start_date, :date => format_date(soonest_start)
2465 0:513646585e45 Chris
    end
2466 441:cbce1fd3b1b7 Chris
2467 0:513646585e45 Chris
    if fixed_version
2468
      if !assignable_versions.include?(fixed_version)
2469
        errors.add :fixed_version_id, :inclusion
2470
      elsif reopened? && fixed_version.closed?
2471 909:cbb26bc654de Chris
        errors.add :base, I18n.t(:error_can_not_reopen_issue_on_closed_version)
2472 0:513646585e45 Chris
      end
2473
    end
2474 441:cbce1fd3b1b7 Chris
2475 0:513646585e45 Chris
    # Checks that the issue can not be added/moved to a disabled tracker
2476
    if project && (tracker_id_changed? || project_id_changed?)
2477
      unless project.trackers.include?(tracker)
2478
        errors.add :tracker_id, :inclusion
2479
      end
2480
    end
2481 441:cbce1fd3b1b7 Chris
2482 0:513646585e45 Chris
    # Checks parent issue assignment
2483 1115:433d4f72a19b Chris
    if @invalid_parent_issue_id.present?
2484
      errors.add :parent_issue_id, :invalid
2485
    elsif @parent_issue
2486
      if !valid_parent_project?(@parent_issue)
2487
        errors.add :parent_issue_id, :invalid
2488 1464:261b3d9a4903 Chris
      elsif (@parent_issue != parent) && (all_dependent_issues.include?(@parent_issue) || @parent_issue.all_dependent_issues.include?(self))
2489
        errors.add :parent_issue_id, :invalid
2490 0:513646585e45 Chris
      elsif !new_record?
2491
        # moving an existing issue
2492
        if @parent_issue.root_id != root_id
2493
          # we can always move to another tree
2494
        elsif move_possible?(@parent_issue)
2495
          # move accepted inside tree
2496
        else
2497 1115:433d4f72a19b Chris
          errors.add :parent_issue_id, :invalid
2498
        end
2499
      end
2500
    end
2501
  end
2502
2503
  # Validates the issue against additional workflow requirements
2504
  def validate_required_fields
2505
    user = new_record? ? author : current_journal.try(:user)
2506
2507
    required_attribute_names(user).each do |attribute|
2508
      if attribute =~ /^\d+$/
2509
        attribute = attribute.to_i
2510
        v = custom_field_values.detect {|v| v.custom_field_id == attribute }
2511
        if v && v.value.blank?
2512
          errors.add :base, v.custom_field.name + ' ' + l('activerecord.errors.messages.blank')
2513
        end
2514
      else
2515
        if respond_to?(attribute) && send(attribute).blank?
2516
          errors.add attribute, :blank
2517 0:513646585e45 Chris
        end
2518
      end
2519
    end
2520
  end
2521 441:cbce1fd3b1b7 Chris
2522 0:513646585e45 Chris
  # Set the done_ratio using the status if that setting is set.  This will keep the done_ratios
2523
  # even if the user turns off the setting later
2524
  def update_done_ratio_from_issue_status
2525 37:94944d00e43c chris
    if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
2526 0:513646585e45 Chris
      self.done_ratio = status.default_done_ratio
2527
    end
2528
  end
2529 441:cbce1fd3b1b7 Chris
2530 0:513646585e45 Chris
  def init_journal(user, notes = "")
2531
    @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
2532 1115:433d4f72a19b Chris
    if new_record?
2533
      @current_journal.notify = false
2534
    else
2535
      @attributes_before_change = attributes.dup
2536
      @custom_values_before_change = {}
2537
      self.custom_field_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
2538
    end
2539 0:513646585e45 Chris
    @current_journal
2540
  end
2541 441:cbce1fd3b1b7 Chris
2542 1115:433d4f72a19b Chris
  # Returns the id of the last journal or nil
2543
  def last_journal_id
2544
    if new_record?
2545
      nil
2546
    else
2547
      journals.maximum(:id)
2548
    end
2549
  end
2550
2551
  # Returns a scope for journals that have an id greater than journal_id
2552
  def journals_after(journal_id)
2553
    scope = journals.reorder("#{Journal.table_name}.id ASC")
2554
    if journal_id.present?
2555
      scope = scope.where("#{Journal.table_name}.id > ?", journal_id.to_i)
2556
    end
2557
    scope
2558
  end
2559
2560 1464:261b3d9a4903 Chris
  # Returns the initial status of the issue
2561
  # Returns nil for a new issue
2562
  def status_was
2563
    if status_id_was && status_id_was.to_i > 0
2564
      @status_was ||= IssueStatus.find_by_id(status_id_was)
2565
    end
2566
  end
2567
2568 0:513646585e45 Chris
  # Return true if the issue is closed, otherwise false
2569
  def closed?
2570
    self.status.is_closed?
2571
  end
2572 441:cbce1fd3b1b7 Chris
2573 0:513646585e45 Chris
  # Return true if the issue is being reopened
2574
  def reopened?
2575
    if !new_record? && status_id_changed?
2576
      status_was = IssueStatus.find_by_id(status_id_was)
2577
      status_new = IssueStatus.find_by_id(status_id)
2578
      if status_was && status_new && status_was.is_closed? && !status_new.is_closed?
2579
        return true
2580
      end
2581
    end
2582
    false
2583
  end
2584
2585
  # Return true if the issue is being closed
2586
  def closing?
2587
    if !new_record? && status_id_changed?
2588 1464:261b3d9a4903 Chris
      if status_was && status && !status_was.is_closed? && status.is_closed?
2589 0:513646585e45 Chris
        return true
2590
      end
2591
    end
2592
    false
2593
  end
2594 441:cbce1fd3b1b7 Chris
2595 0:513646585e45 Chris
  # Returns true if the issue is overdue
2596
  def overdue?
2597
    !due_date.nil? && (due_date < Date.today) && !status.is_closed?
2598
  end
2599 22:40f7cfd4df19 chris
2600
  # Is the amount of work done less than it should for the due date
2601
  def behind_schedule?
2602
    return false if start_date.nil? || due_date.nil?
2603 1464:261b3d9a4903 Chris
    done_date = start_date + ((due_date - start_date + 1) * done_ratio / 100).floor
2604 22:40f7cfd4df19 chris
    return done_date <= Date.today
2605
  end
2606
2607
  # Does this issue have children?
2608
  def children?
2609
    !leaf?
2610
  end
2611 441:cbce1fd3b1b7 Chris
2612 0:513646585e45 Chris
  # Users the issue can be assigned to
2613
  def assignable_users
2614 37:94944d00e43c chris
    users = project.assignable_users
2615
    users << author if author
2616 909:cbb26bc654de Chris
    users << assigned_to if assigned_to
2617 37:94944d00e43c chris
    users.uniq.sort
2618 0:513646585e45 Chris
  end
2619 441:cbce1fd3b1b7 Chris
2620 0:513646585e45 Chris
  # Versions that the issue can be assigned to
2621
  def assignable_versions
2622 1115:433d4f72a19b Chris
    return @assignable_versions if @assignable_versions
2623
2624
    versions = project.shared_versions.open.all
2625
    if fixed_version
2626
      if fixed_version_id_changed?
2627
        # nothing to do
2628
      elsif project_id_changed?
2629
        if project.shared_versions.include?(fixed_version)
2630
          versions << fixed_version
2631
        end
2632
      else
2633
        versions << fixed_version
2634
      end
2635
    end
2636
    @assignable_versions = versions.uniq.sort
2637 0:513646585e45 Chris
  end
2638 441:cbce1fd3b1b7 Chris
2639 0:513646585e45 Chris
  # Returns true if this issue is blocked by another issue that is still open
2640
  def blocked?
2641
    !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
2642
  end
2643 441:cbce1fd3b1b7 Chris
2644 1115:433d4f72a19b Chris
  # Returns an array of statuses that user is able to apply
2645
  def new_statuses_allowed_to(user=User.current, include_default=false)
2646
    if new_record? && @copied_from
2647
      [IssueStatus.default, @copied_from.status].compact.uniq.sort
2648
    else
2649
      initial_status = nil
2650
      if new_record?
2651
        initial_status = IssueStatus.default
2652
      elsif status_id_was
2653
        initial_status = IssueStatus.find_by_id(status_id_was)
2654
      end
2655
      initial_status ||= status
2656 1464:261b3d9a4903 Chris
2657
      initial_assigned_to_id = assigned_to_id_changed? ? assigned_to_id_was : assigned_to_id
2658
      assignee_transitions_allowed = initial_assigned_to_id.present? &&
2659
        (user.id == initial_assigned_to_id || user.group_ids.include?(initial_assigned_to_id))
2660
2661 1115:433d4f72a19b Chris
      statuses = initial_status.find_new_statuses_allowed_to(
2662
        user.admin ? Role.all : user.roles_for_project(project),
2663
        tracker,
2664
        author == user,
2665 1464:261b3d9a4903 Chris
        assignee_transitions_allowed
2666 1115:433d4f72a19b Chris
        )
2667
      statuses << initial_status unless statuses.empty?
2668
      statuses << IssueStatus.default if include_default
2669
      statuses = statuses.compact.uniq.sort
2670
      blocked? ? statuses.reject {|s| s.is_closed?} : statuses
2671
    end
2672 0:513646585e45 Chris
  end
2673 441:cbce1fd3b1b7 Chris
2674 1464:261b3d9a4903 Chris
  # Returns the previous assignee if changed
2675 1115:433d4f72a19b Chris
  def assigned_to_was
2676 1464:261b3d9a4903 Chris
    # assigned_to_id_was is reset before after_save callbacks
2677
    user_id = @previous_assigned_to_id || assigned_to_id_was
2678
    if user_id && user_id != assigned_to_id
2679
      @assigned_to_was ||= User.find_by_id(user_id)
2680 1115:433d4f72a19b Chris
    end
2681
  end
2682
2683
  # Returns the users that should be notified
2684
  def notified_users
2685
    notified = []
2686 37:94944d00e43c chris
    # Author and assignee are always notified unless they have been
2687
    # locked or don't want to be notified
2688 1115:433d4f72a19b Chris
    notified << author if author
2689 909:cbb26bc654de Chris
    if assigned_to
2690 1115:433d4f72a19b Chris
      notified += (assigned_to.is_a?(Group) ? assigned_to.users : [assigned_to])
2691 909:cbb26bc654de Chris
    end
2692 1115:433d4f72a19b Chris
    if assigned_to_was
2693
      notified += (assigned_to_was.is_a?(Group) ? assigned_to_was.users : [assigned_to_was])
2694
    end
2695
    notified = notified.select {|u| u.active? && u.notify_about?(self)}
2696
2697
    notified += project.notified_users
2698 0:513646585e45 Chris
    notified.uniq!
2699
    # Remove users that can not view the issue
2700
    notified.reject! {|user| !visible?(user)}
2701 1115:433d4f72a19b Chris
    notified
2702
  end
2703
2704
  # Returns the email addresses that should be notified
2705
  def recipients
2706
    notified_users.collect(&:mail)
2707
  end
2708
2709 1464:261b3d9a4903 Chris
  def each_notification(users, &block)
2710
    if users.any?
2711
      if custom_field_values.detect {|value| !value.custom_field.visible?}
2712
        users_by_custom_field_visibility = users.group_by do |user|
2713
          visible_custom_field_values(user).map(&:custom_field_id).sort
2714
        end
2715
        users_by_custom_field_visibility.values.each do |users|
2716
          yield(users)
2717
        end
2718
      else
2719
        yield(users)
2720
      end
2721
    end
2722
  end
2723
2724 1115:433d4f72a19b Chris
  # Returns the number of hours spent on this issue
2725
  def spent_hours
2726
    @spent_hours ||= time_entries.sum(:hours) || 0
2727 0:513646585e45 Chris
  end
2728 441:cbce1fd3b1b7 Chris
2729 0:513646585e45 Chris
  # Returns the total number of hours spent on this issue and its descendants
2730
  #
2731
  # Example:
2732
  #   spent_hours => 0.0
2733
  #   spent_hours => 50.2
2734 1115:433d4f72a19b Chris
  def total_spent_hours
2735 1517:dffacf8a6908 Chris
    @total_spent_hours ||=
2736
      self_and_descendants.
2737
        joins("LEFT JOIN #{TimeEntry.table_name} ON #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id").
2738
        sum("#{TimeEntry.table_name}.hours").to_f || 0.0
2739 0:513646585e45 Chris
  end
2740 441:cbce1fd3b1b7 Chris
2741 0:513646585e45 Chris
  def relations
2742 1464:261b3d9a4903 Chris
    @relations ||= IssueRelation::Relations.new(self, (relations_from + relations_to).sort)
2743 909:cbb26bc654de Chris
  end
2744
2745
  # Preloads relations for a collection of issues
2746
  def self.load_relations(issues)
2747
    if issues.any?
2748 1464:261b3d9a4903 Chris
      relations = IssueRelation.where("issue_from_id IN (:ids) OR issue_to_id IN (:ids)", :ids => issues.map(&:id)).all
2749 909:cbb26bc654de Chris
      issues.each do |issue|
2750
        issue.instance_variable_set "@relations", relations.select {|r| r.issue_from_id == issue.id || r.issue_to_id == issue.id}
2751
      end
2752
    end
2753
  end
2754
2755 1115:433d4f72a19b Chris
  # Preloads visible spent time for a collection of issues
2756
  def self.load_visible_spent_hours(issues, user=User.current)
2757
    if issues.any?
2758 1464:261b3d9a4903 Chris
      hours_by_issue_id = TimeEntry.visible(user).group(:issue_id).sum(:hours)
2759 1115:433d4f72a19b Chris
      issues.each do |issue|
2760
        issue.instance_variable_set "@spent_hours", (hours_by_issue_id[issue.id] || 0)
2761
      end
2762
    end
2763
  end
2764
2765
  # Preloads visible relations for a collection of issues
2766
  def self.load_visible_relations(issues, user=User.current)
2767
    if issues.any?
2768
      issue_ids = issues.map(&:id)
2769
      # Relations with issue_from in given issues and visible issue_to
2770
      relations_from = IssueRelation.includes(:issue_to => [:status, :project]).where(visible_condition(user)).where(:issue_from_id => issue_ids).all
2771
      # Relations with issue_to in given issues and visible issue_from
2772
      relations_to = IssueRelation.includes(:issue_from => [:status, :project]).where(visible_condition(user)).where(:issue_to_id => issue_ids).all
2773
2774
      issues.each do |issue|
2775
        relations =
2776
          relations_from.select {|relation| relation.issue_from_id == issue.id} +
2777
          relations_to.select {|relation| relation.issue_to_id == issue.id}
2778
2779 1464:261b3d9a4903 Chris
        issue.instance_variable_set "@relations", IssueRelation::Relations.new(issue, relations.sort)
2780 1115:433d4f72a19b Chris
      end
2781
    end
2782
  end
2783
2784 909:cbb26bc654de Chris
  # Finds an issue relation given its id.
2785
  def find_relation(relation_id)
2786 1464:261b3d9a4903 Chris
    IssueRelation.where("issue_to_id = ? OR issue_from_id = ?", id, id).find(relation_id)
2787 0:513646585e45 Chris
  end
2788 441:cbce1fd3b1b7 Chris
2789 1464:261b3d9a4903 Chris
  # Returns all the other issues that depend on the issue
2790
  # The algorithm is a modified breadth first search (bfs)
2791 441:cbce1fd3b1b7 Chris
  def all_dependent_issues(except=[])
2792 1464:261b3d9a4903 Chris
    # The found dependencies
2793 0:513646585e45 Chris
    dependencies = []
2794 1464:261b3d9a4903 Chris
2795
    # The visited flag for every node (issue) used by the breadth first search
2796
    eNOT_DISCOVERED         = 0       # The issue is "new" to the algorithm, it has not seen it before.
2797
2798
    ePROCESS_ALL            = 1       # The issue is added to the queue. Process both children and relations of
2799
                                      # the issue when it is processed.
2800
2801
    ePROCESS_RELATIONS_ONLY = 2       # The issue was added to the queue and will be output as dependent issue,
2802
                                      # but its children will not be added to the queue when it is processed.
2803
2804
    eRELATIONS_PROCESSED    = 3       # The related issues, the parent issue and the issue itself have been added to
2805
                                      # the queue, but its children have not been added.
2806
2807
    ePROCESS_CHILDREN_ONLY  = 4       # The relations and the parent of the issue have been added to the queue, but
2808
                                      # the children still need to be processed.
2809
2810
    eALL_PROCESSED          = 5       # The issue and all its children, its parent and its related issues have been
2811
                                      # added as dependent issues. It needs no further processing.
2812
2813
    issue_status = Hash.new(eNOT_DISCOVERED)
2814
2815
    # The queue
2816
    queue = []
2817
2818
    # Initialize the bfs, add start node (self) to the queue
2819
    queue << self
2820
    issue_status[self] = ePROCESS_ALL
2821
2822
    while (!queue.empty?) do
2823
      current_issue = queue.shift
2824
      current_issue_status = issue_status[current_issue]
2825
      dependencies << current_issue
2826
2827
      # Add parent to queue, if not already in it.
2828
      parent = current_issue.parent
2829
      parent_status = issue_status[parent]
2830
2831
      if parent && (parent_status == eNOT_DISCOVERED) && !except.include?(parent)
2832
        queue << parent
2833
        issue_status[parent] = ePROCESS_RELATIONS_ONLY
2834 128:07fa8a8b56a8 Chris
      end
2835 1464:261b3d9a4903 Chris
2836
      # Add children to queue, but only if they are not already in it and
2837
      # the children of the current node need to be processed.
2838
      if (current_issue_status == ePROCESS_CHILDREN_ONLY || current_issue_status == ePROCESS_ALL)
2839
        current_issue.children.each do |child|
2840
          next if except.include?(child)
2841
2842
          if (issue_status[child] == eNOT_DISCOVERED)
2843
            queue << child
2844
            issue_status[child] = ePROCESS_ALL
2845
          elsif (issue_status[child] == eRELATIONS_PROCESSED)
2846
            queue << child
2847
            issue_status[child] = ePROCESS_CHILDREN_ONLY
2848
          elsif (issue_status[child] == ePROCESS_RELATIONS_ONLY)
2849
            queue << child
2850
            issue_status[child] = ePROCESS_ALL
2851
          end
2852
        end
2853
      end
2854
2855
      # Add related issues to the queue, if they are not already in it.
2856
      current_issue.relations_from.map(&:issue_to).each do |related_issue|
2857
        next if except.include?(related_issue)
2858
2859
        if (issue_status[related_issue] == eNOT_DISCOVERED)
2860
          queue << related_issue
2861
          issue_status[related_issue] = ePROCESS_ALL
2862
        elsif (issue_status[related_issue] == eRELATIONS_PROCESSED)
2863
          queue << related_issue
2864
          issue_status[related_issue] = ePROCESS_CHILDREN_ONLY
2865
        elsif (issue_status[related_issue] == ePROCESS_RELATIONS_ONLY)
2866
          queue << related_issue
2867
          issue_status[related_issue] = ePROCESS_ALL
2868
        end
2869
      end
2870
2871
      # Set new status for current issue
2872
      if (current_issue_status == ePROCESS_ALL) || (current_issue_status == ePROCESS_CHILDREN_ONLY)
2873
        issue_status[current_issue] = eALL_PROCESSED
2874
      elsif (current_issue_status == ePROCESS_RELATIONS_ONLY)
2875
        issue_status[current_issue] = eRELATIONS_PROCESSED
2876
      end
2877
    end # while
2878
2879
    # Remove the issues from the "except" parameter from the result array
2880
    dependencies -= except
2881
    dependencies.delete(self)
2882
2883 0:513646585e45 Chris
    dependencies
2884
  end
2885 441:cbce1fd3b1b7 Chris
2886 0:513646585e45 Chris
  # Returns an array of issues that duplicate this one
2887
  def duplicates
2888
    relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
2889
  end
2890 441:cbce1fd3b1b7 Chris
2891 0:513646585e45 Chris
  # Returns the due date or the target due date if any
2892
  # Used on gantt chart
2893
  def due_before
2894
    due_date || (fixed_version ? fixed_version.effective_date : nil)
2895
  end
2896 441:cbce1fd3b1b7 Chris
2897 0:513646585e45 Chris
  # Returns the time scheduled for this issue.
2898 441:cbce1fd3b1b7 Chris
  #
2899 0:513646585e45 Chris
  # Example:
2900
  #   Start Date: 2/26/09, End Date: 3/04/09
2901
  #   duration => 6
2902
  def duration
2903
    (start_date && due_date) ? due_date - start_date : 0
2904
  end
2905 441:cbce1fd3b1b7 Chris
2906 1115:433d4f72a19b Chris
  # Returns the duration in working days
2907
  def working_duration
2908
    (start_date && due_date) ? working_days(start_date, due_date) : 0
2909
  end
2910
2911
  def soonest_start(reload=false)
2912
    @soonest_start = nil if reload
2913 0:513646585e45 Chris
    @soonest_start ||= (
2914 1115:433d4f72a19b Chris
        relations_to(reload).collect{|relation| relation.successor_soonest_start} +
2915 1464:261b3d9a4903 Chris
        [(@parent_issue || parent).try(:soonest_start)]
2916 0:513646585e45 Chris
      ).compact.max
2917
  end
2918 441:cbce1fd3b1b7 Chris
2919 1115:433d4f72a19b Chris
  # Sets start_date on the given date or the next working day
2920
  # and changes due_date to keep the same working duration.
2921
  def reschedule_on(date)
2922
    wd = working_duration
2923
    date = next_working_date(date)
2924
    self.start_date = date
2925
    self.due_date = add_working_days(date, wd)
2926
  end
2927
2928
  # Reschedules the issue on the given date or the next working day and saves the record.
2929
  # If the issue is a parent task, this is done by rescheduling its subtasks.
2930
  def reschedule_on!(date)
2931 0:513646585e45 Chris
    return if date.nil?
2932
    if leaf?
2933 1115:433d4f72a19b Chris
      if start_date.nil? || start_date != date
2934
        if start_date && start_date > date
2935
          # Issue can not be moved earlier than its soonest start date
2936
          date = [soonest_start(true), date].compact.max
2937
        end
2938
        reschedule_on(date)
2939
        begin
2940
          save
2941
        rescue ActiveRecord::StaleObjectError
2942
          reload
2943
          reschedule_on(date)
2944
          save
2945
        end
2946 0:513646585e45 Chris
      end
2947
    else
2948
      leaves.each do |leaf|
2949 1115:433d4f72a19b Chris
        if leaf.start_date
2950
          # Only move subtask if it starts at the same date as the parent
2951
          # or if it starts before the given date
2952
          if start_date == leaf.start_date || date > leaf.start_date
2953
            leaf.reschedule_on!(date)
2954
          end
2955
        else
2956
          leaf.reschedule_on!(date)
2957
        end
2958 0:513646585e45 Chris
      end
2959
    end
2960
  end
2961 441:cbce1fd3b1b7 Chris
2962 0:513646585e45 Chris
  def <=>(issue)
2963
    if issue.nil?
2964
      -1
2965
    elsif root_id != issue.root_id
2966
      (root_id || 0) <=> (issue.root_id || 0)
2967
    else
2968
      (lft || 0) <=> (issue.lft || 0)
2969
    end
2970
  end
2971 441:cbce1fd3b1b7 Chris
2972 0:513646585e45 Chris
  def to_s
2973
    "#{tracker} ##{id}: #{subject}"
2974
  end
2975 441:cbce1fd3b1b7 Chris
2976 0:513646585e45 Chris
  # Returns a string of css classes that apply to the issue
2977 1464:261b3d9a4903 Chris
  def css_classes(user=User.current)
2978
    s = "issue tracker-#{tracker_id} status-#{status_id} #{priority.try(:css_classes)}"
2979 0:513646585e45 Chris
    s << ' closed' if closed?
2980
    s << ' overdue' if overdue?
2981 441:cbce1fd3b1b7 Chris
    s << ' child' if child?
2982
    s << ' parent' unless leaf?
2983
    s << ' private' if is_private?
2984 1464:261b3d9a4903 Chris
    if user.logged?
2985
      s << ' created-by-me' if author_id == user.id
2986
      s << ' assigned-to-me' if assigned_to_id == user.id
2987 1494:e248c7af89ec Chris
      s << ' assigned-to-my-group' if user.groups.any? {|g| g.id == assigned_to_id}
2988 1464:261b3d9a4903 Chris
    end
2989 0:513646585e45 Chris
    s
2990
  end
2991
2992
  # Unassigns issues from +version+ if it's no longer shared with issue's project
2993
  def self.update_versions_from_sharing_change(version)
2994
    # Update issues assigned to the version
2995
    update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
2996
  end
2997 441:cbce1fd3b1b7 Chris
2998 0:513646585e45 Chris
  # Unassigns issues from versions that are no longer shared
2999
  # after +project+ was moved
3000
  def self.update_versions_from_hierarchy_change(project)
3001
    moved_project_ids = project.self_and_descendants.reload.collect(&:id)
3002
    # Update issues of the moved projects and issues assigned to a version of a moved project
3003
    Issue.update_versions(["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)", moved_project_ids, moved_project_ids])
3004
  end
3005
3006
  def parent_issue_id=(arg)
3007 1115:433d4f72a19b Chris
    s = arg.to_s.strip.presence
3008
    if s && (m = s.match(%r{\A#?(\d+)\z})) && (@parent_issue = Issue.find_by_id(m[1]))
3009 0:513646585e45 Chris
      @parent_issue.id
3010 1464:261b3d9a4903 Chris
      @invalid_parent_issue_id = nil
3011
    elsif s.blank?
3012
      @parent_issue = nil
3013
      @invalid_parent_issue_id = nil
3014 0:513646585e45 Chris
    else
3015
      @parent_issue = nil
3016 1115:433d4f72a19b Chris
      @invalid_parent_issue_id = arg
3017 0:513646585e45 Chris
    end
3018
  end
3019 441:cbce1fd3b1b7 Chris
3020 0:513646585e45 Chris
  def parent_issue_id
3021 1115:433d4f72a19b Chris
    if @invalid_parent_issue_id
3022
      @invalid_parent_issue_id
3023
    elsif instance_variable_defined? :@parent_issue
3024 0:513646585e45 Chris
      @parent_issue.nil? ? nil : @parent_issue.id
3025
    else
3026
      parent_id
3027
    end
3028
  end
3029
3030 1464:261b3d9a4903 Chris
  # Returns true if issue's project is a valid
3031
  # parent issue project
3032 1115:433d4f72a19b Chris
  def valid_parent_project?(issue=parent)
3033
    return true if issue.nil? || issue.project_id == project_id
3034
3035
    case Setting.cross_project_subtasks
3036
    when 'system'
3037
      true
3038
    when 'tree'
3039
      issue.project.root == project.root
3040
    when 'hierarchy'
3041
      issue.project.is_or_is_ancestor_of?(project) || issue.project.is_descendant_of?(project)
3042
    when 'descendants'
3043
      issue.project.is_or_is_ancestor_of?(project)
3044
    else
3045
      false
3046
    end
3047
  end
3048
3049 0:513646585e45 Chris
  # Extracted from the ReportsController.
3050
  def self.by_tracker(project)
3051
    count_and_group_by(:project => project,
3052
                       :field => 'tracker_id',
3053
                       :joins => Tracker.table_name)
3054
  end
3055
3056
  def self.by_version(project)
3057
    count_and_group_by(:project => project,
3058
                       :field => 'fixed_version_id',
3059
                       :joins => Version.table_name)
3060
  end
3061
3062
  def self.by_priority(project)
3063
    count_and_group_by(:project => project,
3064
                       :field => 'priority_id',
3065
                       :joins => IssuePriority.table_name)
3066
  end
3067
3068
  def self.by_category(project)
3069
    count_and_group_by(:project => project,
3070
                       :field => 'category_id',
3071
                       :joins => IssueCategory.table_name)
3072
  end
3073
3074
  def self.by_assigned_to(project)
3075
    count_and_group_by(:project => project,
3076
                       :field => 'assigned_to_id',
3077
                       :joins => User.table_name)
3078
  end
3079
3080
  def self.by_author(project)
3081
    count_and_group_by(:project => project,
3082
                       :field => 'author_id',
3083
                       :joins => User.table_name)
3084
  end
3085
3086
  def self.by_subproject(project)
3087
    ActiveRecord::Base.connection.select_all("select    s.id as status_id,
3088
                                                s.is_closed as closed,
3089 441:cbce1fd3b1b7 Chris
                                                #{Issue.table_name}.project_id as project_id,
3090
                                                count(#{Issue.table_name}.id) as total
3091 0:513646585e45 Chris
                                              from
3092 441:cbce1fd3b1b7 Chris
                                                #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s
3093 0:513646585e45 Chris
                                              where
3094 441:cbce1fd3b1b7 Chris
                                                #{Issue.table_name}.status_id=s.id
3095
                                                and #{Issue.table_name}.project_id = #{Project.table_name}.id
3096
                                                and #{visible_condition(User.current, :project => project, :with_subprojects => true)}
3097
                                                and #{Issue.table_name}.project_id <> #{project.id}
3098
                                              group by s.id, s.is_closed, #{Issue.table_name}.project_id") if project.descendants.active.any?
3099 0:513646585e45 Chris
  end
3100
  # End ReportsController extraction
3101 441:cbce1fd3b1b7 Chris
3102 1464:261b3d9a4903 Chris
  # Returns a scope of projects that user can assign the issue to
3103 1115:433d4f72a19b Chris
  def allowed_target_projects(user=User.current)
3104
    if new_record?
3105 1464:261b3d9a4903 Chris
      Project.where(Project.allowed_to_condition(user, :add_issues))
3106 1115:433d4f72a19b Chris
    else
3107
      self.class.allowed_target_projects_on_move(user)
3108 0:513646585e45 Chris
    end
3109 1115:433d4f72a19b Chris
  end
3110
3111 1464:261b3d9a4903 Chris
  # Returns a scope of projects that user can move issues to
3112 1115:433d4f72a19b Chris
  def self.allowed_target_projects_on_move(user=User.current)
3113 1464:261b3d9a4903 Chris
    Project.where(Project.allowed_to_condition(user, :move_issues))
3114 0:513646585e45 Chris
  end
3115 441:cbce1fd3b1b7 Chris
3116 0:513646585e45 Chris
  private
3117 441:cbce1fd3b1b7 Chris
3118 1115:433d4f72a19b Chris
  def after_project_change
3119
    # Update project_id on related time entries
3120 1517:dffacf8a6908 Chris
    TimeEntry.where({:issue_id => id}).update_all(["project_id = ?", project_id])
3121 1115:433d4f72a19b Chris
3122
    # Delete issue relations
3123
    unless Setting.cross_project_issue_relations?
3124
      relations_from.clear
3125
      relations_to.clear
3126
    end
3127
3128
    # Move subtasks that were in the same project
3129
    children.each do |child|
3130
      next unless child.project_id == project_id_was
3131
      # Change project and keep project
3132
      child.send :project=, project, true
3133
      unless child.save
3134
        raise ActiveRecord::Rollback
3135
      end
3136
    end
3137
  end
3138
3139
  # Callback for after the creation of an issue by copy
3140
  # * adds a "copied to" relation with the copied issue
3141
  # * copies subtasks from the copied issue
3142
  def after_create_from_copy
3143
    return unless copy? && !@after_create_from_copy_handled
3144
3145
    if (@copied_from.project_id == project_id || Setting.cross_project_issue_relations?) && @copy_options[:link] != false
3146
      relation = IssueRelation.new(:issue_from => @copied_from, :issue_to => self, :relation_type => IssueRelation::TYPE_COPIED_TO)
3147
      unless relation.save
3148
        logger.error "Could not create relation while copying ##{@copied_from.id} to ##{id} due to validation errors: #{relation.errors.full_messages.join(', ')}" if logger
3149
      end
3150
    end
3151
3152
    unless @copied_from.leaf? || @copy_options[:subtasks] == false
3153 1464:261b3d9a4903 Chris
      copy_options = (@copy_options || {}).merge(:subtasks => false)
3154
      copied_issue_ids = {@copied_from.id => self.id}
3155
      @copied_from.reload.descendants.reorder("#{Issue.table_name}.lft").each do |child|
3156
        # Do not copy self when copying an issue as a descendant of the copied issue
3157
        next if child == self
3158
        # Do not copy subtasks of issues that were not copied
3159
        next unless copied_issue_ids[child.parent_id]
3160
        # Do not copy subtasks that are not visible to avoid potential disclosure of private data
3161 1115:433d4f72a19b Chris
        unless child.visible?
3162
          logger.error "Subtask ##{child.id} was not copied during ##{@copied_from.id} copy because it is not visible to the current user" if logger
3163
          next
3164
        end
3165 1464:261b3d9a4903 Chris
        copy = Issue.new.copy_from(child, copy_options)
3166 1115:433d4f72a19b Chris
        copy.author = author
3167
        copy.project = project
3168 1464:261b3d9a4903 Chris
        copy.parent_issue_id = copied_issue_ids[child.parent_id]
3169 1115:433d4f72a19b Chris
        unless copy.save
3170
          logger.error "Could not copy subtask ##{child.id} while copying ##{@copied_from.id} to ##{id} due to validation errors: #{copy.errors.full_messages.join(', ')}" if logger
3171 1464:261b3d9a4903 Chris
          next
3172 1115:433d4f72a19b Chris
        end
3173 1464:261b3d9a4903 Chris
        copied_issue_ids[child.id] = copy.id
3174 1115:433d4f72a19b Chris
      end
3175
    end
3176
    @after_create_from_copy_handled = true
3177
  end
3178
3179 0:513646585e45 Chris
  def update_nested_set_attributes
3180
    if root_id.nil?
3181
      # issue was just created
3182
      self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
3183
      set_default_left_and_right
3184 1517:dffacf8a6908 Chris
      Issue.where(["id = ?", id]).
3185
        update_all(["root_id = ?, lft = ?, rgt = ?", root_id, lft, rgt])
3186 0:513646585e45 Chris
      if @parent_issue
3187
        move_to_child_of(@parent_issue)
3188
      end
3189
    elsif parent_issue_id != parent_id
3190 1464:261b3d9a4903 Chris
      update_nested_set_attributes_on_parent_change
3191 0:513646585e45 Chris
    end
3192
    remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
3193
  end
3194 441:cbce1fd3b1b7 Chris
3195 1464:261b3d9a4903 Chris
  # Updates the nested set for when an existing issue is moved
3196
  def update_nested_set_attributes_on_parent_change
3197
    former_parent_id = parent_id
3198
    # moving an existing issue
3199
    if @parent_issue && @parent_issue.root_id == root_id
3200
      # inside the same tree
3201
      move_to_child_of(@parent_issue)
3202
    else
3203
      # to another tree
3204
      unless root?
3205
        move_to_right_of(root)
3206
      end
3207
      old_root_id = root_id
3208
      self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id )
3209
      target_maxright = nested_set_scope.maximum(right_column_name) || 0
3210
      offset = target_maxright + 1 - lft
3211 1517:dffacf8a6908 Chris
      Issue.where(["root_id = ? AND lft >= ? AND rgt <= ? ", old_root_id, lft, rgt]).
3212
        update_all(["root_id = ?, lft = lft + ?, rgt = rgt + ?", root_id, offset, offset])
3213 1464:261b3d9a4903 Chris
      self[left_column_name] = lft + offset
3214
      self[right_column_name] = rgt + offset
3215
      if @parent_issue
3216
        move_to_child_of(@parent_issue)
3217
      end
3218
    end
3219
    # delete invalid relations of all descendants
3220
    self_and_descendants.each do |issue|
3221
      issue.relations.each do |relation|
3222
        relation.destroy unless relation.valid?
3223
      end
3224
    end
3225
    # update former parent
3226
    recalculate_attributes_for(former_parent_id) if former_parent_id
3227
  end
3228
3229 0:513646585e45 Chris
  def update_parent_attributes
3230
    recalculate_attributes_for(parent_id) if parent_id
3231
  end
3232
3233
  def recalculate_attributes_for(issue_id)
3234
    if issue_id && p = Issue.find_by_id(issue_id)
3235
      # priority = highest priority of children
3236 1517:dffacf8a6908 Chris
      if priority_position = p.children.joins(:priority).maximum("#{IssuePriority.table_name}.position")
3237 0:513646585e45 Chris
        p.priority = IssuePriority.find_by_position(priority_position)
3238
      end
3239 441:cbce1fd3b1b7 Chris
3240 0:513646585e45 Chris
      # start/due dates = lowest/highest dates of children
3241
      p.start_date = p.children.minimum(:start_date)
3242
      p.due_date = p.children.maximum(:due_date)
3243
      if p.start_date && p.due_date && p.due_date < p.start_date
3244
        p.start_date, p.due_date = p.due_date, p.start_date
3245
      end
3246 441:cbce1fd3b1b7 Chris
3247 0:513646585e45 Chris
      # done ratio = weighted average ratio of leaves
3248 37:94944d00e43c chris
      unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
3249 0:513646585e45 Chris
        leaves_count = p.leaves.count
3250
        if leaves_count > 0
3251 1494:e248c7af89ec Chris
          average = p.leaves.where("estimated_hours > 0").average(:estimated_hours).to_f
3252 0:513646585e45 Chris
          if average == 0
3253
            average = 1
3254
          end
3255 1517:dffacf8a6908 Chris
          done = p.leaves.joins(:status).
3256
            sum("COALESCE(CASE WHEN estimated_hours > 0 THEN estimated_hours ELSE NULL END, #{average}) " +
3257
                "* (CASE WHEN is_closed = #{connection.quoted_true} THEN 100 ELSE COALESCE(done_ratio, 0) END)").to_f
3258 0:513646585e45 Chris
          progress = done / (average * leaves_count)
3259
          p.done_ratio = progress.round
3260
        end
3261
      end
3262 441:cbce1fd3b1b7 Chris
3263 0:513646585e45 Chris
      # estimate = sum of leaves estimates
3264
      p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
3265
      p.estimated_hours = nil if p.estimated_hours == 0.0
3266 441:cbce1fd3b1b7 Chris
3267 0:513646585e45 Chris
      # ancestors will be recursively updated
3268 1115:433d4f72a19b Chris
      p.save(:validate => false)
3269 0:513646585e45 Chris
    end
3270
  end
3271 441:cbce1fd3b1b7 Chris
3272 0:513646585e45 Chris
  # Update issues so their versions are not pointing to a
3273
  # fixed_version that is not shared with the issue's project
3274
  def self.update_versions(conditions=nil)
3275
    # Only need to update issues with a fixed_version from
3276
    # a different project and that is not systemwide shared
3277 1464:261b3d9a4903 Chris
    Issue.includes(:project, :fixed_version).
3278
      where("#{Issue.table_name}.fixed_version_id IS NOT NULL" +
3279 1115:433d4f72a19b Chris
        " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
3280 1464:261b3d9a4903 Chris
        " AND #{Version.table_name}.sharing <> 'system'").
3281
      where(conditions).each do |issue|
3282 0:513646585e45 Chris
      next if issue.project.nil? || issue.fixed_version.nil?
3283
      unless issue.project.shared_versions.include?(issue.fixed_version)
3284
        issue.init_journal(User.current)
3285
        issue.fixed_version = nil
3286
        issue.save
3287
      end
3288
    end
3289
  end
3290 441:cbce1fd3b1b7 Chris
3291 1115:433d4f72a19b Chris
  # Callback on file attachment
3292 909:cbb26bc654de Chris
  def attachment_added(obj)
3293
    if @current_journal && !obj.new_record?
3294
      @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => obj.id, :value => obj.filename)
3295
    end
3296
  end
3297
3298
  # Callback on attachment deletion
3299 0:513646585e45 Chris
  def attachment_removed(obj)
3300 1115:433d4f72a19b Chris
    if @current_journal && !obj.new_record?
3301
      @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => obj.id, :old_value => obj.filename)
3302
      @current_journal.save
3303
    end
3304 0:513646585e45 Chris
  end
3305 441:cbce1fd3b1b7 Chris
3306 0:513646585e45 Chris
  # Default assignment based on category
3307
  def default_assign
3308
    if assigned_to.nil? && category && category.assigned_to
3309
      self.assigned_to = category.assigned_to
3310
    end
3311
  end
3312
3313
  # Updates start/due dates of following issues
3314
  def reschedule_following_issues
3315
    if start_date_changed? || due_date_changed?
3316
      relations_from.each do |relation|
3317
        relation.set_issue_to_dates
3318
      end
3319
    end
3320
  end
3321
3322
  # Closes duplicates if the issue is being closed
3323
  def close_duplicates
3324
    if closing?
3325
      duplicates.each do |duplicate|
3326
        # Reload is need in case the duplicate was updated by a previous duplicate
3327
        duplicate.reload
3328
        # Don't re-close it if it's already closed
3329
        next if duplicate.closed?
3330
        # Same user and notes
3331
        if @current_journal
3332
          duplicate.init_journal(@current_journal.user, @current_journal.notes)
3333
        end
3334
        duplicate.update_attribute :status, self.status
3335
      end
3336
    end
3337
  end
3338 441:cbce1fd3b1b7 Chris
3339 1464:261b3d9a4903 Chris
  # Make sure updated_on is updated when adding a note and set updated_on now
3340
  # so we can set closed_on with the same value on closing
3341 1115:433d4f72a19b Chris
  def force_updated_on_change
3342 1464:261b3d9a4903 Chris
    if @current_journal || changed?
3343 1115:433d4f72a19b Chris
      self.updated_on = current_time_from_proper_timezone
3344 1464:261b3d9a4903 Chris
      if new_record?
3345
        self.created_on = updated_on
3346
      end
3347
    end
3348
  end
3349
3350
  # Callback for setting closed_on when the issue is closed.
3351
  # The closed_on attribute stores the time of the last closing
3352
  # and is preserved when the issue is reopened.
3353
  def update_closed_on
3354
    if closing? || (new_record? && closed?)
3355
      self.closed_on = updated_on
3356 1115:433d4f72a19b Chris
    end
3357
  end
3358
3359 0:513646585e45 Chris
  # Saves the changes in a Journal
3360
  # Called after_save
3361
  def create_journal
3362
    if @current_journal
3363
      # attributes changes
3364 1115:433d4f72a19b Chris
      if @attributes_before_change
3365 1464:261b3d9a4903 Chris
        (Issue.column_names - %w(id root_id lft rgt lock_version created_on updated_on closed_on)).each {|c|
3366 1115:433d4f72a19b Chris
          before = @attributes_before_change[c]
3367
          after = send(c)
3368
          next if before == after || (before.blank? && after.blank?)
3369
          @current_journal.details << JournalDetail.new(:property => 'attr',
3370
                                                        :prop_key => c,
3371
                                                        :old_value => before,
3372
                                                        :value => after)
3373
        }
3374
      end
3375
      if @custom_values_before_change
3376
        # custom fields changes
3377
        custom_field_values.each {|c|
3378
          before = @custom_values_before_change[c.custom_field_id]
3379
          after = c.value
3380
          next if before == after || (before.blank? && after.blank?)
3381
3382
          if before.is_a?(Array) || after.is_a?(Array)
3383
            before = [before] unless before.is_a?(Array)
3384
            after = [after] unless after.is_a?(Array)
3385
3386
            # values removed
3387
            (before - after).reject(&:blank?).each do |value|
3388
              @current_journal.details << JournalDetail.new(:property => 'cf',
3389
                                                            :prop_key => c.custom_field_id,
3390
                                                            :old_value => value,
3391
                                                            :value => nil)
3392
            end
3393
            # values added
3394
            (after - before).reject(&:blank?).each do |value|
3395
              @current_journal.details << JournalDetail.new(:property => 'cf',
3396
                                                            :prop_key => c.custom_field_id,
3397
                                                            :old_value => nil,
3398
                                                            :value => value)
3399
            end
3400
          else
3401
            @current_journal.details << JournalDetail.new(:property => 'cf',
3402
                                                          :prop_key => c.custom_field_id,
3403
                                                          :old_value => before,
3404
                                                          :value => after)
3405
          end
3406
        }
3407
      end
3408 0:513646585e45 Chris
      @current_journal.save
3409
      # reset current journal
3410
      init_journal @current_journal.user, @current_journal.notes
3411
    end
3412
  end
3413
3414 1464:261b3d9a4903 Chris
  def send_notification
3415
    if Setting.notified_events.include?('issue_added')
3416
      Mailer.deliver_issue_add(self)
3417
    end
3418
  end
3419
3420
  # Stores the previous assignee so we can still have access
3421
  # to it during after_save callbacks (assigned_to_id_was is reset)
3422
  def set_assigned_to_was
3423
    @previous_assigned_to_id = assigned_to_id_was
3424
  end
3425
3426
  # Clears the previous assignee at the end of after_save callbacks
3427
  def clear_assigned_to_was
3428
    @assigned_to_was = nil
3429
    @previous_assigned_to_id = nil
3430
  end
3431
3432 0:513646585e45 Chris
  # Query generator for selecting groups of issue counts for a project
3433
  # based on specific criteria
3434
  #
3435
  # Options
3436
  # * project - Project to search in.
3437
  # * field - String. Issue field to key off of in the grouping.
3438
  # * joins - String. The table name to join against.
3439
  def self.count_and_group_by(options)
3440
    project = options.delete(:project)
3441
    select_field = options.delete(:field)
3442
    joins = options.delete(:joins)
3443
3444 441:cbce1fd3b1b7 Chris
    where = "#{Issue.table_name}.#{select_field}=j.id"
3445
3446 0:513646585e45 Chris
    ActiveRecord::Base.connection.select_all("select    s.id as status_id,
3447
                                                s.is_closed as closed,
3448
                                                j.id as #{select_field},
3449 441:cbce1fd3b1b7 Chris
                                                count(#{Issue.table_name}.id) as total
3450 0:513646585e45 Chris
                                              from
3451 441:cbce1fd3b1b7 Chris
                                                  #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s, #{joins} j
3452 0:513646585e45 Chris
                                              where
3453 441:cbce1fd3b1b7 Chris
                                                #{Issue.table_name}.status_id=s.id
3454 0:513646585e45 Chris
                                                and #{where}
3455 441:cbce1fd3b1b7 Chris
                                                and #{Issue.table_name}.project_id=#{Project.table_name}.id
3456
                                                and #{visible_condition(User.current, :project => project)}
3457 0:513646585e45 Chris
                                              group by s.id, s.is_closed, j.id")
3458
  end
3459
end
3460 507:0c939c159af4 Chris
# Redmine - project management software
3461 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
3462 0:513646585e45 Chris
#
3463
# This program is free software; you can redistribute it and/or
3464
# modify it under the terms of the GNU General Public License
3465
# as published by the Free Software Foundation; either version 2
3466
# of the License, or (at your option) any later version.
3467 909:cbb26bc654de Chris
#
3468 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
3469
# but WITHOUT ANY WARRANTY; without even the implied warranty of
3470
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
3471
# GNU General Public License for more details.
3472 909:cbb26bc654de Chris
#
3473 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
3474
# along with this program; if not, write to the Free Software
3475
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
3476
3477
class IssueCategory < ActiveRecord::Base
3478 929:5f33065ddc4b Chris
  include Redmine::SafeAttributes
3479 0:513646585e45 Chris
  belongs_to :project
3480 909:cbb26bc654de Chris
  belongs_to :assigned_to, :class_name => 'Principal', :foreign_key => 'assigned_to_id'
3481 0:513646585e45 Chris
  has_many :issues, :foreign_key => 'category_id', :dependent => :nullify
3482 909:cbb26bc654de Chris
3483 0:513646585e45 Chris
  validates_presence_of :name
3484
  validates_uniqueness_of :name, :scope => [:project_id]
3485
  validates_length_of :name, :maximum => 30
3486 1464:261b3d9a4903 Chris
3487 929:5f33065ddc4b Chris
  safe_attributes 'name', 'assigned_to_id'
3488 909:cbb26bc654de Chris
3489 1115:433d4f72a19b Chris
  scope :named, lambda {|arg| where("LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip)}
3490 909:cbb26bc654de Chris
3491 0:513646585e45 Chris
  alias :destroy_without_reassign :destroy
3492 909:cbb26bc654de Chris
3493 0:513646585e45 Chris
  # Destroy the category
3494
  # If a category is specified, issues are reassigned to this category
3495
  def destroy(reassign_to = nil)
3496
    if reassign_to && reassign_to.is_a?(IssueCategory) && reassign_to.project == self.project
3497 1517:dffacf8a6908 Chris
      Issue.where({:category_id => id}).update_all({:category_id => reassign_to.id})
3498 0:513646585e45 Chris
    end
3499
    destroy_without_reassign
3500
  end
3501 909:cbb26bc654de Chris
3502 0:513646585e45 Chris
  def <=>(category)
3503
    name <=> category.name
3504
  end
3505 909:cbb26bc654de Chris
3506 0:513646585e45 Chris
  def to_s; name end
3507
end
3508 909:cbb26bc654de Chris
# Redmine - project management software
3509 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
3510 0:513646585e45 Chris
#
3511
# This program is free software; you can redistribute it and/or
3512
# modify it under the terms of the GNU General Public License
3513
# as published by the Free Software Foundation; either version 2
3514
# of the License, or (at your option) any later version.
3515 909:cbb26bc654de Chris
#
3516 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
3517
# but WITHOUT ANY WARRANTY; without even the implied warranty of
3518
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
3519
# GNU General Public License for more details.
3520 909:cbb26bc654de Chris
#
3521 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
3522
# along with this program; if not, write to the Free Software
3523
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
3524
3525
class IssueCustomField < CustomField
3526
  has_and_belongs_to_many :projects, :join_table => "#{table_name_prefix}custom_fields_projects#{table_name_suffix}", :foreign_key => "custom_field_id"
3527
  has_and_belongs_to_many :trackers, :join_table => "#{table_name_prefix}custom_fields_trackers#{table_name_suffix}", :foreign_key => "custom_field_id"
3528
  has_many :issues, :through => :issue_custom_values
3529 909:cbb26bc654de Chris
3530 0:513646585e45 Chris
  def type_name
3531
    :label_issue_plural
3532
  end
3533 1464:261b3d9a4903 Chris
3534
  def visible_by?(project, user=User.current)
3535
    super || (roles & user.roles_for_project(project)).present?
3536
  end
3537
3538 1517:dffacf8a6908 Chris
  def visibility_by_project_condition(project_key=nil, user=User.current, id_column=nil)
3539 1464:261b3d9a4903 Chris
    sql = super
3540 1517:dffacf8a6908 Chris
    id_column ||= id
3541
    tracker_condition = "#{Issue.table_name}.tracker_id IN (SELECT tracker_id FROM #{table_name_prefix}custom_fields_trackers#{table_name_suffix} WHERE custom_field_id = #{id_column})"
3542
    project_condition = "EXISTS (SELECT 1 FROM #{CustomField.table_name} ifa WHERE ifa.is_for_all = #{connection.quoted_true} AND ifa.id = #{id_column})" +
3543
      " OR #{Issue.table_name}.project_id IN (SELECT project_id FROM #{table_name_prefix}custom_fields_projects#{table_name_suffix} WHERE custom_field_id = #{id_column})"
3544
3545
    "((#{sql}) AND (#{tracker_condition}) AND (#{project_condition}))"
3546 1464:261b3d9a4903 Chris
  end
3547
3548
  def validate_custom_field
3549
    super
3550
    errors.add(:base, l(:label_role_plural) + ' ' + l('activerecord.errors.messages.blank')) unless visible? || roles.present?
3551
  end
3552 0:513646585e45 Chris
end
3553 909:cbb26bc654de Chris
# Redmine - project management software
3554 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
3555 0:513646585e45 Chris
#
3556
# This program is free software; you can redistribute it and/or
3557
# modify it under the terms of the GNU General Public License
3558
# as published by the Free Software Foundation; either version 2
3559
# of the License, or (at your option) any later version.
3560 909:cbb26bc654de Chris
#
3561 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
3562
# but WITHOUT ANY WARRANTY; without even the implied warranty of
3563
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
3564
# GNU General Public License for more details.
3565 909:cbb26bc654de Chris
#
3566 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
3567
# along with this program; if not, write to the Free Software
3568
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
3569
3570
class IssuePriority < Enumeration
3571
  has_many :issues, :foreign_key => 'priority_id'
3572
3573 1115:433d4f72a19b Chris
  after_destroy {|priority| priority.class.compute_position_names}
3574
  after_save {|priority| priority.class.compute_position_names if priority.position_changed? && priority.position}
3575
3576 0:513646585e45 Chris
  OptionName = :enumeration_issue_priorities
3577
3578
  def option_name
3579
    OptionName
3580
  end
3581
3582
  def objects_count
3583
    issues.count
3584
  end
3585
3586
  def transfer_relations(to)
3587
    issues.update_all("priority_id = #{to.id}")
3588
  end
3589 1115:433d4f72a19b Chris
3590
  def css_classes
3591
    "priority-#{id} priority-#{position_name}"
3592
  end
3593
3594
  # Clears position_name for all priorities
3595
  # Called from migration 20121026003537_populate_enumerations_position_name
3596
  def self.clear_position_names
3597
    update_all :position_name => nil
3598
  end
3599
3600
  # Updates position_name for active priorities
3601
  # Called from migration 20121026003537_populate_enumerations_position_name
3602
  def self.compute_position_names
3603 1517:dffacf8a6908 Chris
    priorities = where(:active => true).sort_by(&:position)
3604 1115:433d4f72a19b Chris
    if priorities.any?
3605
      default = priorities.detect(&:is_default?) || priorities[(priorities.size - 1) / 2]
3606
      priorities.each_with_index do |priority, index|
3607
        name = case
3608
          when priority.position == default.position
3609
            "default"
3610
          when priority.position < default.position
3611
            index == 0 ? "lowest" : "low#{index+1}"
3612
          else
3613
            index == (priorities.size - 1) ? "highest" : "high#{priorities.size - index}"
3614
          end
3615
3616 1517:dffacf8a6908 Chris
        where(:id => priority.id).update_all({:position_name => name})
3617 1115:433d4f72a19b Chris
      end
3618
    end
3619
  end
3620 0:513646585e45 Chris
end
3621 909:cbb26bc654de Chris
# Redmine - project management software
3622 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
3623 0:513646585e45 Chris
#
3624
# This program is free software; you can redistribute it and/or
3625
# modify it under the terms of the GNU General Public License
3626
# as published by the Free Software Foundation; either version 2
3627
# of the License, or (at your option) any later version.
3628 909:cbb26bc654de Chris
#
3629 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
3630
# but WITHOUT ANY WARRANTY; without even the implied warranty of
3631
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
3632
# GNU General Public License for more details.
3633 909:cbb26bc654de Chris
#
3634 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
3635
# along with this program; if not, write to the Free Software
3636
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
3637
3638
class IssuePriorityCustomField < CustomField
3639
  def type_name
3640
    :enumeration_issue_priorities
3641
  end
3642
end
3643
3644 1464:261b3d9a4903 Chris
# Redmine - project management software
3645 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
3646 1464:261b3d9a4903 Chris
#
3647
# This program is free software; you can redistribute it and/or
3648
# modify it under the terms of the GNU General Public License
3649
# as published by the Free Software Foundation; either version 2
3650
# of the License, or (at your option) any later version.
3651
#
3652
# This program is distributed in the hope that it will be useful,
3653
# but WITHOUT ANY WARRANTY; without even the implied warranty of
3654
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
3655
# GNU General Public License for more details.
3656
#
3657
# You should have received a copy of the GNU General Public License
3658
# along with this program; if not, write to the Free Software
3659
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
3660
3661
class IssueQuery < Query
3662
3663
  self.queried_class = Issue
3664
3665
  self.available_columns = [
3666
    QueryColumn.new(:id, :sortable => "#{Issue.table_name}.id", :default_order => 'desc', :caption => '#', :frozen => true),
3667
    QueryColumn.new(:project, :sortable => "#{Project.table_name}.name", :groupable => true),
3668
    QueryColumn.new(:tracker, :sortable => "#{Tracker.table_name}.position", :groupable => true),
3669
    QueryColumn.new(:parent, :sortable => ["#{Issue.table_name}.root_id", "#{Issue.table_name}.lft ASC"], :default_order => 'desc', :caption => :field_parent_issue),
3670
    QueryColumn.new(:status, :sortable => "#{IssueStatus.table_name}.position", :groupable => true),
3671
    QueryColumn.new(:priority, :sortable => "#{IssuePriority.table_name}.position", :default_order => 'desc', :groupable => true),
3672
    QueryColumn.new(:subject, :sortable => "#{Issue.table_name}.subject"),
3673
    QueryColumn.new(:author, :sortable => lambda {User.fields_for_order_statement("authors")}, :groupable => true),
3674
    QueryColumn.new(:assigned_to, :sortable => lambda {User.fields_for_order_statement}, :groupable => true),
3675
    QueryColumn.new(:updated_on, :sortable => "#{Issue.table_name}.updated_on", :default_order => 'desc'),
3676
    QueryColumn.new(:category, :sortable => "#{IssueCategory.table_name}.name", :groupable => true),
3677
    QueryColumn.new(:fixed_version, :sortable => lambda {Version.fields_for_order_statement}, :groupable => true),
3678
    QueryColumn.new(:start_date, :sortable => "#{Issue.table_name}.start_date"),
3679
    QueryColumn.new(:due_date, :sortable => "#{Issue.table_name}.due_date"),
3680
    QueryColumn.new(:estimated_hours, :sortable => "#{Issue.table_name}.estimated_hours"),
3681
    QueryColumn.new(:done_ratio, :sortable => "#{Issue.table_name}.done_ratio", :groupable => true),
3682
    QueryColumn.new(:created_on, :sortable => "#{Issue.table_name}.created_on", :default_order => 'desc'),
3683
    QueryColumn.new(:closed_on, :sortable => "#{Issue.table_name}.closed_on", :default_order => 'desc'),
3684
    QueryColumn.new(:relations, :caption => :label_related_issues),
3685
    QueryColumn.new(:description, :inline => false)
3686
  ]
3687
3688
  scope :visible, lambda {|*args|
3689
    user = args.shift || User.current
3690
    base = Project.allowed_to_condition(user, :view_issues, *args)
3691
    scope = includes(:project).where("#{table_name}.project_id IS NULL OR (#{base})")
3692
3693
    if user.admin?
3694
      scope.where("#{table_name}.visibility <> ? OR #{table_name}.user_id = ?", VISIBILITY_PRIVATE, user.id)
3695
    elsif user.memberships.any?
3696
      scope.where("#{table_name}.visibility = ?" +
3697
        " OR (#{table_name}.visibility = ? AND #{table_name}.id IN (" +
3698
          "SELECT DISTINCT q.id FROM #{table_name} q" +
3699
          " INNER JOIN #{table_name_prefix}queries_roles#{table_name_suffix} qr on qr.query_id = q.id" +
3700
          " INNER JOIN #{MemberRole.table_name} mr ON mr.role_id = qr.role_id" +
3701
          " INNER JOIN #{Member.table_name} m ON m.id = mr.member_id AND m.user_id = ?" +
3702
          " WHERE q.project_id IS NULL OR q.project_id = m.project_id))" +
3703
        " OR #{table_name}.user_id = ?",
3704
        VISIBILITY_PUBLIC, VISIBILITY_ROLES, user.id, user.id)
3705
    elsif user.logged?
3706
      scope.where("#{table_name}.visibility = ? OR #{table_name}.user_id = ?", VISIBILITY_PUBLIC, user.id)
3707
    else
3708
      scope.where("#{table_name}.visibility = ?", VISIBILITY_PUBLIC)
3709
    end
3710
  }
3711
3712
  def initialize(attributes=nil, *args)
3713
    super attributes
3714
    self.filters ||= { 'status_id' => {:operator => "o", :values => [""]} }
3715
  end
3716
3717
  # Returns true if the query is visible to +user+ or the current user.
3718
  def visible?(user=User.current)
3719
    return true if user.admin?
3720
    return false unless project.nil? || user.allowed_to?(:view_issues, project)
3721
    case visibility
3722
    when VISIBILITY_PUBLIC
3723
      true
3724
    when VISIBILITY_ROLES
3725
      if project
3726
        (user.roles_for_project(project) & roles).any?
3727
      else
3728
        Member.where(:user_id => user.id).joins(:roles).where(:member_roles => {:role_id => roles.map(&:id)}).any?
3729
      end
3730
    else
3731
      user == self.user
3732
    end
3733
  end
3734
3735
  def is_private?
3736
    visibility == VISIBILITY_PRIVATE
3737
  end
3738
3739
  def is_public?
3740
    !is_private?
3741
  end
3742
3743
  def draw_relations
3744
    r = options[:draw_relations]
3745
    r.nil? || r == '1'
3746
  end
3747
3748
  def draw_relations=(arg)
3749
    options[:draw_relations] = (arg == '0' ? '0' : nil)
3750
  end
3751
3752
  def draw_progress_line
3753
    r = options[:draw_progress_line]
3754
    r == '1'
3755
  end
3756
3757
  def draw_progress_line=(arg)
3758
    options[:draw_progress_line] = (arg == '1' ? '1' : nil)
3759
  end
3760
3761
  def build_from_params(params)
3762
    super
3763
    self.draw_relations = params[:draw_relations] || (params[:query] && params[:query][:draw_relations])
3764
    self.draw_progress_line = params[:draw_progress_line] || (params[:query] && params[:query][:draw_progress_line])
3765
    self
3766
  end
3767
3768
  def initialize_available_filters
3769
    principals = []
3770
    subprojects = []
3771
    versions = []
3772
    categories = []
3773
    issue_custom_fields = []
3774
3775
    if project
3776
      principals += project.principals.sort
3777
      unless project.leaf?
3778
        subprojects = project.descendants.visible.all
3779
        principals += Principal.member_of(subprojects)
3780
      end
3781
      versions = project.shared_versions.all
3782
      categories = project.issue_categories.all
3783
      issue_custom_fields = project.all_issue_custom_fields
3784
    else
3785
      if all_projects.any?
3786
        principals += Principal.member_of(all_projects)
3787
      end
3788 1517:dffacf8a6908 Chris
      versions = Version.visible.where(:sharing => 'system').all
3789 1464:261b3d9a4903 Chris
      issue_custom_fields = IssueCustomField.where(:is_for_all => true)
3790
    end
3791
    principals.uniq!
3792
    principals.sort!
3793
    users = principals.select {|p| p.is_a?(User)}
3794
3795
    add_available_filter "status_id",
3796 1517:dffacf8a6908 Chris
      :type => :list_status, :values => IssueStatus.sorted.collect{|s| [s.name, s.id.to_s] }
3797 1464:261b3d9a4903 Chris
3798
    if project.nil?
3799
      project_values = []
3800
      if User.current.logged? && User.current.memberships.any?
3801
        project_values << ["<< #{l(:label_my_projects).downcase} >>", "mine"]
3802
      end
3803
      project_values += all_projects_values
3804
      add_available_filter("project_id",
3805
        :type => :list, :values => project_values
3806
      ) unless project_values.empty?
3807
    end
3808
3809
    add_available_filter "tracker_id",
3810
      :type => :list, :values => trackers.collect{|s| [s.name, s.id.to_s] }
3811
    add_available_filter "priority_id",
3812
      :type => :list, :values => IssuePriority.all.collect{|s| [s.name, s.id.to_s] }
3813
3814
    author_values = []
3815
    author_values << ["<< #{l(:label_me)} >>", "me"] if User.current.logged?
3816
    author_values += users.collect{|s| [s.name, s.id.to_s] }
3817
    add_available_filter("author_id",
3818
      :type => :list, :values => author_values
3819
    ) unless author_values.empty?
3820
3821
    assigned_to_values = []
3822
    assigned_to_values << ["<< #{l(:label_me)} >>", "me"] if User.current.logged?
3823
    assigned_to_values += (Setting.issue_group_assignment? ?
3824
                              principals : users).collect{|s| [s.name, s.id.to_s] }
3825
    add_available_filter("assigned_to_id",
3826
      :type => :list_optional, :values => assigned_to_values
3827
    ) unless assigned_to_values.empty?
3828
3829
    group_values = Group.all.collect {|g| [g.name, g.id.to_s] }
3830
    add_available_filter("member_of_group",
3831
      :type => :list_optional, :values => group_values
3832
    ) unless group_values.empty?
3833
3834
    role_values = Role.givable.collect {|r| [r.name, r.id.to_s] }
3835
    add_available_filter("assigned_to_role",
3836
      :type => :list_optional, :values => role_values
3837
    ) unless role_values.empty?
3838
3839
    if versions.any?
3840
      add_available_filter "fixed_version_id",
3841
        :type => :list_optional,
3842
        :values => versions.sort.collect{|s| ["#{s.project.name} - #{s.name}", s.id.to_s] }
3843
    end
3844
3845
    if categories.any?
3846
      add_available_filter "category_id",
3847
        :type => :list_optional,
3848
        :values => categories.collect{|s| [s.name, s.id.to_s] }
3849
    end
3850
3851
    add_available_filter "subject", :type => :text
3852
    add_available_filter "created_on", :type => :date_past
3853
    add_available_filter "updated_on", :type => :date_past
3854
    add_available_filter "closed_on", :type => :date_past
3855
    add_available_filter "start_date", :type => :date
3856
    add_available_filter "due_date", :type => :date
3857
    add_available_filter "estimated_hours", :type => :float
3858
    add_available_filter "done_ratio", :type => :integer
3859
3860
    if User.current.allowed_to?(:set_issues_private, nil, :global => true) ||
3861
      User.current.allowed_to?(:set_own_issues_private, nil, :global => true)
3862
      add_available_filter "is_private",
3863
        :type => :list,
3864
        :values => [[l(:general_text_yes), "1"], [l(:general_text_no), "0"]]
3865
    end
3866
3867
    if User.current.logged?
3868
      add_available_filter "watcher_id",
3869
        :type => :list, :values => [["<< #{l(:label_me)} >>", "me"]]
3870
    end
3871
3872
    if subprojects.any?
3873
      add_available_filter "subproject_id",
3874
        :type => :list_subprojects,
3875
        :values => subprojects.collect{|s| [s.name, s.id.to_s] }
3876
    end
3877
3878
    add_custom_fields_filters(issue_custom_fields)
3879
3880
    add_associations_custom_fields_filters :project, :author, :assigned_to, :fixed_version
3881
3882
    IssueRelation::TYPES.each do |relation_type, options|
3883
      add_available_filter relation_type, :type => :relation, :label => options[:name]
3884
    end
3885
3886
    Tracker.disabled_core_fields(trackers).each {|field|
3887
      delete_available_filter field
3888
    }
3889
  end
3890
3891
  def available_columns
3892
    return @available_columns if @available_columns
3893
    @available_columns = self.class.available_columns.dup
3894
    @available_columns += (project ?
3895
                            project.all_issue_custom_fields :
3896
                            IssueCustomField
3897
                           ).visible.collect {|cf| QueryCustomFieldColumn.new(cf) }
3898
3899
    if User.current.allowed_to?(:view_time_entries, project, :global => true)
3900
      index = nil
3901
      @available_columns.each_with_index {|column, i| index = i if column.name == :estimated_hours}
3902
      index = (index ? index + 1 : -1)
3903
      # insert the column after estimated_hours or at the end
3904
      @available_columns.insert index, QueryColumn.new(:spent_hours,
3905
        :sortable => "COALESCE((SELECT SUM(hours) FROM #{TimeEntry.table_name} WHERE #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id), 0)",
3906
        :default_order => 'desc',
3907
        :caption => :label_spent_time
3908
      )
3909
    end
3910
3911
    if User.current.allowed_to?(:set_issues_private, nil, :global => true) ||
3912
      User.current.allowed_to?(:set_own_issues_private, nil, :global => true)
3913
      @available_columns << QueryColumn.new(:is_private, :sortable => "#{Issue.table_name}.is_private")
3914
    end
3915
3916
    disabled_fields = Tracker.disabled_core_fields(trackers).map {|field| field.sub(/_id$/, '')}
3917
    @available_columns.reject! {|column|
3918
      disabled_fields.include?(column.name.to_s)
3919
    }
3920
3921
    @available_columns
3922
  end
3923
3924
  def default_columns_names
3925
    @default_columns_names ||= begin
3926
      default_columns = Setting.issue_list_default_columns.map(&:to_sym)
3927
3928
      project.present? ? default_columns : [:project] | default_columns
3929
    end
3930
  end
3931
3932
  # Returns the issue count
3933
  def issue_count
3934
    Issue.visible.joins(:status, :project).where(statement).count
3935
  rescue ::ActiveRecord::StatementInvalid => e
3936
    raise StatementInvalid.new(e.message)
3937
  end
3938
3939
  # Returns the issue count by group or nil if query is not grouped
3940
  def issue_count_by_group
3941
    r = nil
3942
    if grouped?
3943
      begin
3944
        # Rails3 will raise an (unexpected) RecordNotFound if there's only a nil group value
3945
        r = Issue.visible.
3946
          joins(:status, :project).
3947
          where(statement).
3948
          joins(joins_for_order_statement(group_by_statement)).
3949
          group(group_by_statement).
3950
          count
3951
      rescue ActiveRecord::RecordNotFound
3952
        r = {nil => issue_count}
3953
      end
3954
      c = group_by_column
3955
      if c.is_a?(QueryCustomFieldColumn)
3956
        r = r.keys.inject({}) {|h, k| h[c.custom_field.cast_value(k)] = r[k]; h}
3957
      end
3958
    end
3959
    r
3960
  rescue ::ActiveRecord::StatementInvalid => e
3961
    raise StatementInvalid.new(e.message)
3962
  end
3963
3964
  # Returns the issues
3965
  # Valid options are :order, :offset, :limit, :include, :conditions
3966
  def issues(options={})
3967
    order_option = [group_by_sort_order, options[:order]].flatten.reject(&:blank?)
3968
3969
    scope = Issue.visible.
3970
      joins(:status, :project).
3971
      where(statement).
3972
      includes(([:status, :project] + (options[:include] || [])).uniq).
3973
      where(options[:conditions]).
3974
      order(order_option).
3975
      joins(joins_for_order_statement(order_option.join(','))).
3976
      limit(options[:limit]).
3977
      offset(options[:offset])
3978
3979 1517:dffacf8a6908 Chris
    scope = scope.preload(:custom_values)
3980
    if has_column?(:author)
3981
      scope = scope.preload(:author)
3982 1464:261b3d9a4903 Chris
    end
3983
3984
    issues = scope.all
3985
3986
    if has_column?(:spent_hours)
3987
      Issue.load_visible_spent_hours(issues)
3988
    end
3989
    if has_column?(:relations)
3990
      Issue.load_visible_relations(issues)
3991
    end
3992
    issues
3993
  rescue ::ActiveRecord::StatementInvalid => e
3994
    raise StatementInvalid.new(e.message)
3995
  end
3996
3997
  # Returns the issues ids
3998
  def issue_ids(options={})
3999
    order_option = [group_by_sort_order, options[:order]].flatten.reject(&:blank?)
4000
4001
    Issue.visible.
4002
      joins(:status, :project).
4003
      where(statement).
4004
      includes(([:status, :project] + (options[:include] || [])).uniq).
4005
      where(options[:conditions]).
4006
      order(order_option).
4007
      joins(joins_for_order_statement(order_option.join(','))).
4008
      limit(options[:limit]).
4009
      offset(options[:offset]).
4010
      find_ids
4011
  rescue ::ActiveRecord::StatementInvalid => e
4012
    raise StatementInvalid.new(e.message)
4013
  end
4014
4015
  # Returns the journals
4016
  # Valid options are :order, :offset, :limit
4017
  def journals(options={})
4018
    Journal.visible.
4019
      joins(:issue => [:project, :status]).
4020
      where(statement).
4021
      order(options[:order]).
4022
      limit(options[:limit]).
4023
      offset(options[:offset]).
4024
      preload(:details, :user, {:issue => [:project, :author, :tracker, :status]}).
4025
      all
4026
  rescue ::ActiveRecord::StatementInvalid => e
4027
    raise StatementInvalid.new(e.message)
4028
  end
4029
4030
  # Returns the versions
4031
  # Valid options are :conditions
4032
  def versions(options={})
4033
    Version.visible.
4034
      where(project_statement).
4035
      where(options[:conditions]).
4036
      includes(:project).
4037
      all
4038
  rescue ::ActiveRecord::StatementInvalid => e
4039
    raise StatementInvalid.new(e.message)
4040
  end
4041
4042
  def sql_for_watcher_id_field(field, operator, value)
4043
    db_table = Watcher.table_name
4044
    "#{Issue.table_name}.id #{ operator == '=' ? 'IN' : 'NOT IN' } (SELECT #{db_table}.watchable_id FROM #{db_table} WHERE #{db_table}.watchable_type='Issue' AND " +
4045
      sql_for_field(field, '=', value, db_table, 'user_id') + ')'
4046
  end
4047
4048
  def sql_for_member_of_group_field(field, operator, value)
4049
    if operator == '*' # Any group
4050
      groups = Group.all
4051
      operator = '=' # Override the operator since we want to find by assigned_to
4052
    elsif operator == "!*"
4053
      groups = Group.all
4054
      operator = '!' # Override the operator since we want to find by assigned_to
4055
    else
4056 1517:dffacf8a6908 Chris
      groups = Group.where(:id => value).all
4057 1464:261b3d9a4903 Chris
    end
4058
    groups ||= []
4059
4060
    members_of_groups = groups.inject([]) {|user_ids, group|
4061
      user_ids + group.user_ids + [group.id]
4062
    }.uniq.compact.sort.collect(&:to_s)
4063
4064
    '(' + sql_for_field("assigned_to_id", operator, members_of_groups, Issue.table_name, "assigned_to_id", false) + ')'
4065
  end
4066
4067
  def sql_for_assigned_to_role_field(field, operator, value)
4068
    case operator
4069
    when "*", "!*" # Member / Not member
4070
      sw = operator == "!*" ? 'NOT' : ''
4071
      nl = operator == "!*" ? "#{Issue.table_name}.assigned_to_id IS NULL OR" : ''
4072
      "(#{nl} #{Issue.table_name}.assigned_to_id #{sw} IN (SELECT DISTINCT #{Member.table_name}.user_id FROM #{Member.table_name}" +
4073
        " WHERE #{Member.table_name}.project_id = #{Issue.table_name}.project_id))"
4074
    when "=", "!"
4075
      role_cond = value.any? ?
4076
        "#{MemberRole.table_name}.role_id IN (" + value.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + ")" :
4077
        "1=0"
4078
4079
      sw = operator == "!" ? 'NOT' : ''
4080
      nl = operator == "!" ? "#{Issue.table_name}.assigned_to_id IS NULL OR" : ''
4081
      "(#{nl} #{Issue.table_name}.assigned_to_id #{sw} IN (SELECT DISTINCT #{Member.table_name}.user_id FROM #{Member.table_name}, #{MemberRole.table_name}" +
4082
        " WHERE #{Member.table_name}.project_id = #{Issue.table_name}.project_id AND #{Member.table_name}.id = #{MemberRole.table_name}.member_id AND #{role_cond}))"
4083
    end
4084
  end
4085
4086
  def sql_for_is_private_field(field, operator, value)
4087
    op = (operator == "=" ? 'IN' : 'NOT IN')
4088
    va = value.map {|v| v == '0' ? connection.quoted_false : connection.quoted_true}.uniq.join(',')
4089
4090
    "#{Issue.table_name}.is_private #{op} (#{va})"
4091
  end
4092
4093
  def sql_for_relations(field, operator, value, options={})
4094
    relation_options = IssueRelation::TYPES[field]
4095
    return relation_options unless relation_options
4096
4097
    relation_type = field
4098
    join_column, target_join_column = "issue_from_id", "issue_to_id"
4099
    if relation_options[:reverse] || options[:reverse]
4100
      relation_type = relation_options[:reverse] || relation_type
4101
      join_column, target_join_column = target_join_column, join_column
4102
    end
4103
4104
    sql = case operator
4105
      when "*", "!*"
4106
        op = (operator == "*" ? 'IN' : 'NOT IN')
4107
        "#{Issue.table_name}.id #{op} (SELECT DISTINCT #{IssueRelation.table_name}.#{join_column} FROM #{IssueRelation.table_name} WHERE #{IssueRelation.table_name}.relation_type = '#{connection.quote_string(relation_type)}')"
4108
      when "=", "!"
4109
        op = (operator == "=" ? 'IN' : 'NOT IN')
4110
        "#{Issue.table_name}.id #{op} (SELECT DISTINCT #{IssueRelation.table_name}.#{join_column} FROM #{IssueRelation.table_name} WHERE #{IssueRelation.table_name}.relation_type = '#{connection.quote_string(relation_type)}' AND #{IssueRelation.table_name}.#{target_join_column} = #{value.first.to_i})"
4111
      when "=p", "=!p", "!p"
4112
        op = (operator == "!p" ? 'NOT IN' : 'IN')
4113
        comp = (operator == "=!p" ? '<>' : '=')
4114
        "#{Issue.table_name}.id #{op} (SELECT DISTINCT #{IssueRelation.table_name}.#{join_column} FROM #{IssueRelation.table_name}, #{Issue.table_name} relissues WHERE #{IssueRelation.table_name}.relation_type = '#{connection.quote_string(relation_type)}' AND #{IssueRelation.table_name}.#{target_join_column} = relissues.id AND relissues.project_id #{comp} #{value.first.to_i})"
4115
      end
4116
4117
    if relation_options[:sym] == field && !options[:reverse]
4118
      sqls = [sql, sql_for_relations(field, operator, value, :reverse => true)]
4119
      sql = sqls.join(["!", "!*", "!p"].include?(operator) ? " AND " : " OR ")
4120
    end
4121
    "(#{sql})"
4122
  end
4123
4124
  IssueRelation::TYPES.keys.each do |relation_type|
4125
    alias_method "sql_for_#{relation_type}_field".to_sym, :sql_for_relations
4126
  end
4127
end
4128 909:cbb26bc654de Chris
# Redmine - project management software
4129 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
4130 0:513646585e45 Chris
#
4131
# This program is free software; you can redistribute it and/or
4132
# modify it under the terms of the GNU General Public License
4133
# as published by the Free Software Foundation; either version 2
4134
# of the License, or (at your option) any later version.
4135 909:cbb26bc654de Chris
#
4136 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
4137
# but WITHOUT ANY WARRANTY; without even the implied warranty of
4138
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
4139
# GNU General Public License for more details.
4140 909:cbb26bc654de Chris
#
4141 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
4142
# along with this program; if not, write to the Free Software
4143
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
4144
4145 1464:261b3d9a4903 Chris
class IssueRelation < ActiveRecord::Base
4146
  # Class used to represent the relations of an issue
4147
  class Relations < Array
4148
    include Redmine::I18n
4149 1115:433d4f72a19b Chris
4150 1464:261b3d9a4903 Chris
    def initialize(issue, *args)
4151
      @issue = issue
4152
      super(*args)
4153
    end
4154
4155
    def to_s(*args)
4156
      map {|relation| "#{l(relation.label_for(@issue))} ##{relation.other_issue(@issue).id}"}.join(', ')
4157
    end
4158 1115:433d4f72a19b Chris
  end
4159
4160 0:513646585e45 Chris
  belongs_to :issue_from, :class_name => 'Issue', :foreign_key => 'issue_from_id'
4161
  belongs_to :issue_to, :class_name => 'Issue', :foreign_key => 'issue_to_id'
4162 909:cbb26bc654de Chris
4163 0:513646585e45 Chris
  TYPE_RELATES      = "relates"
4164
  TYPE_DUPLICATES   = "duplicates"
4165
  TYPE_DUPLICATED   = "duplicated"
4166
  TYPE_BLOCKS       = "blocks"
4167
  TYPE_BLOCKED      = "blocked"
4168
  TYPE_PRECEDES     = "precedes"
4169
  TYPE_FOLLOWS      = "follows"
4170 1115:433d4f72a19b Chris
  TYPE_COPIED_TO    = "copied_to"
4171
  TYPE_COPIED_FROM  = "copied_from"
4172 909:cbb26bc654de Chris
4173 1115:433d4f72a19b Chris
  TYPES = {
4174
    TYPE_RELATES =>     { :name => :label_relates_to, :sym_name => :label_relates_to,
4175
                          :order => 1, :sym => TYPE_RELATES },
4176
    TYPE_DUPLICATES =>  { :name => :label_duplicates, :sym_name => :label_duplicated_by,
4177
                          :order => 2, :sym => TYPE_DUPLICATED },
4178
    TYPE_DUPLICATED =>  { :name => :label_duplicated_by, :sym_name => :label_duplicates,
4179
                          :order => 3, :sym => TYPE_DUPLICATES, :reverse => TYPE_DUPLICATES },
4180
    TYPE_BLOCKS =>      { :name => :label_blocks, :sym_name => :label_blocked_by,
4181
                          :order => 4, :sym => TYPE_BLOCKED },
4182
    TYPE_BLOCKED =>     { :name => :label_blocked_by, :sym_name => :label_blocks,
4183
                          :order => 5, :sym => TYPE_BLOCKS, :reverse => TYPE_BLOCKS },
4184
    TYPE_PRECEDES =>    { :name => :label_precedes, :sym_name => :label_follows,
4185
                          :order => 6, :sym => TYPE_FOLLOWS },
4186
    TYPE_FOLLOWS =>     { :name => :label_follows, :sym_name => :label_precedes,
4187
                          :order => 7, :sym => TYPE_PRECEDES, :reverse => TYPE_PRECEDES },
4188
    TYPE_COPIED_TO =>   { :name => :label_copied_to, :sym_name => :label_copied_from,
4189
                          :order => 8, :sym => TYPE_COPIED_FROM },
4190
    TYPE_COPIED_FROM => { :name => :label_copied_from, :sym_name => :label_copied_to,
4191
                          :order => 9, :sym => TYPE_COPIED_TO, :reverse => TYPE_COPIED_TO }
4192
  }.freeze
4193 909:cbb26bc654de Chris
4194 0:513646585e45 Chris
  validates_presence_of :issue_from, :issue_to, :relation_type
4195
  validates_inclusion_of :relation_type, :in => TYPES.keys
4196
  validates_numericality_of :delay, :allow_nil => true
4197
  validates_uniqueness_of :issue_to_id, :scope => :issue_from_id
4198 909:cbb26bc654de Chris
  validate :validate_issue_relation
4199
4200 0:513646585e45 Chris
  attr_protected :issue_from_id, :issue_to_id
4201 909:cbb26bc654de Chris
  before_save :handle_issue_order
4202 1464:261b3d9a4903 Chris
  after_create  :create_journal_after_create
4203
  after_destroy :create_journal_after_delete
4204 909:cbb26bc654de Chris
4205
  def visible?(user=User.current)
4206
    (issue_from.nil? || issue_from.visible?(user)) && (issue_to.nil? || issue_to.visible?(user))
4207
  end
4208
4209
  def deletable?(user=User.current)
4210
    visible?(user) &&
4211
      ((issue_from.nil? || user.allowed_to?(:manage_issue_relations, issue_from.project)) ||
4212
        (issue_to.nil? || user.allowed_to?(:manage_issue_relations, issue_to.project)))
4213
  end
4214
4215 1115:433d4f72a19b Chris
  def initialize(attributes=nil, *args)
4216
    super
4217 909:cbb26bc654de Chris
    if new_record?
4218
      if relation_type.blank?
4219
        self.relation_type = IssueRelation::TYPE_RELATES
4220
      end
4221
    end
4222
  end
4223
4224
  def validate_issue_relation
4225 0:513646585e45 Chris
    if issue_from && issue_to
4226
      errors.add :issue_to_id, :invalid if issue_from_id == issue_to_id
4227 1115:433d4f72a19b Chris
      unless issue_from.project_id == issue_to.project_id ||
4228
                Setting.cross_project_issue_relations?
4229
        errors.add :issue_to_id, :not_same_project
4230
      end
4231
      # detect circular dependencies depending wether the relation should be reversed
4232 507:0c939c159af4 Chris
      if TYPES.has_key?(relation_type) && TYPES[relation_type][:reverse]
4233 909:cbb26bc654de Chris
        errors.add :base, :circular_dependency if issue_from.all_dependent_issues.include? issue_to
4234 507:0c939c159af4 Chris
      else
4235 909:cbb26bc654de Chris
        errors.add :base, :circular_dependency if issue_to.all_dependent_issues.include? issue_from
4236 507:0c939c159af4 Chris
      end
4237 1115:433d4f72a19b Chris
      if issue_from.is_descendant_of?(issue_to) || issue_from.is_ancestor_of?(issue_to)
4238
        errors.add :base, :cant_link_an_issue_with_a_descendant
4239
      end
4240 0:513646585e45 Chris
    end
4241
  end
4242 909:cbb26bc654de Chris
4243 0:513646585e45 Chris
  def other_issue(issue)
4244
    (self.issue_from_id == issue.id) ? issue_to : issue_from
4245
  end
4246 909:cbb26bc654de Chris
4247 0:513646585e45 Chris
  # Returns the relation type for +issue+
4248
  def relation_type_for(issue)
4249
    if TYPES[relation_type]
4250
      if self.issue_from_id == issue.id
4251
        relation_type
4252
      else
4253
        TYPES[relation_type][:sym]
4254
      end
4255
    end
4256
  end
4257 909:cbb26bc654de Chris
4258 0:513646585e45 Chris
  def label_for(issue)
4259 1115:433d4f72a19b Chris
    TYPES[relation_type] ?
4260
        TYPES[relation_type][(self.issue_from_id == issue.id) ? :name : :sym_name] :
4261
        :unknow
4262
  end
4263
4264
  def css_classes_for(issue)
4265
    "rel-#{relation_type_for(issue)}"
4266 0:513646585e45 Chris
  end
4267 909:cbb26bc654de Chris
4268
  def handle_issue_order
4269 0:513646585e45 Chris
    reverse_if_needed
4270 909:cbb26bc654de Chris
4271 0:513646585e45 Chris
    if TYPE_PRECEDES == relation_type
4272
      self.delay ||= 0
4273
    else
4274
      self.delay = nil
4275
    end
4276
    set_issue_to_dates
4277
  end
4278 909:cbb26bc654de Chris
4279 0:513646585e45 Chris
  def set_issue_to_dates
4280
    soonest_start = self.successor_soonest_start
4281 119:8661b858af72 Chris
    if soonest_start && issue_to
4282 1115:433d4f72a19b Chris
      issue_to.reschedule_on!(soonest_start)
4283 0:513646585e45 Chris
    end
4284
  end
4285 909:cbb26bc654de Chris
4286 0:513646585e45 Chris
  def successor_soonest_start
4287 1115:433d4f72a19b Chris
    if (TYPE_PRECEDES == self.relation_type) && delay && issue_from &&
4288
           (issue_from.start_date || issue_from.due_date)
4289 119:8661b858af72 Chris
      (issue_from.due_date || issue_from.start_date) + 1 + delay
4290
    end
4291 0:513646585e45 Chris
  end
4292 909:cbb26bc654de Chris
4293 0:513646585e45 Chris
  def <=>(relation)
4294 1115:433d4f72a19b Chris
    r = TYPES[self.relation_type][:order] <=> TYPES[relation.relation_type][:order]
4295
    r == 0 ? id <=> relation.id : r
4296 0:513646585e45 Chris
  end
4297 909:cbb26bc654de Chris
4298 0:513646585e45 Chris
  private
4299 909:cbb26bc654de Chris
4300 0:513646585e45 Chris
  # Reverses the relation if needed so that it gets stored in the proper way
4301 909:cbb26bc654de Chris
  # Should not be reversed before validation so that it can be displayed back
4302
  # as entered on new relation form
4303 0:513646585e45 Chris
  def reverse_if_needed
4304
    if TYPES.has_key?(relation_type) && TYPES[relation_type][:reverse]
4305
      issue_tmp = issue_to
4306
      self.issue_to = issue_from
4307
      self.issue_from = issue_tmp
4308
      self.relation_type = TYPES[relation_type][:reverse]
4309
    end
4310
  end
4311 1464:261b3d9a4903 Chris
4312
  def create_journal_after_create
4313
    journal = issue_from.init_journal(User.current)
4314
    journal.details << JournalDetail.new(:property => 'relation',
4315 1517:dffacf8a6908 Chris
                                         :prop_key => relation_type_for(issue_from),
4316 1464:261b3d9a4903 Chris
                                         :value    => issue_to.id)
4317
    journal.save
4318
    journal = issue_to.init_journal(User.current)
4319
    journal.details << JournalDetail.new(:property => 'relation',
4320 1517:dffacf8a6908 Chris
                                         :prop_key => relation_type_for(issue_to),
4321 1464:261b3d9a4903 Chris
                                         :value    => issue_from.id)
4322
    journal.save
4323
  end
4324
4325
  def create_journal_after_delete
4326
    journal = issue_from.init_journal(User.current)
4327
    journal.details << JournalDetail.new(:property  => 'relation',
4328 1517:dffacf8a6908 Chris
                                         :prop_key  => relation_type_for(issue_from),
4329 1464:261b3d9a4903 Chris
                                         :old_value => issue_to.id)
4330
    journal.save
4331
    journal = issue_to.init_journal(User.current)
4332
    journal.details << JournalDetail.new(:property  => 'relation',
4333 1517:dffacf8a6908 Chris
                                         :prop_key  => relation_type_for(issue_to),
4334 1464:261b3d9a4903 Chris
                                         :old_value => issue_from.id)
4335
    journal.save
4336
  end
4337 0:513646585e45 Chris
end
4338 909:cbb26bc654de Chris
# Redmine - project management software
4339 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
4340 0:513646585e45 Chris
#
4341
# This program is free software; you can redistribute it and/or
4342
# modify it under the terms of the GNU General Public License
4343
# as published by the Free Software Foundation; either version 2
4344
# of the License, or (at your option) any later version.
4345 909:cbb26bc654de Chris
#
4346 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
4347
# but WITHOUT ANY WARRANTY; without even the implied warranty of
4348
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
4349
# GNU General Public License for more details.
4350 909:cbb26bc654de Chris
#
4351 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
4352
# along with this program; if not, write to the Free Software
4353
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
4354
4355
class IssueStatus < ActiveRecord::Base
4356 909:cbb26bc654de Chris
  before_destroy :check_integrity
4357 1115:433d4f72a19b Chris
  has_many :workflows, :class_name => 'WorkflowTransition', :foreign_key => "old_status_id"
4358 0:513646585e45 Chris
  acts_as_list
4359 909:cbb26bc654de Chris
4360 1115:433d4f72a19b Chris
  before_destroy :delete_workflow_rules
4361 909:cbb26bc654de Chris
  after_save     :update_default
4362 0:513646585e45 Chris
4363
  validates_presence_of :name
4364
  validates_uniqueness_of :name
4365
  validates_length_of :name, :maximum => 30
4366
  validates_inclusion_of :default_done_ratio, :in => 0..100, :allow_nil => true
4367 909:cbb26bc654de Chris
4368 1464:261b3d9a4903 Chris
  scope :sorted, lambda { order("#{table_name}.position ASC") }
4369 1115:433d4f72a19b Chris
  scope :named, lambda {|arg| where("LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip)}
4370 0:513646585e45 Chris
4371 909:cbb26bc654de Chris
  def update_default
4372 1517:dffacf8a6908 Chris
    IssueStatus.where(['id <> ?', id]).update_all({:is_default => false}) if self.is_default?
4373 909:cbb26bc654de Chris
  end
4374
4375 0:513646585e45 Chris
  # Returns the default status for new issues
4376
  def self.default
4377 1115:433d4f72a19b Chris
    where(:is_default => true).first
4378 0:513646585e45 Chris
  end
4379 909:cbb26bc654de Chris
4380 0:513646585e45 Chris
  # Update all the +Issues+ setting their done_ratio to the value of their +IssueStatus+
4381
  def self.update_issue_done_ratios
4382
    if Issue.use_status_for_done_ratio?
4383 1517:dffacf8a6908 Chris
      IssueStatus.where("default_done_ratio >= 0").each do |status|
4384
        Issue.where({:status_id => status.id}).update_all({:done_ratio => status.default_done_ratio})
4385 0:513646585e45 Chris
      end
4386
    end
4387
4388
    return Issue.use_status_for_done_ratio?
4389
  end
4390
4391
  # Returns an array of all statuses the given role can switch to
4392
  # Uses association cache when called more than one time
4393 245:051f544170fe Chris
  def new_statuses_allowed_to(roles, tracker, author=false, assignee=false)
4394 0:513646585e45 Chris
    if roles && tracker
4395
      role_ids = roles.collect(&:id)
4396 245:051f544170fe Chris
      transitions = workflows.select do |w|
4397
        role_ids.include?(w.role_id) &&
4398 909:cbb26bc654de Chris
        w.tracker_id == tracker.id &&
4399
        ((!w.author && !w.assignee) || (author && w.author) || (assignee && w.assignee))
4400 245:051f544170fe Chris
      end
4401 1115:433d4f72a19b Chris
      transitions.map(&:new_status).compact.sort
4402 0:513646585e45 Chris
    else
4403
      []
4404
    end
4405
  end
4406 909:cbb26bc654de Chris
4407 0:513646585e45 Chris
  # Same thing as above but uses a database query
4408
  # More efficient than the previous method if called just once
4409 245:051f544170fe Chris
  def find_new_statuses_allowed_to(roles, tracker, author=false, assignee=false)
4410 909:cbb26bc654de Chris
    if roles.present? && tracker
4411
      conditions = "(author = :false AND assignee = :false)"
4412
      conditions << " OR author = :true" if author
4413
      conditions << " OR assignee = :true" if assignee
4414
4415 1115:433d4f72a19b Chris
      workflows.
4416
        includes(:new_status).
4417
        where(["role_id IN (:role_ids) AND tracker_id = :tracker_id AND (#{conditions})",
4418 909:cbb26bc654de Chris
          {:role_ids => roles.collect(&:id), :tracker_id => tracker.id, :true => true, :false => false}
4419 1115:433d4f72a19b Chris
          ]).all.
4420
        map(&:new_status).compact.sort
4421 0:513646585e45 Chris
    else
4422
      []
4423
    end
4424
  end
4425
4426
  def <=>(status)
4427
    position <=> status.position
4428
  end
4429 909:cbb26bc654de Chris
4430 0:513646585e45 Chris
  def to_s; name end
4431
4432 1115:433d4f72a19b Chris
  private
4433
4434 0:513646585e45 Chris
  def check_integrity
4435 1115:433d4f72a19b Chris
    raise "Can't delete status" if Issue.where(:status_id => id).any?
4436 0:513646585e45 Chris
  end
4437 909:cbb26bc654de Chris
4438 1:cca12e1c1fd4 Chris
  # Deletes associated workflows
4439 1115:433d4f72a19b Chris
  def delete_workflow_rules
4440
    WorkflowRule.delete_all(["old_status_id = :id OR new_status_id = :id", {:id => id}])
4441 1:cca12e1c1fd4 Chris
  end
4442 0:513646585e45 Chris
end
4443 441:cbce1fd3b1b7 Chris
# Redmine - project management software
4444 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
4445 0:513646585e45 Chris
#
4446
# This program is free software; you can redistribute it and/or
4447
# modify it under the terms of the GNU General Public License
4448
# as published by the Free Software Foundation; either version 2
4449
# of the License, or (at your option) any later version.
4450 909:cbb26bc654de Chris
#
4451 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
4452
# but WITHOUT ANY WARRANTY; without even the implied warranty of
4453
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
4454
# GNU General Public License for more details.
4455 909:cbb26bc654de Chris
#
4456 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
4457
# along with this program; if not, write to the Free Software
4458
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
4459
4460
class Journal < ActiveRecord::Base
4461
  belongs_to :journalized, :polymorphic => true
4462
  # added as a quick fix to allow eager loading of the polymorphic association
4463
  # since always associated to an issue, for now
4464
  belongs_to :issue, :foreign_key => :journalized_id
4465 909:cbb26bc654de Chris
4466 0:513646585e45 Chris
  belongs_to :user
4467
  has_many :details, :class_name => "JournalDetail", :dependent => :delete_all
4468
  attr_accessor :indice
4469 909:cbb26bc654de Chris
4470 0:513646585e45 Chris
  acts_as_event :title => Proc.new {|o| status = ((s = o.new_status) ? " (#{s})" : nil); "#{o.issue.tracker} ##{o.issue.id}#{status}: #{o.issue.subject}" },
4471
                :description => :notes,
4472
                :author => :user,
4473 1464:261b3d9a4903 Chris
                :group => :issue,
4474 0:513646585e45 Chris
                :type => Proc.new {|o| (s = o.new_status) ? (s.is_closed? ? 'issue-closed' : 'issue-edit') : 'issue-note' },
4475
                :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.issue.id, :anchor => "change-#{o.id}"}}
4476
4477
  acts_as_activity_provider :type => 'issues',
4478
                            :author_key => :user_id,
4479
                            :find_options => {:include => [{:issue => :project}, :details, :user],
4480
                                              :conditions => "#{Journal.table_name}.journalized_type = 'Issue' AND" +
4481
                                                             " (#{JournalDetail.table_name}.prop_key = 'status_id' OR #{Journal.table_name}.notes <> '')"}
4482 909:cbb26bc654de Chris
4483 1115:433d4f72a19b Chris
  before_create :split_private_notes
4484 1464:261b3d9a4903 Chris
  after_create :send_notification
4485 1115:433d4f72a19b Chris
4486
  scope :visible, lambda {|*args|
4487
    user = args.shift || User.current
4488
4489
    includes(:issue => :project).
4490
      where(Issue.visible_condition(user, *args)).
4491
      where("(#{Journal.table_name}.private_notes = ? OR (#{Project.allowed_to_condition(user, :view_private_notes, *args)}))", false)
4492
  }
4493 909:cbb26bc654de Chris
4494 0:513646585e45 Chris
  def save(*args)
4495
    # Do not save an empty journal
4496
    (details.empty? && notes.blank?) ? false : super
4497
  end
4498 909:cbb26bc654de Chris
4499 1464:261b3d9a4903 Chris
  # Returns journal details that are visible to user
4500
  def visible_details(user=User.current)
4501
    details.select do |detail|
4502
      if detail.property == 'cf'
4503
        detail.custom_field && detail.custom_field.visible_by?(project, user)
4504
      elsif detail.property == 'relation'
4505
        Issue.find_by_id(detail.value || detail.old_value).try(:visible?, user)
4506
      else
4507
        true
4508
      end
4509
    end
4510
  end
4511
4512
  def each_notification(users, &block)
4513
    if users.any?
4514
      users_by_details_visibility = users.group_by do |user|
4515
        visible_details(user)
4516
      end
4517
      users_by_details_visibility.each do |visible_details, users|
4518
        if notes? || visible_details.any?
4519
          yield(users)
4520
        end
4521
      end
4522
    end
4523
  end
4524
4525 0:513646585e45 Chris
  # Returns the new status if the journal contains a status change, otherwise nil
4526
  def new_status
4527
    c = details.detect {|detail| detail.prop_key == 'status_id'}
4528
    (c && c.value) ? IssueStatus.find_by_id(c.value.to_i) : nil
4529
  end
4530 909:cbb26bc654de Chris
4531 0:513646585e45 Chris
  def new_value_for(prop)
4532
    c = details.detect {|detail| detail.prop_key == prop}
4533
    c ? c.value : nil
4534
  end
4535 909:cbb26bc654de Chris
4536 0:513646585e45 Chris
  def editable_by?(usr)
4537
    usr && usr.logged? && (usr.allowed_to?(:edit_issue_notes, project) || (self.user == usr && usr.allowed_to?(:edit_own_issue_notes, project)))
4538
  end
4539 909:cbb26bc654de Chris
4540 0:513646585e45 Chris
  def project
4541
    journalized.respond_to?(:project) ? journalized.project : nil
4542
  end
4543 909:cbb26bc654de Chris
4544 0:513646585e45 Chris
  def attachments
4545
    journalized.respond_to?(:attachments) ? journalized.attachments : nil
4546
  end
4547 22:40f7cfd4df19 chris
4548
  # Returns a string of css classes
4549
  def css_classes
4550
    s = 'journal'
4551
    s << ' has-notes' unless notes.blank?
4552
    s << ' has-details' unless details.blank?
4553 1115:433d4f72a19b Chris
    s << ' private-notes' if private_notes?
4554 22:40f7cfd4df19 chris
    s
4555
  end
4556 909:cbb26bc654de Chris
4557 441:cbce1fd3b1b7 Chris
  def notify?
4558
    @notify != false
4559
  end
4560 909:cbb26bc654de Chris
4561 441:cbce1fd3b1b7 Chris
  def notify=(arg)
4562
    @notify = arg
4563
  end
4564 1115:433d4f72a19b Chris
4565 1464:261b3d9a4903 Chris
  def notified_users
4566 1115:433d4f72a19b Chris
    notified = journalized.notified_users
4567
    if private_notes?
4568
      notified = notified.select {|user| user.allowed_to?(:view_private_notes, journalized.project)}
4569
    end
4570 1464:261b3d9a4903 Chris
    notified
4571 1115:433d4f72a19b Chris
  end
4572
4573 1464:261b3d9a4903 Chris
  def recipients
4574
    notified_users.map(&:mail)
4575
  end
4576
4577
  def notified_watchers
4578 1115:433d4f72a19b Chris
    notified = journalized.notified_watchers
4579
    if private_notes?
4580
      notified = notified.select {|user| user.allowed_to?(:view_private_notes, journalized.project)}
4581
    end
4582 1464:261b3d9a4903 Chris
    notified
4583
  end
4584
4585
  def watcher_recipients
4586
    notified_watchers.map(&:mail)
4587
  end
4588
4589
  # Sets @custom_field instance variable on journals details using a single query
4590
  def self.preload_journals_details_custom_fields(journals)
4591
    field_ids = journals.map(&:details).flatten.select {|d| d.property == 'cf'}.map(&:prop_key).uniq
4592
    if field_ids.any?
4593 1517:dffacf8a6908 Chris
      fields_by_id = CustomField.where(:id => field_ids).inject({}) {|h, f| h[f.id] = f; h}
4594 1464:261b3d9a4903 Chris
      journals.each do |journal|
4595
        journal.details.each do |detail|
4596
          if detail.property == 'cf'
4597
            detail.instance_variable_set "@custom_field", fields_by_id[detail.prop_key.to_i]
4598
          end
4599
        end
4600
      end
4601
    end
4602
    journals
4603 1115:433d4f72a19b Chris
  end
4604
4605
  private
4606
4607
  def split_private_notes
4608
    if private_notes?
4609
      if notes.present?
4610
        if details.any?
4611
          # Split the journal (notes/changes) so we don't have half-private journals
4612
          journal = Journal.new(:journalized => journalized, :user => user, :notes => nil, :private_notes => false)
4613
          journal.details = details
4614
          journal.save
4615
          self.details = []
4616
          self.created_on = journal.created_on
4617
        end
4618
      else
4619
        # Blank notes should not be private
4620
        self.private_notes = false
4621
      end
4622
    end
4623
    true
4624
  end
4625 1464:261b3d9a4903 Chris
4626
  def send_notification
4627
    if notify? && (Setting.notified_events.include?('issue_updated') ||
4628
        (Setting.notified_events.include?('issue_note_added') && notes.present?) ||
4629
        (Setting.notified_events.include?('issue_status_updated') && new_status.present?) ||
4630
        (Setting.notified_events.include?('issue_priority_updated') && new_value_for('priority_id').present?)
4631
      )
4632
      Mailer.deliver_issue_edit(self)
4633
    end
4634
  end
4635 0:513646585e45 Chris
end
4636 245:051f544170fe Chris
# Redmine - project management software
4637 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
4638 0:513646585e45 Chris
#
4639
# This program is free software; you can redistribute it and/or
4640
# modify it under the terms of the GNU General Public License
4641
# as published by the Free Software Foundation; either version 2
4642
# of the License, or (at your option) any later version.
4643 909:cbb26bc654de Chris
#
4644 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
4645
# but WITHOUT ANY WARRANTY; without even the implied warranty of
4646
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
4647
# GNU General Public License for more details.
4648 909:cbb26bc654de Chris
#
4649 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
4650
# along with this program; if not, write to the Free Software
4651
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
4652
4653
class JournalDetail < ActiveRecord::Base
4654
  belongs_to :journal
4655 441:cbce1fd3b1b7 Chris
  before_save :normalize_values
4656 909:cbb26bc654de Chris
4657 1464:261b3d9a4903 Chris
  def custom_field
4658
    if property == 'cf'
4659
      @custom_field ||= CustomField.find_by_id(prop_key)
4660
    end
4661
  end
4662
4663 441:cbce1fd3b1b7 Chris
  private
4664 909:cbb26bc654de Chris
4665 441:cbce1fd3b1b7 Chris
  def normalize_values
4666
    self.value = normalize(value)
4667
    self.old_value = normalize(old_value)
4668
  end
4669 909:cbb26bc654de Chris
4670 441:cbce1fd3b1b7 Chris
  def normalize(v)
4671 1464:261b3d9a4903 Chris
    case v
4672
    when true
4673 441:cbce1fd3b1b7 Chris
      "1"
4674 1464:261b3d9a4903 Chris
    when false
4675 441:cbce1fd3b1b7 Chris
      "0"
4676 1464:261b3d9a4903 Chris
    when Date
4677
      v.strftime("%Y-%m-%d")
4678 441:cbce1fd3b1b7 Chris
    else
4679
      v
4680
    end
4681
  end
4682 0:513646585e45 Chris
end
4683 441:cbce1fd3b1b7 Chris
# Redmine - project management software
4684 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
4685 0:513646585e45 Chris
#
4686
# This program is free software; you can redistribute it and/or
4687
# modify it under the terms of the GNU General Public License
4688
# as published by the Free Software Foundation; either version 2
4689
# of the License, or (at your option) any later version.
4690 441:cbce1fd3b1b7 Chris
#
4691 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
4692
# but WITHOUT ANY WARRANTY; without even the implied warranty of
4693
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
4694
# GNU General Public License for more details.
4695 441:cbce1fd3b1b7 Chris
#
4696 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
4697
# along with this program; if not, write to the Free Software
4698
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
4699
4700
class MailHandler < ActionMailer::Base
4701
  include ActionView::Helpers::SanitizeHelper
4702 37:94944d00e43c chris
  include Redmine::I18n
4703 0:513646585e45 Chris
4704
  class UnauthorizedAction < StandardError; end
4705
  class MissingInformation < StandardError; end
4706 441:cbce1fd3b1b7 Chris
4707 0:513646585e45 Chris
  attr_reader :email, :user
4708
4709
  def self.receive(email, options={})
4710
    @@handler_options = options.dup
4711 441:cbce1fd3b1b7 Chris
4712 0:513646585e45 Chris
    @@handler_options[:issue] ||= {}
4713 441:cbce1fd3b1b7 Chris
4714 1115:433d4f72a19b Chris
    if @@handler_options[:allow_override].is_a?(String)
4715
      @@handler_options[:allow_override] = @@handler_options[:allow_override].split(',').collect(&:strip)
4716
    end
4717 0:513646585e45 Chris
    @@handler_options[:allow_override] ||= []
4718
    # Project needs to be overridable if not specified
4719
    @@handler_options[:allow_override] << 'project' unless @@handler_options[:issue].has_key?(:project)
4720
    # Status overridable by default
4721 441:cbce1fd3b1b7 Chris
    @@handler_options[:allow_override] << 'status' unless @@handler_options[:issue].has_key?(:status)
4722
4723 1464:261b3d9a4903 Chris
    @@handler_options[:no_account_notice] = (@@handler_options[:no_account_notice].to_s == '1')
4724
    @@handler_options[:no_notification] = (@@handler_options[:no_notification].to_s == '1')
4725
    @@handler_options[:no_permission_check] = (@@handler_options[:no_permission_check].to_s == '1')
4726 1115:433d4f72a19b Chris
4727
    email.force_encoding('ASCII-8BIT') if email.respond_to?(:force_encoding)
4728
    super(email)
4729 0:513646585e45 Chris
  end
4730 441:cbce1fd3b1b7 Chris
4731 1464:261b3d9a4903 Chris
  # Extracts MailHandler options from environment variables
4732
  # Use when receiving emails with rake tasks
4733
  def self.extract_options_from_env(env)
4734
    options = {:issue => {}}
4735
    %w(project status tracker category priority).each do |option|
4736
      options[:issue][option.to_sym] = env[option] if env[option]
4737
    end
4738
    %w(allow_override unknown_user no_permission_check no_account_notice default_group).each do |option|
4739
      options[option.to_sym] = env[option] if env[option]
4740
    end
4741
    options
4742
  end
4743
4744 1115:433d4f72a19b Chris
  def logger
4745
    Rails.logger
4746
  end
4747
4748
  cattr_accessor :ignored_emails_headers
4749
  @@ignored_emails_headers = {
4750
    'X-Auto-Response-Suppress' => 'oof',
4751
    'Auto-Submitted' => /^auto-/
4752
  }
4753
4754 0:513646585e45 Chris
  # Processes incoming emails
4755
  # Returns the created object (eg. an issue, a message) or false
4756
  def receive(email)
4757
    @email = email
4758
    sender_email = email.from.to_a.first.to_s.strip
4759
    # Ignore emails received from the application emission address to avoid hell cycles
4760
    if sender_email.downcase == Setting.mail_from.to_s.strip.downcase
4761 1464:261b3d9a4903 Chris
      if logger
4762 1115:433d4f72a19b Chris
        logger.info  "MailHandler: ignoring email from Redmine emission address [#{sender_email}]"
4763
      end
4764 0:513646585e45 Chris
      return false
4765
    end
4766 1115:433d4f72a19b Chris
    # Ignore auto generated emails
4767
    self.class.ignored_emails_headers.each do |key, ignored_value|
4768
      value = email.header[key]
4769
      if value
4770
        value = value.to_s.downcase
4771
        if (ignored_value.is_a?(Regexp) && value.match(ignored_value)) || value == ignored_value
4772 1464:261b3d9a4903 Chris
          if logger
4773 1115:433d4f72a19b Chris
            logger.info "MailHandler: ignoring email with #{key}:#{value} header"
4774
          end
4775
          return false
4776
        end
4777
      end
4778
    end
4779 0:513646585e45 Chris
    @user = User.find_by_mail(sender_email) if sender_email.present?
4780
    if @user && !@user.active?
4781 1464:261b3d9a4903 Chris
      if logger
4782 1115:433d4f72a19b Chris
        logger.info  "MailHandler: ignoring email from non-active user [#{@user.login}]"
4783
      end
4784 0:513646585e45 Chris
      return false
4785
    end
4786
    if @user.nil?
4787
      # Email was submitted by an unknown user
4788
      case @@handler_options[:unknown_user]
4789
      when 'accept'
4790
        @user = User.anonymous
4791
      when 'create'
4792 1115:433d4f72a19b Chris
        @user = create_user_from_email
4793 0:513646585e45 Chris
        if @user
4794 1464:261b3d9a4903 Chris
          if logger
4795 1115:433d4f72a19b Chris
            logger.info "MailHandler: [#{@user.login}] account created"
4796
          end
4797 1464:261b3d9a4903 Chris
          add_user_to_group(@@handler_options[:default_group])
4798
          unless @@handler_options[:no_account_notice]
4799
            Mailer.account_information(@user, @user.password).deliver
4800
          end
4801 0:513646585e45 Chris
        else
4802 1464:261b3d9a4903 Chris
          if logger
4803 1115:433d4f72a19b Chris
            logger.error "MailHandler: could not create account for [#{sender_email}]"
4804
          end
4805 0:513646585e45 Chris
          return false
4806
        end
4807
      else
4808
        # Default behaviour, emails from unknown users are ignored
4809 1464:261b3d9a4903 Chris
        if logger
4810
          logger.info  "MailHandler: ignoring email from unknown user [#{sender_email}]"
4811 1115:433d4f72a19b Chris
        end
4812 0:513646585e45 Chris
        return false
4813
      end
4814
    end
4815
    User.current = @user
4816
    dispatch
4817
  end
4818 441:cbce1fd3b1b7 Chris
4819 0:513646585e45 Chris
  private
4820
4821 1464:261b3d9a4903 Chris
  MESSAGE_ID_RE = %r{^<?redmine\.([a-z0-9_]+)\-(\d+)\.\d+(\.[a-f0-9]+)?@}
4822 0:513646585e45 Chris
  ISSUE_REPLY_SUBJECT_RE = %r{\[[^\]]*#(\d+)\]}
4823
  MESSAGE_REPLY_SUBJECT_RE = %r{\[[^\]]*msg(\d+)\]}
4824 441:cbce1fd3b1b7 Chris
4825 0:513646585e45 Chris
  def dispatch
4826
    headers = [email.in_reply_to, email.references].flatten.compact
4827 1115:433d4f72a19b Chris
    subject = email.subject.to_s
4828 0:513646585e45 Chris
    if headers.detect {|h| h.to_s =~ MESSAGE_ID_RE}
4829
      klass, object_id = $1, $2.to_i
4830
      method_name = "receive_#{klass}_reply"
4831
      if self.class.private_instance_methods.collect(&:to_s).include?(method_name)
4832
        send method_name, object_id
4833
      else
4834
        # ignoring it
4835
      end
4836 1115:433d4f72a19b Chris
    elsif m = subject.match(ISSUE_REPLY_SUBJECT_RE)
4837 0:513646585e45 Chris
      receive_issue_reply(m[1].to_i)
4838 1115:433d4f72a19b Chris
    elsif m = subject.match(MESSAGE_REPLY_SUBJECT_RE)
4839 0:513646585e45 Chris
      receive_message_reply(m[1].to_i)
4840
    else
4841 245:051f544170fe Chris
      dispatch_to_default
4842 0:513646585e45 Chris
    end
4843
  rescue ActiveRecord::RecordInvalid => e
4844
    # TODO: send a email to the user
4845
    logger.error e.message if logger
4846
    false
4847
  rescue MissingInformation => e
4848
    logger.error "MailHandler: missing information from #{user}: #{e.message}" if logger
4849
    false
4850
  rescue UnauthorizedAction => e
4851
    logger.error "MailHandler: unauthorized attempt from #{user}" if logger
4852
    false
4853
  end
4854 245:051f544170fe Chris
4855
  def dispatch_to_default
4856
    receive_issue
4857
  end
4858 441:cbce1fd3b1b7 Chris
4859 0:513646585e45 Chris
  # Creates a new issue
4860
  def receive_issue
4861
    project = target_project
4862
    # check permission
4863
    unless @@handler_options[:no_permission_check]
4864
      raise UnauthorizedAction unless user.allowed_to?(:add_issues, project)
4865
    end
4866
4867 37:94944d00e43c chris
    issue = Issue.new(:author => user, :project => project)
4868
    issue.safe_attributes = issue_attributes_from_keywords(issue)
4869
    issue.safe_attributes = {'custom_field_values' => custom_field_values_from_keywords(issue)}
4870 1115:433d4f72a19b Chris
    issue.subject = cleaned_up_subject
4871 0:513646585e45 Chris
    if issue.subject.blank?
4872
      issue.subject = '(no subject)'
4873
    end
4874
    issue.description = cleaned_up_text_body
4875 1517:dffacf8a6908 Chris
    issue.start_date ||= Date.today if Setting.default_issue_start_date_to_creation_date?
4876 441:cbce1fd3b1b7 Chris
4877 0:513646585e45 Chris
    # add To and Cc as watchers before saving so the watchers can reply to Redmine
4878
    add_watchers(issue)
4879
    issue.save!
4880
    add_attachments(issue)
4881 1464:261b3d9a4903 Chris
    logger.info "MailHandler: issue ##{issue.id} created by #{user}" if logger
4882 0:513646585e45 Chris
    issue
4883
  end
4884 441:cbce1fd3b1b7 Chris
4885 0:513646585e45 Chris
  # Adds a note to an existing issue
4886 1115:433d4f72a19b Chris
  def receive_issue_reply(issue_id, from_journal=nil)
4887 0:513646585e45 Chris
    issue = Issue.find_by_id(issue_id)
4888
    return unless issue
4889
    # check permission
4890
    unless @@handler_options[:no_permission_check]
4891 1115:433d4f72a19b Chris
      unless user.allowed_to?(:add_issue_notes, issue.project) ||
4892
               user.allowed_to?(:edit_issues, issue.project)
4893
        raise UnauthorizedAction
4894
      end
4895 0:513646585e45 Chris
    end
4896 441:cbce1fd3b1b7 Chris
4897 119:8661b858af72 Chris
    # ignore CLI-supplied defaults for new issues
4898
    @@handler_options[:issue].clear
4899 441:cbce1fd3b1b7 Chris
4900
    journal = issue.init_journal(user)
4901 1115:433d4f72a19b Chris
    if from_journal && from_journal.private_notes?
4902
      # If the received email was a reply to a private note, make the added note private
4903
      issue.private_notes = true
4904
    end
4905 37:94944d00e43c chris
    issue.safe_attributes = issue_attributes_from_keywords(issue)
4906
    issue.safe_attributes = {'custom_field_values' => custom_field_values_from_keywords(issue)}
4907 441:cbce1fd3b1b7 Chris
    journal.notes = cleaned_up_text_body
4908 0:513646585e45 Chris
    add_attachments(issue)
4909
    issue.save!
4910 1464:261b3d9a4903 Chris
    if logger
4911 1115:433d4f72a19b Chris
      logger.info "MailHandler: issue ##{issue.id} updated by #{user}"
4912
    end
4913 0:513646585e45 Chris
    journal
4914
  end
4915 441:cbce1fd3b1b7 Chris
4916 0:513646585e45 Chris
  # Reply will be added to the issue
4917
  def receive_journal_reply(journal_id)
4918
    journal = Journal.find_by_id(journal_id)
4919
    if journal && journal.journalized_type == 'Issue'
4920 1115:433d4f72a19b Chris
      receive_issue_reply(journal.journalized_id, journal)
4921 0:513646585e45 Chris
    end
4922
  end
4923 441:cbce1fd3b1b7 Chris
4924 0:513646585e45 Chris
  # Receives a reply to a forum message
4925
  def receive_message_reply(message_id)
4926
    message = Message.find_by_id(message_id)
4927
    if message
4928
      message = message.root
4929 441:cbce1fd3b1b7 Chris
4930 0:513646585e45 Chris
      unless @@handler_options[:no_permission_check]
4931
        raise UnauthorizedAction unless user.allowed_to?(:add_messages, message.project)
4932
      end
4933 441:cbce1fd3b1b7 Chris
4934 0:513646585e45 Chris
      if !message.locked?
4935 1115:433d4f72a19b Chris
        reply = Message.new(:subject => cleaned_up_subject.gsub(%r{^.*msg\d+\]}, '').strip,
4936 0:513646585e45 Chris
                            :content => cleaned_up_text_body)
4937
        reply.author = user
4938
        reply.board = message.board
4939
        message.children << reply
4940
        add_attachments(reply)
4941
        reply
4942
      else
4943 1464:261b3d9a4903 Chris
        if logger
4944 1115:433d4f72a19b Chris
          logger.info "MailHandler: ignoring reply from [#{sender_email}] to a locked topic"
4945
        end
4946 0:513646585e45 Chris
      end
4947
    end
4948
  end
4949 441:cbce1fd3b1b7 Chris
4950 0:513646585e45 Chris
  def add_attachments(obj)
4951 909:cbb26bc654de Chris
    if email.attachments && email.attachments.any?
4952 0:513646585e45 Chris
      email.attachments.each do |attachment|
4953 1464:261b3d9a4903 Chris
        next unless accept_attachment?(attachment)
4954 909:cbb26bc654de Chris
        obj.attachments << Attachment.create(:container => obj,
4955 1115:433d4f72a19b Chris
                          :file => attachment.decoded,
4956 1294:3e4c3460b6ca Chris
                          :filename => attachment.filename,
4957 0:513646585e45 Chris
                          :author => user,
4958 1115:433d4f72a19b Chris
                          :content_type => attachment.mime_type)
4959 0:513646585e45 Chris
      end
4960
    end
4961
  end
4962 441:cbce1fd3b1b7 Chris
4963 1464:261b3d9a4903 Chris
  # Returns false if the +attachment+ of the incoming email should be ignored
4964
  def accept_attachment?(attachment)
4965
    @excluded ||= Setting.mail_handler_excluded_filenames.to_s.split(',').map(&:strip).reject(&:blank?)
4966
    @excluded.each do |pattern|
4967
      regexp = %r{\A#{Regexp.escape(pattern).gsub("\\*", ".*")}\z}i
4968
      if attachment.filename.to_s =~ regexp
4969
        logger.info "MailHandler: ignoring attachment #{attachment.filename} matching #{pattern}"
4970
        return false
4971
      end
4972
    end
4973
    true
4974
  end
4975
4976 0:513646585e45 Chris
  # Adds To and Cc as watchers of the given object if the sender has the
4977
  # appropriate permission
4978
  def add_watchers(obj)
4979
    if user.allowed_to?("add_#{obj.class.name.underscore}_watchers".to_sym, obj.project)
4980
      addresses = [email.to, email.cc].flatten.compact.uniq.collect {|a| a.strip.downcase}
4981
      unless addresses.empty?
4982 1517:dffacf8a6908 Chris
        User.active.where('LOWER(mail) IN (?)', addresses).each do |w|
4983
          obj.add_watcher(w)
4984
        end
4985 0:513646585e45 Chris
      end
4986
    end
4987
  end
4988 441:cbce1fd3b1b7 Chris
4989 0:513646585e45 Chris
  def get_keyword(attr, options={})
4990
    @keywords ||= {}
4991
    if @keywords.has_key?(attr)
4992
      @keywords[attr]
4993
    else
4994
      @keywords[attr] = begin
4995 1115:433d4f72a19b Chris
        if (options[:override] || @@handler_options[:allow_override].include?(attr.to_s)) &&
4996
              (v = extract_keyword!(plain_text_body, attr, options[:format]))
4997 37:94944d00e43c chris
          v
4998 0:513646585e45 Chris
        elsif !@@handler_options[:issue][attr].blank?
4999
          @@handler_options[:issue][attr]
5000
        end
5001
      end
5002
    end
5003
  end
5004 441:cbce1fd3b1b7 Chris
5005 37:94944d00e43c chris
  # Destructively extracts the value for +attr+ in +text+
5006
  # Returns nil if no matching keyword found
5007
  def extract_keyword!(text, attr, format=nil)
5008
    keys = [attr.to_s.humanize]
5009
    if attr.is_a?(Symbol)
5010 1115:433d4f72a19b Chris
      if user && user.language.present?
5011
        keys << l("field_#{attr}", :default => '', :locale =>  user.language)
5012
      end
5013
      if Setting.default_language.present?
5014
        keys << l("field_#{attr}", :default => '', :locale =>  Setting.default_language)
5015
      end
5016 37:94944d00e43c chris
    end
5017
    keys.reject! {|k| k.blank?}
5018
    keys.collect! {|k| Regexp.escape(k)}
5019
    format ||= '.+'
5020 1115:433d4f72a19b Chris
    keyword = nil
5021
    regexp = /^(#{keys.join('|')})[ \t]*:[ \t]*(#{format})\s*$/i
5022
    if m = text.match(regexp)
5023
      keyword = m[2].strip
5024
      text.gsub!(regexp, '')
5025
    end
5026
    keyword
5027 37:94944d00e43c chris
  end
5028
5029
  def target_project
5030
    # TODO: other ways to specify project:
5031
    # * parse the email To field
5032
    # * specific project (eg. Setting.mail_handler_target_project)
5033
    target = Project.find_by_identifier(get_keyword(:project))
5034 1464:261b3d9a4903 Chris
    if target.nil?
5035
      # Invalid project keyword, use the project specified as the default one
5036
      default_project = @@handler_options[:issue][:project]
5037
      if default_project.present?
5038
        target = Project.find_by_identifier(default_project)
5039
      end
5040
    end
5041 37:94944d00e43c chris
    raise MissingInformation.new('Unable to determine target project') if target.nil?
5042
    target
5043
  end
5044 441:cbce1fd3b1b7 Chris
5045 37:94944d00e43c chris
  # Returns a Hash of issue attributes extracted from keywords in the email body
5046
  def issue_attributes_from_keywords(issue)
5047 909:cbb26bc654de Chris
    assigned_to = (k = get_keyword(:assigned_to, :override => true)) && find_assignee_from_keyword(k, issue)
5048 441:cbce1fd3b1b7 Chris
5049 119:8661b858af72 Chris
    attrs = {
5050 507:0c939c159af4 Chris
      'tracker_id' => (k = get_keyword(:tracker)) && issue.project.trackers.named(k).first.try(:id),
5051
      'status_id' =>  (k = get_keyword(:status)) && IssueStatus.named(k).first.try(:id),
5052
      'priority_id' => (k = get_keyword(:priority)) && IssuePriority.named(k).first.try(:id),
5053
      'category_id' => (k = get_keyword(:category)) && issue.project.issue_categories.named(k).first.try(:id),
5054 37:94944d00e43c chris
      'assigned_to_id' => assigned_to.try(:id),
5055 1115:433d4f72a19b Chris
      'fixed_version_id' => (k = get_keyword(:fixed_version, :override => true)) &&
5056
                                issue.project.shared_versions.named(k).first.try(:id),
5057 37:94944d00e43c chris
      'start_date' => get_keyword(:start_date, :override => true, :format => '\d{4}-\d{2}-\d{2}'),
5058
      'due_date' => get_keyword(:due_date, :override => true, :format => '\d{4}-\d{2}-\d{2}'),
5059
      'estimated_hours' => get_keyword(:estimated_hours, :override => true),
5060
      'done_ratio' => get_keyword(:done_ratio, :override => true, :format => '(\d|10)?0')
5061
    }.delete_if {|k, v| v.blank? }
5062 441:cbce1fd3b1b7 Chris
5063 119:8661b858af72 Chris
    if issue.new_record? && attrs['tracker_id'].nil?
5064 1464:261b3d9a4903 Chris
      attrs['tracker_id'] = issue.project.trackers.first.try(:id)
5065 119:8661b858af72 Chris
    end
5066 441:cbce1fd3b1b7 Chris
5067 119:8661b858af72 Chris
    attrs
5068 37:94944d00e43c chris
  end
5069 441:cbce1fd3b1b7 Chris
5070 37:94944d00e43c chris
  # Returns a Hash of issue custom field values extracted from keywords in the email body
5071 441:cbce1fd3b1b7 Chris
  def custom_field_values_from_keywords(customized)
5072 37:94944d00e43c chris
    customized.custom_field_values.inject({}) do |h, v|
5073 1115:433d4f72a19b Chris
      if keyword = get_keyword(v.custom_field.name, :override => true)
5074
        h[v.custom_field.id.to_s] = v.custom_field.value_from_keyword(keyword, customized)
5075 37:94944d00e43c chris
      end
5076
      h
5077
    end
5078
  end
5079 441:cbce1fd3b1b7 Chris
5080 0:513646585e45 Chris
  # Returns the text/plain part of the email
5081
  # If not found (eg. HTML-only email), returns the body with tags removed
5082
  def plain_text_body
5083
    return @plain_text_body unless @plain_text_body.nil?
5084 1115:433d4f72a19b Chris
5085 1464:261b3d9a4903 Chris
    parts = if (text_parts = email.all_parts.select {|p| p.mime_type == 'text/plain'}).present?
5086
              text_parts
5087
            elsif (html_parts = email.all_parts.select {|p| p.mime_type == 'text/html'}).present?
5088
              html_parts
5089
            else
5090
              [email]
5091
            end
5092
5093
    parts.reject! do |part|
5094
      part.header[:content_disposition].try(:disposition_type) == 'attachment'
5095
    end
5096
5097 1517:dffacf8a6908 Chris
    @plain_text_body = parts.map do |p|
5098
      body_charset = p.charset.respond_to?(:force_encoding) ?
5099
                       Mail::RubyVer.pick_encoding(p.charset).to_s : p.charset
5100
      Redmine::CodesetUtil.to_utf8(p.body.decoded, body_charset)
5101
    end.join("\r\n")
5102 1115:433d4f72a19b Chris
5103
    # strip html tags and remove doctype directive
5104 1464:261b3d9a4903 Chris
    if parts.any? {|p| p.mime_type == 'text/html'}
5105
      @plain_text_body = strip_tags(@plain_text_body.strip)
5106
      @plain_text_body.sub! %r{^<!DOCTYPE .*$}, ''
5107
    end
5108
5109 0:513646585e45 Chris
    @plain_text_body
5110
  end
5111 441:cbce1fd3b1b7 Chris
5112 0:513646585e45 Chris
  def cleaned_up_text_body
5113
    cleanup_body(plain_text_body)
5114
  end
5115
5116 1115:433d4f72a19b Chris
  def cleaned_up_subject
5117
    subject = email.subject.to_s
5118
    subject.strip[0,255]
5119
  end
5120
5121 0:513646585e45 Chris
  def self.full_sanitizer
5122
    @full_sanitizer ||= HTML::FullSanitizer.new
5123
  end
5124 441:cbce1fd3b1b7 Chris
5125 1115:433d4f72a19b Chris
  def self.assign_string_attribute_with_limit(object, attribute, value, limit=nil)
5126
    limit ||= object.class.columns_hash[attribute.to_s].limit || 255
5127 909:cbb26bc654de Chris
    value = value.to_s.slice(0, limit)
5128
    object.send("#{attribute}=", value)
5129
  end
5130
5131
  # Returns a User from an email address and a full name
5132
  def self.new_user_from_attributes(email_address, fullname=nil)
5133
    user = User.new
5134
5135
    # Truncating the email address would result in an invalid format
5136
    user.mail = email_address
5137 1115:433d4f72a19b Chris
    assign_string_attribute_with_limit(user, 'login', email_address, User::LOGIN_LENGTH_LIMIT)
5138 909:cbb26bc654de Chris
5139
    names = fullname.blank? ? email_address.gsub(/@.*$/, '').split('.') : fullname.split
5140 1464:261b3d9a4903 Chris
    assign_string_attribute_with_limit(user, 'firstname', names.shift, 30)
5141
    assign_string_attribute_with_limit(user, 'lastname', names.join(' '), 30)
5142 909:cbb26bc654de Chris
    user.lastname = '-' if user.lastname.blank?
5143
    user.language = Setting.default_language
5144 1464:261b3d9a4903 Chris
    user.generate_password = true
5145
    user.mail_notification = 'only_my_events'
5146 909:cbb26bc654de Chris
5147
    unless user.valid?
5148 1115:433d4f72a19b Chris
      user.login = "user#{Redmine::Utils.random_hex(6)}" unless user.errors[:login].blank?
5149
      user.firstname = "-" unless user.errors[:firstname].blank?
5150 1464:261b3d9a4903 Chris
      (puts user.errors[:lastname];user.lastname  = "-") unless user.errors[:lastname].blank?
5151 909:cbb26bc654de Chris
    end
5152
5153
    user
5154
  end
5155
5156
  # Creates a User for the +email+ sender
5157
  # Returns the user or nil if it could not be created
5158 1115:433d4f72a19b Chris
  def create_user_from_email
5159
    from = email.header['from'].to_s
5160
    addr, name = from, nil
5161
    if m = from.match(/^"?(.+?)"?\s+<(.+@.+)>$/)
5162
      addr, name = m[2], m[1]
5163
    end
5164
    if addr.present?
5165
      user = self.class.new_user_from_attributes(addr, name)
5166 1464:261b3d9a4903 Chris
      if @@handler_options[:no_notification]
5167
        user.mail_notification = 'none'
5168
      end
5169 909:cbb26bc654de Chris
      if user.save
5170
        user
5171
      else
5172
        logger.error "MailHandler: failed to create User: #{user.errors.full_messages}" if logger
5173
        nil
5174
      end
5175
    else
5176
      logger.error "MailHandler: failed to create User: no FROM address found" if logger
5177
      nil
5178 0:513646585e45 Chris
    end
5179
  end
5180
5181 1464:261b3d9a4903 Chris
  # Adds the newly created user to default group
5182
  def add_user_to_group(default_group)
5183
    if default_group.present?
5184
      default_group.split(',').each do |group_name|
5185
        if group = Group.named(group_name).first
5186
          group.users << @user
5187
        elsif logger
5188
          logger.warn "MailHandler: could not add user to [#{group_name}], group not found"
5189
        end
5190
      end
5191
    end
5192
  end
5193
5194 0:513646585e45 Chris
  # Removes the email body of text after the truncation configurations.
5195
  def cleanup_body(body)
5196
    delimiters = Setting.mail_handler_body_delimiters.to_s.split(/[\r\n]+/).reject(&:blank?).map {|s| Regexp.escape(s)}
5197
    unless delimiters.empty?
5198 37:94944d00e43c chris
      regex = Regexp.new("^[> ]*(#{ delimiters.join('|') })\s*[\r\n].*", Regexp::MULTILINE)
5199 0:513646585e45 Chris
      body = body.gsub(regex, '')
5200
    end
5201
    body.strip
5202
  end
5203
5204 909:cbb26bc654de Chris
  def find_assignee_from_keyword(keyword, issue)
5205
    keyword = keyword.to_s.downcase
5206
    assignable = issue.assignable_users
5207
    assignee = nil
5208 1115:433d4f72a19b Chris
    assignee ||= assignable.detect {|a|
5209
                   a.mail.to_s.downcase == keyword ||
5210
                     a.login.to_s.downcase == keyword
5211
                 }
5212 909:cbb26bc654de Chris
    if assignee.nil? && keyword.match(/ /)
5213 0:513646585e45 Chris
      firstname, lastname = *(keyword.split) # "First Last Throwaway"
5214 1464:261b3d9a4903 Chris
      assignee ||= assignable.detect {|a|
5215 1115:433d4f72a19b Chris
                     a.is_a?(User) && a.firstname.to_s.downcase == firstname &&
5216
                       a.lastname.to_s.downcase == lastname
5217
                   }
5218 0:513646585e45 Chris
    end
5219 909:cbb26bc654de Chris
    if assignee.nil?
5220 1115:433d4f72a19b Chris
      assignee ||= assignable.detect {|a| a.name.downcase == keyword}
5221 909:cbb26bc654de Chris
    end
5222
    assignee
5223 0:513646585e45 Chris
  end
5224
end
5225 441:cbce1fd3b1b7 Chris
# Redmine - project management software
5226 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
5227 0:513646585e45 Chris
#
5228
# This program is free software; you can redistribute it and/or
5229
# modify it under the terms of the GNU General Public License
5230
# as published by the Free Software Foundation; either version 2
5231
# of the License, or (at your option) any later version.
5232
#
5233
# This program is distributed in the hope that it will be useful,
5234
# but WITHOUT ANY WARRANTY; without even the implied warranty of
5235
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
5236
# GNU General Public License for more details.
5237
#
5238
# You should have received a copy of the GNU General Public License
5239
# along with this program; if not, write to the Free Software
5240
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
5241
5242
class Mailer < ActionMailer::Base
5243
  layout 'mailer'
5244
  helper :application
5245
  helper :issues
5246
  helper :custom_fields
5247
5248
  include Redmine::I18n
5249
5250
  def self.default_url_options
5251 1115:433d4f72a19b Chris
    { :host => Setting.host_name, :protocol => Setting.protocol }
5252 0:513646585e45 Chris
  end
5253 441:cbce1fd3b1b7 Chris
5254 1484:51364c0cd58f Chris
  # Builds a mail for notifying the specified member that they were
5255
  # added to a project
5256 1270:b2f7f52a164d chris
  def member_added_to_project(member, project)
5257
5258 949:ebfda4c68b7a luis
    principal = Principal.find(member.user_id)
5259 291:f3b2dc533e83 luis
5260 1270:b2f7f52a164d chris
    users = []
5261 949:ebfda4c68b7a luis
    if principal.type == "User"
5262 1270:b2f7f52a164d chris
      users = [User.find(member.user_id)]
5263 949:ebfda4c68b7a luis
    else
5264
      users = Principal.find(member.user_id).users
5265
    end
5266 291:f3b2dc533e83 luis
5267 1270:b2f7f52a164d chris
    users.map do |user|
5268
5269
      set_language_if_valid user.language
5270
      @project_url = url_for(:controller => 'projects', :action => 'show', :id => project.id)
5271
      @project_name = project.name
5272
      mail :to => user.mail,
5273
        :subject => l(:mail_subject_added_to_project, Setting.app_title)
5274
5275
    end
5276 291:f3b2dc533e83 luis
  end
5277 37:94944d00e43c chris
5278 1464:261b3d9a4903 Chris
  # Builds a mail for notifying to_users and cc_users about a new issue
5279
  def issue_add(issue, to_users, cc_users)
5280 0:513646585e45 Chris
    redmine_headers 'Project' => issue.project.identifier,
5281
                    'Issue-Id' => issue.id,
5282
                    'Issue-Author' => issue.author.login
5283
    redmine_headers 'Issue-Assignee' => issue.assigned_to.login if issue.assigned_to
5284
    message_id issue
5285 1464:261b3d9a4903 Chris
    references issue
5286 1115:433d4f72a19b Chris
    @author = issue.author
5287
    @issue = issue
5288 1464:261b3d9a4903 Chris
    @users = to_users + cc_users
5289 1115:433d4f72a19b Chris
    @issue_url = url_for(:controller => 'issues', :action => 'show', :id => issue)
5290 1464:261b3d9a4903 Chris
    mail :to => to_users.map(&:mail),
5291
      :cc => cc_users.map(&:mail),
5292 1115:433d4f72a19b Chris
      :subject => "[#{issue.project.name} - #{issue.tracker.name} ##{issue.id}] (#{issue.status.name}) #{issue.subject}"
5293 0:513646585e45 Chris
  end
5294
5295 1464:261b3d9a4903 Chris
  # Notifies users about a new issue
5296
  def self.deliver_issue_add(issue)
5297
    to = issue.notified_users
5298
    cc = issue.notified_watchers - to
5299
    issue.each_notification(to + cc) do |users|
5300
      Mailer.issue_add(issue, to & users, cc & users).deliver
5301
    end
5302
  end
5303
5304
  # Builds a mail for notifying to_users and cc_users about an issue update
5305
  def issue_edit(journal, to_users, cc_users)
5306
    issue = journal.journalized
5307 0:513646585e45 Chris
    redmine_headers 'Project' => issue.project.identifier,
5308
                    'Issue-Id' => issue.id,
5309
                    'Issue-Author' => issue.author.login
5310
    redmine_headers 'Issue-Assignee' => issue.assigned_to.login if issue.assigned_to
5311
    message_id journal
5312
    references issue
5313
    @author = journal.user
5314
    s = "[#{issue.project.name} - #{issue.tracker.name} ##{issue.id}] "
5315
    s << "(#{issue.status.name}) " if journal.new_value_for('status_id')
5316
    s << issue.subject
5317 1115:433d4f72a19b Chris
    @issue = issue
5318 1464:261b3d9a4903 Chris
    @users = to_users + cc_users
5319 1115:433d4f72a19b Chris
    @journal = journal
5320 1464:261b3d9a4903 Chris
    @journal_details = journal.visible_details(@users.first)
5321 1115:433d4f72a19b Chris
    @issue_url = url_for(:controller => 'issues', :action => 'show', :id => issue, :anchor => "change-#{journal.id}")
5322 1464:261b3d9a4903 Chris
    mail :to => to_users.map(&:mail),
5323
      :cc => cc_users.map(&:mail),
5324 1115:433d4f72a19b Chris
      :subject => s
5325 0:513646585e45 Chris
  end
5326
5327 1464:261b3d9a4903 Chris
  # Notifies users about an issue update
5328
  def self.deliver_issue_edit(journal)
5329
    issue = journal.journalized.reload
5330
    to = journal.notified_users
5331 1517:dffacf8a6908 Chris
    cc = journal.notified_watchers - to
5332 1464:261b3d9a4903 Chris
    journal.each_notification(to + cc) do |users|
5333
      issue.each_notification(users) do |users2|
5334
        Mailer.issue_edit(journal, to & users2, cc & users2).deliver
5335
      end
5336
    end
5337
  end
5338
5339 0:513646585e45 Chris
  def reminder(user, issues, days)
5340
    set_language_if_valid user.language
5341 1115:433d4f72a19b Chris
    @issues = issues
5342
    @days = days
5343
    @issues_url = url_for(:controller => 'issues', :action => 'index',
5344 909:cbb26bc654de Chris
                                :set_filter => 1, :assigned_to_id => user.id,
5345
                                :sort => 'due_date:asc')
5346 1115:433d4f72a19b Chris
    mail :to => user.mail,
5347
      :subject => l(:mail_subject_reminder, :count => issues.size, :days => days)
5348 0:513646585e45 Chris
  end
5349
5350 1115:433d4f72a19b Chris
  # Builds a Mail::Message object used to email users belonging to the added document's project.
5351 0:513646585e45 Chris
  #
5352
  # Example:
5353 1115:433d4f72a19b Chris
  #   document_added(document) => Mail::Message object
5354
  #   Mailer.document_added(document).deliver => sends an email to the document's project recipients
5355 0:513646585e45 Chris
  def document_added(document)
5356
    redmine_headers 'Project' => document.project.identifier
5357 1115:433d4f72a19b Chris
    @author = User.current
5358
    @document = document
5359
    @document_url = url_for(:controller => 'documents', :action => 'show', :id => document)
5360
    mail :to => document.recipients,
5361
      :subject => "[#{document.project.name}] #{l(:label_document_new)}: #{document.title}"
5362 0:513646585e45 Chris
  end
5363
5364 1115:433d4f72a19b Chris
  # Builds a Mail::Message object used to email recipients of a project when an attachements are added.
5365 0:513646585e45 Chris
  #
5366
  # Example:
5367 1115:433d4f72a19b Chris
  #   attachments_added(attachments) => Mail::Message object
5368
  #   Mailer.attachments_added(attachments).deliver => sends an email to the project's recipients
5369 0:513646585e45 Chris
  def attachments_added(attachments)
5370
    container = attachments.first.container
5371
    added_to = ''
5372
    added_to_url = ''
5373 1115:433d4f72a19b Chris
    @author = attachments.first.author
5374 0:513646585e45 Chris
    case container.class.name
5375
    when 'Project'
5376 441:cbce1fd3b1b7 Chris
      added_to_url = url_for(:controller => 'files', :action => 'index', :project_id => container)
5377 0:513646585e45 Chris
      added_to = "#{l(:label_project)}: #{container}"
5378 1115:433d4f72a19b Chris
      recipients = container.project.notified_users.select {|user| user.allowed_to?(:view_files, container.project)}.collect  {|u| u.mail}
5379 0:513646585e45 Chris
    when 'Version'
5380 441:cbce1fd3b1b7 Chris
      added_to_url = url_for(:controller => 'files', :action => 'index', :project_id => container.project)
5381 0:513646585e45 Chris
      added_to = "#{l(:label_version)}: #{container.name}"
5382 1115:433d4f72a19b Chris
      recipients = container.project.notified_users.select {|user| user.allowed_to?(:view_files, container.project)}.collect  {|u| u.mail}
5383 0:513646585e45 Chris
    when 'Document'
5384
      added_to_url = url_for(:controller => 'documents', :action => 'show', :id => container.id)
5385
      added_to = "#{l(:label_document)}: #{container.title}"
5386 1115:433d4f72a19b Chris
      recipients = container.recipients
5387 0:513646585e45 Chris
    end
5388
    redmine_headers 'Project' => container.project.identifier
5389 1115:433d4f72a19b Chris
    @attachments = attachments
5390
    @added_to = added_to
5391
    @added_to_url = added_to_url
5392
    mail :to => recipients,
5393
      :subject => "[#{container.project.name}] #{l(:label_attachment_new)}"
5394 0:513646585e45 Chris
  end
5395 441:cbce1fd3b1b7 Chris
5396 1115:433d4f72a19b Chris
  # Builds a Mail::Message object used to email recipients of a news' project when a news item is added.
5397 0:513646585e45 Chris
  #
5398
  # Example:
5399 1115:433d4f72a19b Chris
  #   news_added(news) => Mail::Message object
5400
  #   Mailer.news_added(news).deliver => sends an email to the news' project recipients
5401 0:513646585e45 Chris
  def news_added(news)
5402
    redmine_headers 'Project' => news.project.identifier
5403 1115:433d4f72a19b Chris
    @author = news.author
5404 0:513646585e45 Chris
    message_id news
5405 1464:261b3d9a4903 Chris
    references news
5406 1115:433d4f72a19b Chris
    @news = news
5407
    @news_url = url_for(:controller => 'news', :action => 'show', :id => news)
5408
    mail :to => news.recipients,
5409 1517:dffacf8a6908 Chris
      :cc => news.cc_for_added_news,
5410 1115:433d4f72a19b Chris
      :subject => "[#{news.project.name}] #{l(:label_news)}: #{news.title}"
5411 0:513646585e45 Chris
  end
5412
5413 1115:433d4f72a19b Chris
  # Builds a Mail::Message object used to email recipients of a news' project when a news comment is added.
5414 441:cbce1fd3b1b7 Chris
  #
5415
  # Example:
5416 1115:433d4f72a19b Chris
  #   news_comment_added(comment) => Mail::Message object
5417 441:cbce1fd3b1b7 Chris
  #   Mailer.news_comment_added(comment) => sends an email to the news' project recipients
5418
  def news_comment_added(comment)
5419
    news = comment.commented
5420
    redmine_headers 'Project' => news.project.identifier
5421 1115:433d4f72a19b Chris
    @author = comment.author
5422 441:cbce1fd3b1b7 Chris
    message_id comment
5423 1464:261b3d9a4903 Chris
    references news
5424 1115:433d4f72a19b Chris
    @news = news
5425
    @comment = comment
5426
    @news_url = url_for(:controller => 'news', :action => 'show', :id => news)
5427
    mail :to => news.recipients,
5428
     :cc => news.watcher_recipients,
5429
     :subject => "Re: [#{news.project.name}] #{l(:label_news)}: #{news.title}"
5430 441:cbce1fd3b1b7 Chris
  end
5431
5432 1115:433d4f72a19b Chris
  # Builds a Mail::Message object used to email the recipients of the specified message that was posted.
5433 0:513646585e45 Chris
  #
5434
  # Example:
5435 1115:433d4f72a19b Chris
  #   message_posted(message) => Mail::Message object
5436
  #   Mailer.message_posted(message).deliver => sends an email to the recipients
5437 0:513646585e45 Chris
  def message_posted(message)
5438
    redmine_headers 'Project' => message.project.identifier,
5439
                    'Topic-Id' => (message.parent_id || message.id)
5440 1115:433d4f72a19b Chris
    @author = message.author
5441 0:513646585e45 Chris
    message_id message
5442 1464:261b3d9a4903 Chris
    references message.root
5443 1115:433d4f72a19b Chris
    recipients = message.recipients
5444
    cc = ((message.root.watcher_recipients + message.board.watcher_recipients).uniq - recipients)
5445
    @message = message
5446
    @message_url = url_for(message.event_url)
5447
    mail :to => recipients,
5448
      :cc => cc,
5449
      :subject => "[#{message.board.project.name} - #{message.board.name} - msg#{message.root.id}] #{message.subject}"
5450 0:513646585e45 Chris
  end
5451 441:cbce1fd3b1b7 Chris
5452 1115:433d4f72a19b Chris
  # Builds a Mail::Message object used to email the recipients of a project of the specified wiki content was added.
5453 0:513646585e45 Chris
  #
5454
  # Example:
5455 1115:433d4f72a19b Chris
  #   wiki_content_added(wiki_content) => Mail::Message object
5456
  #   Mailer.wiki_content_added(wiki_content).deliver => sends an email to the project's recipients
5457 0:513646585e45 Chris
  def wiki_content_added(wiki_content)
5458
    redmine_headers 'Project' => wiki_content.project.identifier,
5459
                    'Wiki-Page-Id' => wiki_content.page.id
5460 1115:433d4f72a19b Chris
    @author = wiki_content.author
5461 0:513646585e45 Chris
    message_id wiki_content
5462 1115:433d4f72a19b Chris
    recipients = wiki_content.recipients
5463
    cc = wiki_content.page.wiki.watcher_recipients - recipients
5464
    @wiki_content = wiki_content
5465
    @wiki_content_url = url_for(:controller => 'wiki', :action => 'show',
5466 909:cbb26bc654de Chris
                                      :project_id => wiki_content.project,
5467
                                      :id => wiki_content.page.title)
5468 1115:433d4f72a19b Chris
    mail :to => recipients,
5469
      :cc => cc,
5470
      :subject => "[#{wiki_content.project.name}] #{l(:mail_subject_wiki_content_added, :id => wiki_content.page.pretty_title)}"
5471 0:513646585e45 Chris
  end
5472 441:cbce1fd3b1b7 Chris
5473 1115:433d4f72a19b Chris
  # Builds a Mail::Message object used to email the recipients of a project of the specified wiki content was updated.
5474 0:513646585e45 Chris
  #
5475
  # Example:
5476 1115:433d4f72a19b Chris
  #   wiki_content_updated(wiki_content) => Mail::Message object
5477
  #   Mailer.wiki_content_updated(wiki_content).deliver => sends an email to the project's recipients
5478 0:513646585e45 Chris
  def wiki_content_updated(wiki_content)
5479
    redmine_headers 'Project' => wiki_content.project.identifier,
5480
                    'Wiki-Page-Id' => wiki_content.page.id
5481 1115:433d4f72a19b Chris
    @author = wiki_content.author
5482 0:513646585e45 Chris
    message_id wiki_content
5483 1115:433d4f72a19b Chris
    recipients = wiki_content.recipients
5484
    cc = wiki_content.page.wiki.watcher_recipients + wiki_content.page.watcher_recipients - recipients
5485
    @wiki_content = wiki_content
5486
    @wiki_content_url = url_for(:controller => 'wiki', :action => 'show',
5487 909:cbb26bc654de Chris
                                      :project_id => wiki_content.project,
5488 1115:433d4f72a19b Chris
                                      :id => wiki_content.page.title)
5489
    @wiki_diff_url = url_for(:controller => 'wiki', :action => 'diff',
5490 909:cbb26bc654de Chris
                                   :project_id => wiki_content.project, :id => wiki_content.page.title,
5491
                                   :version => wiki_content.version)
5492 1115:433d4f72a19b Chris
    mail :to => recipients,
5493
      :cc => cc,
5494
      :subject => "[#{wiki_content.project.name}] #{l(:mail_subject_wiki_content_updated, :id => wiki_content.page.pretty_title)}"
5495 0:513646585e45 Chris
  end
5496
5497 1115:433d4f72a19b Chris
  # Builds a Mail::Message object used to email the specified user their account information.
5498 0:513646585e45 Chris
  #
5499
  # Example:
5500 1115:433d4f72a19b Chris
  #   account_information(user, password) => Mail::Message object
5501
  #   Mailer.account_information(user, password).deliver => sends account information to the user
5502 0:513646585e45 Chris
  def account_information(user, password)
5503
    set_language_if_valid user.language
5504 1115:433d4f72a19b Chris
    @user = user
5505
    @password = password
5506
    @login_url = url_for(:controller => 'account', :action => 'login')
5507
    mail :to => user.mail,
5508
      :subject => l(:mail_subject_register, Setting.app_title)
5509 0:513646585e45 Chris
  end
5510
5511 1115:433d4f72a19b Chris
  # Builds a Mail::Message object used to email all active administrators of an account activation request.
5512 0:513646585e45 Chris
  #
5513
  # Example:
5514 1115:433d4f72a19b Chris
  #   account_activation_request(user) => Mail::Message object
5515
  #   Mailer.account_activation_request(user).deliver => sends an email to all active administrators
5516 0:513646585e45 Chris
  def account_activation_request(user)
5517
    # Send the email to all active administrators
5518 1517:dffacf8a6908 Chris
    recipients = User.active.where(:admin => true).collect { |u| u.mail }.compact
5519 1115:433d4f72a19b Chris
    @user = user
5520
    @url = url_for(:controller => 'users', :action => 'index',
5521 909:cbb26bc654de Chris
                         :status => User::STATUS_REGISTERED,
5522
                         :sort_key => 'created_on', :sort_order => 'desc')
5523 1115:433d4f72a19b Chris
    mail :to => recipients,
5524
      :subject => l(:mail_subject_account_activation_request, Setting.app_title)
5525 0:513646585e45 Chris
  end
5526
5527 1115:433d4f72a19b Chris
  # Builds a Mail::Message object used to email the specified user that their account was activated by an administrator.
5528 0:513646585e45 Chris
  #
5529
  # Example:
5530 1115:433d4f72a19b Chris
  #   account_activated(user) => Mail::Message object
5531
  #   Mailer.account_activated(user).deliver => sends an email to the registered user
5532 0:513646585e45 Chris
  def account_activated(user)
5533
    set_language_if_valid user.language
5534 1115:433d4f72a19b Chris
    @user = user
5535
    @login_url = url_for(:controller => 'account', :action => 'login')
5536
    mail :to => user.mail,
5537
      :subject => l(:mail_subject_register, Setting.app_title)
5538 0:513646585e45 Chris
  end
5539
5540
  def lost_password(token)
5541
    set_language_if_valid(token.user.language)
5542 1115:433d4f72a19b Chris
    @token = token
5543
    @url = url_for(:controller => 'account', :action => 'lost_password', :token => token.value)
5544
    mail :to => token.user.mail,
5545
      :subject => l(:mail_subject_lost_password, Setting.app_title)
5546 0:513646585e45 Chris
  end
5547
5548
  def register(token)
5549
    set_language_if_valid(token.user.language)
5550 1115:433d4f72a19b Chris
    @token = token
5551
    @url = url_for(:controller => 'account', :action => 'activate', :token => token.value)
5552
    mail :to => token.user.mail,
5553
      :subject => l(:mail_subject_register, Setting.app_title)
5554 0:513646585e45 Chris
  end
5555
5556 1115:433d4f72a19b Chris
  def test_email(user)
5557 0:513646585e45 Chris
    set_language_if_valid(user.language)
5558 1115:433d4f72a19b Chris
    @url = url_for(:controller => 'welcome')
5559
    mail :to => user.mail,
5560
      :subject => 'Redmine test'
5561 0:513646585e45 Chris
  end
5562
5563
  # Sends reminders to issue assignees
5564
  # Available options:
5565
  # * :days     => how many days in the future to remind about (defaults to 7)
5566
  # * :tracker  => id of tracker for filtering issues (defaults to all trackers)
5567
  # * :project  => id or identifier of project to process (defaults to all projects)
5568 1115:433d4f72a19b Chris
  # * :users    => array of user/group ids who should be reminded
5569 0:513646585e45 Chris
  def self.reminders(options={})
5570
    days = options[:days] || 7
5571
    project = options[:project] ? Project.find(options[:project]) : nil
5572
    tracker = options[:tracker] ? Tracker.find(options[:tracker]) : nil
5573 22:40f7cfd4df19 chris
    user_ids = options[:users]
5574 0:513646585e45 Chris
5575 1115:433d4f72a19b Chris
    scope = Issue.open.where("#{Issue.table_name}.assigned_to_id IS NOT NULL" +
5576
      " AND #{Project.table_name}.status = #{Project::STATUS_ACTIVE}" +
5577
      " AND #{Issue.table_name}.due_date <= ?", days.day.from_now.to_date
5578
    )
5579
    scope = scope.where(:assigned_to_id => user_ids) if user_ids.present?
5580
    scope = scope.where(:project_id => project.id) if project
5581
    scope = scope.where(:tracker_id => tracker.id) if tracker
5582 1517:dffacf8a6908 Chris
    issues_by_assignee = scope.includes(:status, :assigned_to, :project, :tracker).
5583
                              group_by(&:assigned_to)
5584 1115:433d4f72a19b Chris
    issues_by_assignee.keys.each do |assignee|
5585
      if assignee.is_a?(Group)
5586
        assignee.users.each do |user|
5587
          issues_by_assignee[user] ||= []
5588
          issues_by_assignee[user] += issues_by_assignee[assignee]
5589
        end
5590
      end
5591
    end
5592
5593 0:513646585e45 Chris
    issues_by_assignee.each do |assignee, issues|
5594 1115:433d4f72a19b Chris
      reminder(assignee, issues, days).deliver if assignee.is_a?(User) && assignee.active?
5595 0:513646585e45 Chris
    end
5596
  end
5597 441:cbce1fd3b1b7 Chris
5598 0:513646585e45 Chris
  # Activates/desactivates email deliveries during +block+
5599
  def self.with_deliveries(enabled = true, &block)
5600
    was_enabled = ActionMailer::Base.perform_deliveries
5601
    ActionMailer::Base.perform_deliveries = !!enabled
5602
    yield
5603
  ensure
5604
    ActionMailer::Base.perform_deliveries = was_enabled
5605
  end
5606
5607 1115:433d4f72a19b Chris
  # Sends emails synchronously in the given block
5608
  def self.with_synched_deliveries(&block)
5609
    saved_method = ActionMailer::Base.delivery_method
5610
    if m = saved_method.to_s.match(%r{^async_(.+)$})
5611
      synched_method = m[1]
5612
      ActionMailer::Base.delivery_method = synched_method.to_sym
5613
      ActionMailer::Base.send "#{synched_method}_settings=", ActionMailer::Base.send("async_#{synched_method}_settings")
5614
    end
5615
    yield
5616
  ensure
5617
    ActionMailer::Base.delivery_method = saved_method
5618
  end
5619 441:cbce1fd3b1b7 Chris
5620 1464:261b3d9a4903 Chris
  def mail(headers={}, &block)
5621 1115:433d4f72a19b Chris
    headers.merge! 'X-Mailer' => 'Redmine',
5622 0:513646585e45 Chris
            'X-Redmine-Host' => Setting.host_name,
5623
            'X-Redmine-Site' => Setting.app_title,
5624 909:cbb26bc654de Chris
            'X-Auto-Response-Suppress' => 'OOF',
5625 1115:433d4f72a19b Chris
            'Auto-Submitted' => 'auto-generated',
5626
            'From' => Setting.mail_from,
5627
            'List-Id' => "<#{Setting.mail_from.to_s.gsub('@', '.')}>"
5628
5629
    # Removes the author from the recipients and cc
5630 1464:261b3d9a4903 Chris
    # if the author does not want to receive notifications
5631
    # about what the author do
5632
    if @author && @author.logged? && @author.pref.no_self_notified
5633 1115:433d4f72a19b Chris
      headers[:to].delete(@author.mail) if headers[:to].is_a?(Array)
5634
      headers[:cc].delete(@author.mail) if headers[:cc].is_a?(Array)
5635
    end
5636
5637
    if @author && @author.logged?
5638
      redmine_headers 'Sender' => @author.login
5639
    end
5640
5641
    # Blind carbon copy recipients
5642
    if Setting.bcc_recipients?
5643
      headers[:bcc] = [headers[:to], headers[:cc]].flatten.uniq.reject(&:blank?)
5644
      headers[:to] = nil
5645
      headers[:cc] = nil
5646
    end
5647
5648
    if @message_id_object
5649
      headers[:message_id] = "<#{self.class.message_id_for(@message_id_object)}>"
5650
    end
5651
    if @references_objects
5652 1464:261b3d9a4903 Chris
      headers[:references] = @references_objects.collect {|o| "<#{self.class.references_for(o)}>"}.join(' ')
5653 1115:433d4f72a19b Chris
    end
5654
5655 1464:261b3d9a4903 Chris
    m = if block_given?
5656
      super headers, &block
5657
    else
5658
      super headers do |format|
5659
        format.text
5660
        format.html unless Setting.plain_text_mail?
5661
      end
5662 1115:433d4f72a19b Chris
    end
5663 1464:261b3d9a4903 Chris
    set_language_if_valid @initial_language
5664 1115:433d4f72a19b Chris
5665 1464:261b3d9a4903 Chris
    m
5666 0:513646585e45 Chris
  end
5667
5668 1115:433d4f72a19b Chris
  def initialize(*args)
5669
    @initial_language = current_language
5670
    set_language_if_valid Setting.default_language
5671
    super
5672
  end
5673 1464:261b3d9a4903 Chris
5674 1115:433d4f72a19b Chris
  def self.deliver_mail(mail)
5675
    return false if mail.to.blank? && mail.cc.blank? && mail.bcc.blank?
5676 1464:261b3d9a4903 Chris
    begin
5677
      # Log errors when raise_delivery_errors is set to false, Rails does not
5678
      mail.raise_delivery_errors = true
5679
      super
5680
    rescue Exception => e
5681
      if ActionMailer::Base.raise_delivery_errors
5682
        raise e
5683
      else
5684
        Rails.logger.error "Email delivery error: #{e.message}"
5685
      end
5686
    end
5687 1115:433d4f72a19b Chris
  end
5688
5689
  def self.method_missing(method, *args, &block)
5690
    if m = method.to_s.match(%r{^deliver_(.+)$})
5691
      ActiveSupport::Deprecation.warn "Mailer.deliver_#{m[1]}(*args) is deprecated. Use Mailer.#{m[1]}(*args).deliver instead."
5692
      send(m[1], *args).deliver
5693
    else
5694
      super
5695
    end
5696
  end
5697
5698
  private
5699
5700 0:513646585e45 Chris
  # Appends a Redmine header field (name is prepended with 'X-Redmine-')
5701
  def redmine_headers(h)
5702 1115:433d4f72a19b Chris
    h.each { |k,v| headers["X-Redmine-#{k}"] = v.to_s }
5703 0:513646585e45 Chris
  end
5704
5705 1464:261b3d9a4903 Chris
  def self.token_for(object, rand=true)
5706 441:cbce1fd3b1b7 Chris
    timestamp = object.send(object.respond_to?(:created_on) ? :created_on : :updated_on)
5707 1464:261b3d9a4903 Chris
    hash = [
5708
      "redmine",
5709
      "#{object.class.name.demodulize.underscore}-#{object.id}",
5710
      timestamp.strftime("%Y%m%d%H%M%S")
5711
    ]
5712
    if rand
5713
      hash << Redmine::Utils.random_hex(8)
5714
    end
5715 1517:dffacf8a6908 Chris
    host = Setting.mail_from.to_s.strip.gsub(%r{^.*@|>}, '')
5716 0:513646585e45 Chris
    host = "#{::Socket.gethostname}.redmine" if host.empty?
5717 1464:261b3d9a4903 Chris
    "#{hash.join('.')}@#{host}"
5718
  end
5719
5720
  # Returns a Message-Id for the given object
5721
  def self.message_id_for(object)
5722
    token_for(object, true)
5723
  end
5724
5725
  # Returns a uniq token for a given object referenced by all notifications
5726
  # related to this object
5727
  def self.references_for(object)
5728
    token_for(object, false)
5729 0:513646585e45 Chris
  end
5730 441:cbce1fd3b1b7 Chris
5731 0:513646585e45 Chris
  def message_id(object)
5732
    @message_id_object = object
5733
  end
5734 441:cbce1fd3b1b7 Chris
5735 0:513646585e45 Chris
  def references(object)
5736
    @references_objects ||= []
5737
    @references_objects << object
5738
  end
5739 441:cbce1fd3b1b7 Chris
5740 0:513646585e45 Chris
  def mylogger
5741 909:cbb26bc654de Chris
    Rails.logger
5742 0:513646585e45 Chris
  end
5743
end
5744 929:5f33065ddc4b Chris
5745
# Patch TMail so that message_id is not overwritten
5746 1116:bb32da3bea34 Chris
5747
### NB: Redmine 2.2 no longer uses TMail I think? This function has
5748
### been removed there
5749
5750 929:5f33065ddc4b Chris
module TMail
5751
  class Mail
5752
    def add_message_id( fqdn = nil )
5753
      self.message_id ||= ::TMail::new_message_id(fqdn)
5754
    end
5755
  end
5756
end
5757 291:f3b2dc533e83 luis
5758 0:513646585e45 Chris
# Redmine - project management software
5759 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
5760 0:513646585e45 Chris
#
5761
# This program is free software; you can redistribute it and/or
5762
# modify it under the terms of the GNU General Public License
5763
# as published by the Free Software Foundation; either version 2
5764
# of the License, or (at your option) any later version.
5765 909:cbb26bc654de Chris
#
5766 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
5767
# but WITHOUT ANY WARRANTY; without even the implied warranty of
5768
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
5769
# GNU General Public License for more details.
5770 909:cbb26bc654de Chris
#
5771 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
5772
# along with this program; if not, write to the Free Software
5773
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
5774
5775
class Member < ActiveRecord::Base
5776
  belongs_to :user
5777
  belongs_to :principal, :foreign_key => 'user_id'
5778
  has_many :member_roles, :dependent => :destroy
5779
  has_many :roles, :through => :member_roles
5780
  belongs_to :project
5781
5782
  validates_presence_of :principal, :project
5783
  validates_uniqueness_of :user_id, :scope => :project_id
5784 1115:433d4f72a19b Chris
  validate :validate_role
5785 0:513646585e45 Chris
5786 1115:433d4f72a19b Chris
  before_destroy :set_issue_category_nil
5787 909:cbb26bc654de Chris
5788 1115:433d4f72a19b Chris
  def role
5789
  end
5790
5791
  def role=
5792
  end
5793
5794 0:513646585e45 Chris
  def name
5795
    self.user.name
5796
  end
5797 909:cbb26bc654de Chris
5798 0:513646585e45 Chris
  alias :base_role_ids= :role_ids=
5799
  def role_ids=(arg)
5800
    ids = (arg || []).collect(&:to_i) - [0]
5801
    # Keep inherited roles
5802
    ids += member_roles.select {|mr| !mr.inherited_from.nil?}.collect(&:role_id)
5803 909:cbb26bc654de Chris
5804 0:513646585e45 Chris
    new_role_ids = ids - role_ids
5805
    # Add new roles
5806
    new_role_ids.each {|id| member_roles << MemberRole.new(:role_id => id) }
5807
    # Remove roles (Rails' #role_ids= will not trigger MemberRole#on_destroy)
5808
    member_roles_to_destroy = member_roles.select {|mr| !ids.include?(mr.role_id)}
5809
    if member_roles_to_destroy.any?
5810
      member_roles_to_destroy.each(&:destroy)
5811
    end
5812
  end
5813 909:cbb26bc654de Chris
5814 0:513646585e45 Chris
  def <=>(member)
5815
    a, b = roles.sort.first, member.roles.sort.first
5816 929:5f33065ddc4b Chris
    if a == b
5817
      if principal
5818
        principal <=> member.principal
5819
      else
5820
        1
5821
      end
5822
    elsif a
5823
      a <=> b
5824
    else
5825
      1
5826
    end
5827 0:513646585e45 Chris
  end
5828 909:cbb26bc654de Chris
5829 0:513646585e45 Chris
  def deletable?
5830
    member_roles.detect {|mr| mr.inherited_from}.nil?
5831
  end
5832 909:cbb26bc654de Chris
5833 1517:dffacf8a6908 Chris
  def destroy
5834
    if member_roles.reload.present?
5835
      # destroying the last role will destroy another instance
5836
      # of the same Member record, #super would then trigger callbacks twice
5837
      member_roles.destroy_all
5838
      @destroyed = true
5839
      freeze
5840
    else
5841
      super
5842
    end
5843
  end
5844
5845 0:513646585e45 Chris
  def include?(user)
5846
    if principal.is_a?(Group)
5847
      !user.nil? && user.groups.include?(principal)
5848
    else
5849
      self.user == user
5850
    end
5851
  end
5852 909:cbb26bc654de Chris
5853 1115:433d4f72a19b Chris
  def set_issue_category_nil
5854 0:513646585e45 Chris
    if user
5855
      # remove category based auto assignments for this member
5856 1517:dffacf8a6908 Chris
      IssueCategory.where(["project_id = ? AND assigned_to_id = ?", project.id, user.id]).
5857
        update_all("assigned_to_id = NULL")
5858 0:513646585e45 Chris
    end
5859
  end
5860
5861
  # Find or initilize a Member with an id, attributes, and for a Principal
5862
  def self.edit_membership(id, new_attributes, principal=nil)
5863
    @membership = id.present? ? Member.find(id) : Member.new(:principal => principal)
5864
    @membership.attributes = new_attributes
5865
    @membership
5866
  end
5867 909:cbb26bc654de Chris
5868 1464:261b3d9a4903 Chris
  # Finds or initilizes a Member for the given project and principal
5869
  def self.find_or_new(project, principal)
5870
    project_id = project.is_a?(Project) ? project.id : project
5871
    principal_id = principal.is_a?(Principal) ? principal.id : principal
5872
5873
    member = Member.find_by_project_id_and_user_id(project_id, principal_id)
5874
    member ||= Member.new(:project_id => project_id, :user_id => principal_id)
5875
    member
5876
  end
5877
5878 0:513646585e45 Chris
  protected
5879 909:cbb26bc654de Chris
5880 1115:433d4f72a19b Chris
  def validate_role
5881 14:1d32c0a0efbf Chris
    errors.add_on_empty :role if member_roles.empty? && roles.empty?
5882 0:513646585e45 Chris
  end
5883
end
5884
# Redmine - project management software
5885 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
5886 0:513646585e45 Chris
#
5887
# This program is free software; you can redistribute it and/or
5888
# modify it under the terms of the GNU General Public License
5889
# as published by the Free Software Foundation; either version 2
5890
# of the License, or (at your option) any later version.
5891 909:cbb26bc654de Chris
#
5892 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
5893
# but WITHOUT ANY WARRANTY; without even the implied warranty of
5894
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
5895
# GNU General Public License for more details.
5896 909:cbb26bc654de Chris
#
5897 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
5898
# along with this program; if not, write to the Free Software
5899
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
5900
5901
class MemberRole < ActiveRecord::Base
5902
  belongs_to :member
5903
  belongs_to :role
5904 909:cbb26bc654de Chris
5905 0:513646585e45 Chris
  after_destroy :remove_member_if_empty
5906
5907 1464:261b3d9a4903 Chris
  after_create :add_role_to_group_users, :add_role_to_subprojects
5908
  after_destroy :remove_inherited_roles
5909 909:cbb26bc654de Chris
5910 0:513646585e45 Chris
  validates_presence_of :role
5911 1115:433d4f72a19b Chris
  validate :validate_role_member
5912 909:cbb26bc654de Chris
5913 1115:433d4f72a19b Chris
  def validate_role_member
5914 0:513646585e45 Chris
    errors.add :role_id, :invalid if role && !role.member?
5915
  end
5916 909:cbb26bc654de Chris
5917 0:513646585e45 Chris
  def inherited?
5918
    !inherited_from.nil?
5919
  end
5920 909:cbb26bc654de Chris
5921 0:513646585e45 Chris
  private
5922 909:cbb26bc654de Chris
5923 0:513646585e45 Chris
  def remove_member_if_empty
5924
    if member.roles.empty?
5925
      member.destroy
5926
    end
5927
  end
5928 909:cbb26bc654de Chris
5929 0:513646585e45 Chris
  def add_role_to_group_users
5930 1464:261b3d9a4903 Chris
    if member.principal.is_a?(Group) && !inherited?
5931 0:513646585e45 Chris
      member.principal.users.each do |user|
5932 1464:261b3d9a4903 Chris
        user_member = Member.find_or_new(member.project_id, user.id)
5933 0:513646585e45 Chris
        user_member.member_roles << MemberRole.new(:role => role, :inherited_from => id)
5934
        user_member.save!
5935
      end
5936
    end
5937
  end
5938 909:cbb26bc654de Chris
5939 1464:261b3d9a4903 Chris
  def add_role_to_subprojects
5940
    member.project.children.each do |subproject|
5941
      if subproject.inherit_members?
5942
        child_member = Member.find_or_new(subproject.id, member.user_id)
5943
        child_member.member_roles << MemberRole.new(:role => role, :inherited_from => id)
5944
        child_member.save!
5945 0:513646585e45 Chris
      end
5946
    end
5947
  end
5948 1464:261b3d9a4903 Chris
5949
  def remove_inherited_roles
5950 1517:dffacf8a6908 Chris
    MemberRole.where(:inherited_from => id).group_by(&:member).
5951
        each do |member, member_roles|
5952 1464:261b3d9a4903 Chris
      member_roles.each(&:destroy)
5953
    end
5954
  end
5955 0:513646585e45 Chris
end
5956 441:cbce1fd3b1b7 Chris
# Redmine - project management software
5957 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
5958 0:513646585e45 Chris
#
5959
# This program is free software; you can redistribute it and/or
5960
# modify it under the terms of the GNU General Public License
5961
# as published by the Free Software Foundation; either version 2
5962
# of the License, or (at your option) any later version.
5963 441:cbce1fd3b1b7 Chris
#
5964 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
5965
# but WITHOUT ANY WARRANTY; without even the implied warranty of
5966
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
5967
# GNU General Public License for more details.
5968 441:cbce1fd3b1b7 Chris
#
5969 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
5970
# along with this program; if not, write to the Free Software
5971
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
5972
5973
class Message < ActiveRecord::Base
5974 929:5f33065ddc4b Chris
  include Redmine::SafeAttributes
5975 0:513646585e45 Chris
  belongs_to :board
5976
  belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
5977
  acts_as_tree :counter_cache => :replies_count, :order => "#{Message.table_name}.created_on ASC"
5978
  acts_as_attachable
5979
  belongs_to :last_reply, :class_name => 'Message', :foreign_key => 'last_reply_id'
5980 441:cbce1fd3b1b7 Chris
5981 0:513646585e45 Chris
  acts_as_searchable :columns => ['subject', 'content'],
5982
                     :include => {:board => :project},
5983 441:cbce1fd3b1b7 Chris
                     :project_key => "#{Board.table_name}.project_id",
5984 0:513646585e45 Chris
                     :date_column => "#{table_name}.created_on"
5985
  acts_as_event :title => Proc.new {|o| "#{o.board.name}: #{o.subject}"},
5986
                :description => :content,
5987 1464:261b3d9a4903 Chris
                :group => :parent,
5988 0:513646585e45 Chris
                :type => Proc.new {|o| o.parent_id.nil? ? 'message' : 'reply'},
5989 909:cbb26bc654de Chris
                :url => Proc.new {|o| {:controller => 'messages', :action => 'show', :board_id => o.board_id}.merge(o.parent_id.nil? ? {:id => o.id} :
5990 0:513646585e45 Chris
                                                                                                                                       {:id => o.parent_id, :r => o.id, :anchor => "message-#{o.id}"})}
5991
5992
  acts_as_activity_provider :find_options => {:include => [{:board => :project}, :author]},
5993
                            :author_key => :author_id
5994
  acts_as_watchable
5995 441:cbce1fd3b1b7 Chris
5996 0:513646585e45 Chris
  validates_presence_of :board, :subject, :content
5997
  validates_length_of :subject, :maximum => 255
5998 909:cbb26bc654de Chris
  validate :cannot_reply_to_locked_topic, :on => :create
5999 441:cbce1fd3b1b7 Chris
6000 1115:433d4f72a19b Chris
  after_create :add_author_as_watcher, :reset_counters!
6001 909:cbb26bc654de Chris
  after_update :update_messages_board
6002 1115:433d4f72a19b Chris
  after_destroy :reset_counters!
6003 1464:261b3d9a4903 Chris
  after_create :send_notification
6004 441:cbce1fd3b1b7 Chris
6005 1464:261b3d9a4903 Chris
  scope :visible, lambda {|*args|
6006
    includes(:board => :project).where(Project.allowed_to_condition(args.shift || User.current, :view_messages, *args))
6007
  }
6008 441:cbce1fd3b1b7 Chris
6009 929:5f33065ddc4b Chris
  safe_attributes 'subject', 'content'
6010
  safe_attributes 'locked', 'sticky', 'board_id',
6011
    :if => lambda {|message, user|
6012
      user.allowed_to?(:edit_messages, message.project)
6013
    }
6014
6015 0:513646585e45 Chris
  def visible?(user=User.current)
6016
    !user.nil? && user.allowed_to?(:view_messages, project)
6017
  end
6018 441:cbce1fd3b1b7 Chris
6019 909:cbb26bc654de Chris
  def cannot_reply_to_locked_topic
6020 0:513646585e45 Chris
    # Can not reply to a locked topic
6021 909:cbb26bc654de Chris
    errors.add :base, 'Topic is locked' if root.locked? && self != root
6022 0:513646585e45 Chris
  end
6023 441:cbce1fd3b1b7 Chris
6024 909:cbb26bc654de Chris
  def update_messages_board
6025 0:513646585e45 Chris
    if board_id_changed?
6026 1517:dffacf8a6908 Chris
      Message.where(["id = ? OR parent_id = ?", root.id, root.id]).update_all({:board_id => board_id})
6027 0:513646585e45 Chris
      Board.reset_counters!(board_id_was)
6028
      Board.reset_counters!(board_id)
6029
    end
6030
  end
6031 441:cbce1fd3b1b7 Chris
6032 1115:433d4f72a19b Chris
  def reset_counters!
6033
    if parent && parent.id
6034 1517:dffacf8a6908 Chris
      Message.where({:id => parent.id}).update_all({:last_reply_id => parent.children.maximum(:id)})
6035 1115:433d4f72a19b Chris
    end
6036 0:513646585e45 Chris
    board.reset_counters!
6037
  end
6038 441:cbce1fd3b1b7 Chris
6039 0:513646585e45 Chris
  def sticky=(arg)
6040
    write_attribute :sticky, (arg == true || arg.to_s == '1' ? 1 : 0)
6041
  end
6042 441:cbce1fd3b1b7 Chris
6043 0:513646585e45 Chris
  def sticky?
6044
    sticky == 1
6045
  end
6046 441:cbce1fd3b1b7 Chris
6047 0:513646585e45 Chris
  def project
6048
    board.project
6049
  end
6050
6051
  def editable_by?(usr)
6052
    usr && usr.logged? && (usr.allowed_to?(:edit_messages, project) || (self.author == usr && usr.allowed_to?(:edit_own_messages, project)))
6053
  end
6054
6055
  def destroyable_by?(usr)
6056
    usr && usr.logged? && (usr.allowed_to?(:delete_messages, project) || (self.author == usr && usr.allowed_to?(:delete_own_messages, project)))
6057
  end
6058 441:cbce1fd3b1b7 Chris
6059 0:513646585e45 Chris
  private
6060 441:cbce1fd3b1b7 Chris
6061 0:513646585e45 Chris
  def add_author_as_watcher
6062
    Watcher.create(:watchable => self.root, :user => author)
6063
  end
6064 1464:261b3d9a4903 Chris
6065
  def send_notification
6066
    if Setting.notified_events.include?('message_posted')
6067
      Mailer.message_posted(self).deliver
6068
    end
6069
  end
6070 0:513646585e45 Chris
end
6071
# Redmine - project management software
6072 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
6073 0:513646585e45 Chris
#
6074
# This program is free software; you can redistribute it and/or
6075
# modify it under the terms of the GNU General Public License
6076
# as published by the Free Software Foundation; either version 2
6077
# of the License, or (at your option) any later version.
6078 441:cbce1fd3b1b7 Chris
#
6079 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
6080
# but WITHOUT ANY WARRANTY; without even the implied warranty of
6081
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
6082
# GNU General Public License for more details.
6083 441:cbce1fd3b1b7 Chris
#
6084 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
6085
# along with this program; if not, write to the Free Software
6086
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
6087
6088
class News < ActiveRecord::Base
6089 929:5f33065ddc4b Chris
  include Redmine::SafeAttributes
6090 0:513646585e45 Chris
  belongs_to :project
6091
  belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
6092
  has_many :comments, :as => :commented, :dependent => :delete_all, :order => "created_on"
6093 441:cbce1fd3b1b7 Chris
6094 0:513646585e45 Chris
  validates_presence_of :title, :description
6095
  validates_length_of :title, :maximum => 60
6096
  validates_length_of :summary, :maximum => 255
6097
6098 1115:433d4f72a19b Chris
  acts_as_attachable :delete_permission => :manage_news
6099 0:513646585e45 Chris
  acts_as_searchable :columns => ['title', 'summary', "#{table_name}.description"], :include => :project
6100
  acts_as_event :url => Proc.new {|o| {:controller => 'news', :action => 'show', :id => o.id}}
6101
  acts_as_activity_provider :find_options => {:include => [:project, :author]},
6102
                            :author_key => :author_id
6103 441:cbce1fd3b1b7 Chris
  acts_as_watchable
6104
6105
  after_create :add_author_as_watcher
6106 1464:261b3d9a4903 Chris
  after_create :send_notification
6107 441:cbce1fd3b1b7 Chris
6108 1464:261b3d9a4903 Chris
  scope :visible, lambda {|*args|
6109
    includes(:project).where(Project.allowed_to_condition(args.shift || User.current, :view_news, *args))
6110
  }
6111 441:cbce1fd3b1b7 Chris
6112 929:5f33065ddc4b Chris
  safe_attributes 'title', 'summary', 'description'
6113
6114 0:513646585e45 Chris
  def visible?(user=User.current)
6115
    !user.nil? && user.allowed_to?(:view_news, project)
6116
  end
6117 441:cbce1fd3b1b7 Chris
6118 1115:433d4f72a19b Chris
  # Returns true if the news can be commented by user
6119
  def commentable?(user=User.current)
6120
    user.allowed_to?(:comment_news, project)
6121
  end
6122
6123 1464:261b3d9a4903 Chris
  def recipients
6124 1517:dffacf8a6908 Chris
    project.users.select {|user| user.notify_about?(self) && user.allowed_to?(:view_news, project)}.map(&:mail)
6125
  end
6126
6127
  # Returns the email addresses that should be cc'd when a new news is added
6128
  def cc_for_added_news
6129
    cc = []
6130
    if m = project.enabled_module('news')
6131
      cc = m.notified_watchers
6132
      unless project.is_public?
6133
        cc = cc.select {|user| project.users.include?(user)}
6134
      end
6135
    end
6136
    cc.map(&:mail)
6137 1464:261b3d9a4903 Chris
  end
6138
6139 0:513646585e45 Chris
  # returns latest news for projects visible by user
6140
  def self.latest(user = User.current, count = 5)
6141 1115:433d4f72a19b Chris
    visible(user).includes([:author, :project]).order("#{News.table_name}.created_on DESC").limit(count).all
6142 0:513646585e45 Chris
  end
6143 441:cbce1fd3b1b7 Chris
6144
  private
6145
6146
  def add_author_as_watcher
6147
    Watcher.create(:watchable => self, :user => author)
6148
  end
6149 1464:261b3d9a4903 Chris
6150
  def send_notification
6151
    if Setting.notified_events.include?('news_added')
6152
      Mailer.news_added(self).deliver
6153
    end
6154
  end
6155 1484:51364c0cd58f Chris
6156 374:dcfde3922ec2 chris
  # returns latest news for a specific project
6157
  def self.latest_for(project, count = 5)
6158
    find(:all, :limit => count, :conditions => [ "#{News.table_name}.project_id = #{project.id}", Project.allowed_to_condition(User.current, :view_news) ], :include => [ :author, :project ], :order => "#{News.table_name}.created_on DESC")
6159
  end
6160 0:513646585e45 Chris
end
6161
# Redmine - project management software
6162 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
6163 0:513646585e45 Chris
#
6164
# This program is free software; you can redistribute it and/or
6165
# modify it under the terms of the GNU General Public License
6166
# as published by the Free Software Foundation; either version 2
6167
# of the License, or (at your option) any later version.
6168 909:cbb26bc654de Chris
#
6169 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
6170
# but WITHOUT ANY WARRANTY; without even the implied warranty of
6171
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
6172
# GNU General Public License for more details.
6173 909:cbb26bc654de Chris
#
6174 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
6175
# along with this program; if not, write to the Free Software
6176
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
6177
6178
class Principal < ActiveRecord::Base
6179 1115:433d4f72a19b Chris
  self.table_name = "#{table_name_prefix}users#{table_name_suffix}"
6180 0:513646585e45 Chris
6181 1464:261b3d9a4903 Chris
  # Account statuses
6182
  STATUS_ANONYMOUS  = 0
6183
  STATUS_ACTIVE     = 1
6184
  STATUS_REGISTERED = 2
6185
  STATUS_LOCKED     = 3
6186
6187 0:513646585e45 Chris
  has_many :members, :foreign_key => 'user_id', :dependent => :destroy
6188 1517:dffacf8a6908 Chris
  has_many :memberships, :class_name => 'Member',
6189
           :foreign_key => 'user_id',
6190
           :include => [:project, :roles],
6191
           :conditions => "#{Project.table_name}.status<>#{Project::STATUS_ARCHIVED}",
6192
           :order => "#{Project.table_name}.name"
6193 0:513646585e45 Chris
  has_many :projects, :through => :memberships
6194 909:cbb26bc654de Chris
  has_many :issue_categories, :foreign_key => 'assigned_to_id', :dependent => :nullify
6195 0:513646585e45 Chris
6196
  # Groups and active users
6197 1464:261b3d9a4903 Chris
  scope :active, lambda { where(:status => STATUS_ACTIVE) }
6198 909:cbb26bc654de Chris
6199 1115:433d4f72a19b Chris
  scope :like, lambda {|q|
6200
    q = q.to_s
6201
    if q.blank?
6202
      where({})
6203
    else
6204
      pattern = "%#{q}%"
6205
      sql = %w(login firstname lastname mail).map {|column| "LOWER(#{table_name}.#{column}) LIKE LOWER(:p)"}.join(" OR ")
6206
      params = {:p => pattern}
6207
      if q =~ /^(.+)\s+(.+)$/
6208
        a, b = "#{$1}%", "#{$2}%"
6209
        sql << " OR (LOWER(#{table_name}.firstname) LIKE LOWER(:a) AND LOWER(#{table_name}.lastname) LIKE LOWER(:b))"
6210
        sql << " OR (LOWER(#{table_name}.firstname) LIKE LOWER(:b) AND LOWER(#{table_name}.lastname) LIKE LOWER(:a))"
6211
        params.merge!(:a => a, :b => b)
6212
      end
6213
      where(sql, params)
6214
    end
6215
  }
6216
6217
  # Principals that are members of a collection of projects
6218
  scope :member_of, lambda {|projects|
6219
    projects = [projects] unless projects.is_a?(Array)
6220
    if projects.empty?
6221
      where("1=0")
6222
    else
6223
      ids = projects.map(&:id)
6224 1464:261b3d9a4903 Chris
      active.uniq.joins(:members).where("#{Member.table_name}.project_id IN (?)", ids)
6225 1115:433d4f72a19b Chris
    end
6226 0:513646585e45 Chris
  }
6227 929:5f33065ddc4b Chris
  # Principals that are not members of projects
6228 1115:433d4f72a19b Chris
  scope :not_member_of, lambda {|projects|
6229 929:5f33065ddc4b Chris
    projects = [projects] unless projects.is_a?(Array)
6230
    if projects.empty?
6231 1115:433d4f72a19b Chris
      where("1=0")
6232 929:5f33065ddc4b Chris
    else
6233
      ids = projects.map(&:id)
6234 1115:433d4f72a19b Chris
      where("#{Principal.table_name}.id NOT IN (SELECT DISTINCT user_id FROM #{Member.table_name} WHERE project_id IN (?))", ids)
6235 929:5f33065ddc4b Chris
    end
6236
  }
6237 1464:261b3d9a4903 Chris
  scope :sorted, lambda { order(*Principal.fields_for_order_statement)}
6238 909:cbb26bc654de Chris
6239 0:513646585e45 Chris
  before_create :set_default_empty_values
6240 22:40f7cfd4df19 chris
6241
  def name(formatter = nil)
6242
    to_s
6243
  end
6244
6245 0:513646585e45 Chris
  def <=>(principal)
6246 929:5f33065ddc4b Chris
    if principal.nil?
6247
      -1
6248
    elsif self.class.name == principal.class.name
6249 0:513646585e45 Chris
      self.to_s.downcase <=> principal.to_s.downcase
6250
    else
6251
      # groups after users
6252
      principal.class.name <=> self.class.name
6253
    end
6254
  end
6255 909:cbb26bc654de Chris
6256 1464:261b3d9a4903 Chris
  # Returns an array of fields names than can be used to make an order statement for principals.
6257
  # Users are sorted before Groups.
6258
  # Examples:
6259
  def self.fields_for_order_statement(table=nil)
6260
    table ||= table_name
6261
    columns = ['type DESC'] + (User.name_formatter[:order] - ['id']) + ['lastname', 'id']
6262
    columns.uniq.map {|field| "#{table}.#{field}"}
6263
  end
6264
6265 0:513646585e45 Chris
  protected
6266 909:cbb26bc654de Chris
6267 0:513646585e45 Chris
  # Make sure we don't try to insert NULL values (see #4632)
6268
  def set_default_empty_values
6269
    self.login ||= ''
6270
    self.hashed_password ||= ''
6271
    self.firstname ||= ''
6272
    self.lastname ||= ''
6273
    self.mail ||= ''
6274
    true
6275
  end
6276
end
6277 441:cbce1fd3b1b7 Chris
# Redmine - project management software
6278 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
6279 0:513646585e45 Chris
#
6280
# This program is free software; you can redistribute it and/or
6281
# modify it under the terms of the GNU General Public License
6282
# as published by the Free Software Foundation; either version 2
6283
# of the License, or (at your option) any later version.
6284 909:cbb26bc654de Chris
#
6285 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
6286
# but WITHOUT ANY WARRANTY; without even the implied warranty of
6287
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
6288
# GNU General Public License for more details.
6289 909:cbb26bc654de Chris
#
6290 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
6291
# along with this program; if not, write to the Free Software
6292
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
6293
6294
class Project < ActiveRecord::Base
6295 117:af80e5618e9b Chris
  include Redmine::SafeAttributes
6296 909:cbb26bc654de Chris
6297 0:513646585e45 Chris
  # Project statuses
6298
  STATUS_ACTIVE     = 1
6299 1115:433d4f72a19b Chris
  STATUS_CLOSED     = 5
6300 0:513646585e45 Chris
  STATUS_ARCHIVED   = 9
6301 909:cbb26bc654de Chris
6302 37:94944d00e43c chris
  # Maximum length for project identifiers
6303
  IDENTIFIER_MAX_LENGTH = 100
6304 909:cbb26bc654de Chris
6305 0:513646585e45 Chris
  # Specific overidden Activities
6306
  has_many :time_entry_activities
6307 1464:261b3d9a4903 Chris
  has_many :members, :include => [:principal, :roles], :conditions => "#{Principal.table_name}.type='User' AND #{Principal.table_name}.status=#{Principal::STATUS_ACTIVE}"
6308 0:513646585e45 Chris
  has_many :memberships, :class_name => 'Member'
6309 909:cbb26bc654de Chris
  has_many :member_principals, :class_name => 'Member',
6310 0:513646585e45 Chris
                               :include => :principal,
6311 1464:261b3d9a4903 Chris
                               :conditions => "#{Principal.table_name}.type='Group' OR (#{Principal.table_name}.type='User' AND #{Principal.table_name}.status=#{Principal::STATUS_ACTIVE})"
6312 909:cbb26bc654de Chris
6313 0:513646585e45 Chris
  has_many :enabled_modules, :dependent => :delete_all
6314
  has_and_belongs_to_many :trackers, :order => "#{Tracker.table_name}.position"
6315 1115:433d4f72a19b Chris
  has_many :issues, :dependent => :destroy, :include => [:status, :tracker]
6316 0:513646585e45 Chris
  has_many :issue_changes, :through => :issues, :source => :journals
6317
  has_many :versions, :dependent => :destroy, :order => "#{Version.table_name}.effective_date DESC, #{Version.table_name}.name DESC"
6318 1517:dffacf8a6908 Chris
  has_many :time_entries, :dependent => :destroy
6319 1464:261b3d9a4903 Chris
  has_many :queries, :class_name => 'IssueQuery', :dependent => :delete_all
6320 0:513646585e45 Chris
  has_many :documents, :dependent => :destroy
6321 441:cbce1fd3b1b7 Chris
  has_many :news, :dependent => :destroy, :include => :author
6322 0:513646585e45 Chris
  has_many :issue_categories, :dependent => :delete_all, :order => "#{IssueCategory.table_name}.name"
6323
  has_many :boards, :dependent => :destroy, :order => "position ASC"
6324 1115:433d4f72a19b Chris
  has_one :repository, :conditions => ["is_default = ?", true]
6325
  has_many :repositories, :dependent => :destroy
6326 0:513646585e45 Chris
  has_many :changesets, :through => :repository
6327
  has_one :wiki, :dependent => :destroy
6328
  # Custom field for the project issues
6329 909:cbb26bc654de Chris
  has_and_belongs_to_many :issue_custom_fields,
6330 0:513646585e45 Chris
                          :class_name => 'IssueCustomField',
6331
                          :order => "#{CustomField.table_name}.position",
6332
                          :join_table => "#{table_name_prefix}custom_fields_projects#{table_name_suffix}",
6333
                          :association_foreign_key => 'custom_field_id'
6334 909:cbb26bc654de Chris
6335 1517:dffacf8a6908 Chris
  acts_as_nested_set :dependent => :destroy
6336 0:513646585e45 Chris
  acts_as_attachable :view_permission => :view_files,
6337
                     :delete_permission => :manage_files
6338
6339
  acts_as_customizable
6340
  acts_as_searchable :columns => ['name', 'identifier', 'description'], :project_key => 'id', :permission => nil
6341
  acts_as_event :title => Proc.new {|o| "#{l(:label_project)}: #{o.name}"},
6342
                :url => Proc.new {|o| {:controller => 'projects', :action => 'show', :id => o}},
6343
                :author => nil
6344
6345 117:af80e5618e9b Chris
  attr_protected :status
6346 909:cbb26bc654de Chris
6347 0:513646585e45 Chris
  validates_presence_of :name, :identifier
6348 37:94944d00e43c chris
  validates_uniqueness_of :identifier
6349 0:513646585e45 Chris
  validates_associated :repository, :wiki
6350 37:94944d00e43c chris
  validates_length_of :name, :maximum => 255
6351 0:513646585e45 Chris
  validates_length_of :homepage, :maximum => 255
6352 37:94944d00e43c chris
  validates_length_of :identifier, :in => 1..IDENTIFIER_MAX_LENGTH
6353 0:513646585e45 Chris
  # donwcase letters, digits, dashes but not digits only
6354 1464:261b3d9a4903 Chris
  validates_format_of :identifier, :with => /\A(?!\d+$)[a-z0-9\-_]*\z/, :if => Proc.new { |p| p.identifier_changed? }
6355 0:513646585e45 Chris
  # reserved words
6356
  validates_exclusion_of :identifier, :in => %w( new )
6357
6358 1115:433d4f72a19b Chris
  after_save :update_position_under_parent, :if => Proc.new {|project| project.name_changed?}
6359 1464:261b3d9a4903 Chris
  after_save :update_inherited_members, :if => Proc.new {|project| project.inherit_members_changed?}
6360 441:cbce1fd3b1b7 Chris
  before_destroy :delete_all_members
6361 0:513646585e45 Chris
6362 1464:261b3d9a4903 Chris
  scope :has_module, lambda {|mod|
6363
    where("#{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name=?)", mod.to_s)
6364
  }
6365
  scope :active, lambda { where(:status => STATUS_ACTIVE) }
6366
  scope :status, lambda {|arg| where(arg.blank? ? nil : {:status => arg.to_i}) }
6367
  scope :all_public, lambda { where(:is_public => true) }
6368
  scope :visible, lambda {|*args| where(Project.visible_condition(args.shift || User.current, *args)) }
6369 1484:51364c0cd58f Chris
  scope :visible_roots, lambda {|*args| where(Project.root_visible_by(args.shift || User.current, *args)) }
6370 1464:261b3d9a4903 Chris
  scope :allowed_to, lambda {|*args|
6371 1115:433d4f72a19b Chris
    user = User.current
6372
    permission = nil
6373
    if args.first.is_a?(Symbol)
6374
      permission = args.shift
6375
    else
6376
      user = args.shift
6377
      permission = args.shift
6378
    end
6379 1464:261b3d9a4903 Chris
    where(Project.allowed_to_condition(user, permission, *args))
6380 1115:433d4f72a19b Chris
  }
6381
  scope :like, lambda {|arg|
6382
    if arg.blank?
6383 1464:261b3d9a4903 Chris
      where(nil)
6384 1115:433d4f72a19b Chris
    else
6385
      pattern = "%#{arg.to_s.strip.downcase}%"
6386 1464:261b3d9a4903 Chris
      where("LOWER(identifier) LIKE :p OR LOWER(name) LIKE :p", :p => pattern)
6387 1115:433d4f72a19b Chris
    end
6388
  }
6389 909:cbb26bc654de Chris
6390 1115:433d4f72a19b Chris
  def initialize(attributes=nil, *args)
6391 117:af80e5618e9b Chris
    super
6392 909:cbb26bc654de Chris
6393 117:af80e5618e9b Chris
    initialized = (attributes || {}).stringify_keys
6394 909:cbb26bc654de Chris
    if !initialized.key?('identifier') && Setting.sequential_project_identifiers?
6395 117:af80e5618e9b Chris
      self.identifier = Project.next_identifier
6396
    end
6397
    if !initialized.key?('is_public')
6398
      self.is_public = Setting.default_projects_public?
6399
    end
6400
    if !initialized.key?('enabled_module_names')
6401
      self.enabled_module_names = Setting.default_projects_modules
6402
    end
6403
    if !initialized.key?('trackers') && !initialized.key?('tracker_ids')
6404 1464:261b3d9a4903 Chris
      default = Setting.default_projects_tracker_ids
6405
      if default.is_a?(Array)
6406
        self.trackers = Tracker.where(:id => default.map(&:to_i)).sorted.all
6407
      else
6408
        self.trackers = Tracker.sorted.all
6409
      end
6410 117:af80e5618e9b Chris
    end
6411
  end
6412 909:cbb26bc654de Chris
6413 0:513646585e45 Chris
  def identifier=(identifier)
6414
    super unless identifier_frozen?
6415
  end
6416 909:cbb26bc654de Chris
6417 0:513646585e45 Chris
  def identifier_frozen?
6418 1115:433d4f72a19b Chris
    errors[:identifier].blank? && !(new_record? || identifier.blank?)
6419 0:513646585e45 Chris
  end
6420
6421
  # returns latest created projects
6422
  # non public projects will be returned only if user is a member of those
6423
  def self.latest(user=nil, count=5)
6424 1464:261b3d9a4903 Chris
    visible(user).limit(count).order("created_on DESC").all
6425
  end
6426 0:513646585e45 Chris
6427 507:0c939c159af4 Chris
  # Returns true if the project is visible to +user+ or to the current user.
6428
  def visible?(user=User.current)
6429
    user.allowed_to?(:view_project, self)
6430
  end
6431 909:cbb26bc654de Chris
6432 441:cbce1fd3b1b7 Chris
  # Returns a SQL conditions string used to find all projects visible by the specified user.
6433 0:513646585e45 Chris
  #
6434
  # Examples:
6435 441:cbce1fd3b1b7 Chris
  #   Project.visible_condition(admin)        => "projects.status = 1"
6436
  #   Project.visible_condition(normal_user)  => "((projects.status = 1) AND (projects.is_public = 1 OR projects.id IN (1,3,4)))"
6437
  #   Project.visible_condition(anonymous)    => "((projects.status = 1) AND (projects.is_public = 1))"
6438
  def self.visible_condition(user, options={})
6439
    allowed_to_condition(user, :view_project, options)
6440 0:513646585e45 Chris
  end
6441 909:cbb26bc654de Chris
6442 1501:467282ce64a4 Chris
  def self.root_visible_by(user, options={})
6443
    return "#{Project.table_name}.parent_id IS NULL AND " + visible_condition(user, options)
6444 205:05f9a2a9c753 chris
  end
6445
6446 441:cbce1fd3b1b7 Chris
  # Returns a SQL conditions string used to find all projects for which +user+ has the given +permission+
6447
  #
6448
  # Valid options:
6449
  # * :project => limit the condition to project
6450
  # * :with_subprojects => limit the condition to project and its subprojects
6451
  # * :member => limit the condition to the user projects
6452 0:513646585e45 Chris
  def self.allowed_to_condition(user, permission, options={})
6453 1115:433d4f72a19b Chris
    perm = Redmine::AccessControl.permission(permission)
6454
    base_statement = (perm && perm.read? ? "#{Project.table_name}.status <> #{Project::STATUS_ARCHIVED}" : "#{Project.table_name}.status = #{Project::STATUS_ACTIVE}")
6455
    if perm && perm.project_module
6456
      # If the permission belongs to a project module, make sure the module is enabled
6457
      base_statement << " AND #{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name='#{perm.project_module}')"
6458 0:513646585e45 Chris
    end
6459
    if options[:project]
6460
      project_statement = "#{Project.table_name}.id = #{options[:project].id}"
6461
      project_statement << " OR (#{Project.table_name}.lft > #{options[:project].lft} AND #{Project.table_name}.rgt < #{options[:project].rgt})" if options[:with_subprojects]
6462
      base_statement = "(#{project_statement}) AND (#{base_statement})"
6463
    end
6464 909:cbb26bc654de Chris
6465 0:513646585e45 Chris
    if user.admin?
6466 441:cbce1fd3b1b7 Chris
      base_statement
6467 0:513646585e45 Chris
    else
6468 441:cbce1fd3b1b7 Chris
      statement_by_role = {}
6469
      unless options[:member]
6470 1464:261b3d9a4903 Chris
        role = user.builtin_role
6471 441:cbce1fd3b1b7 Chris
        if role.allowed_to?(permission)
6472
          statement_by_role[role] = "#{Project.table_name}.is_public = #{connection.quoted_true}"
6473
        end
6474
      end
6475 0:513646585e45 Chris
      if user.logged?
6476 441:cbce1fd3b1b7 Chris
        user.projects_by_role.each do |role, projects|
6477 1115:433d4f72a19b Chris
          if role.allowed_to?(permission) && projects.any?
6478 441:cbce1fd3b1b7 Chris
            statement_by_role[role] = "#{Project.table_name}.id IN (#{projects.collect(&:id).join(',')})"
6479
          end
6480 0:513646585e45 Chris
        end
6481 441:cbce1fd3b1b7 Chris
      end
6482
      if statement_by_role.empty?
6483
        "1=0"
6484 0:513646585e45 Chris
      else
6485 441:cbce1fd3b1b7 Chris
        if block_given?
6486
          statement_by_role.each do |role, statement|
6487
            if s = yield(role, user)
6488
              statement_by_role[role] = "(#{statement} AND (#{s}))"
6489
            end
6490
          end
6491
        end
6492
        "((#{base_statement}) AND (#{statement_by_role.values.join(' OR ')}))"
6493 0:513646585e45 Chris
      end
6494
    end
6495
  end
6496
6497 1464:261b3d9a4903 Chris
  def principals
6498
    @principals ||= Principal.active.joins(:members).where("#{Member.table_name}.project_id = ?", id).uniq
6499
  end
6500
6501
  def users
6502
    @users ||= User.active.joins(:members).where("#{Member.table_name}.project_id = ?", id).uniq
6503
  end
6504
6505 0:513646585e45 Chris
  # Returns the Systemwide and project specific activities
6506
  def activities(include_inactive=false)
6507
    if include_inactive
6508
      return all_activities
6509
    else
6510
      return active_activities
6511
    end
6512
  end
6513
6514
  # Will create a new Project specific Activity or update an existing one
6515
  #
6516
  # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
6517
  # does not successfully save.
6518
  def update_or_create_time_entry_activity(id, activity_hash)
6519
    if activity_hash.respond_to?(:has_key?) && activity_hash.has_key?('parent_id')
6520
      self.create_time_entry_activity_if_needed(activity_hash)
6521
    else
6522
      activity = project.time_entry_activities.find_by_id(id.to_i)
6523
      activity.update_attributes(activity_hash) if activity
6524
    end
6525
  end
6526 909:cbb26bc654de Chris
6527 0:513646585e45 Chris
  # Create a new TimeEntryActivity if it overrides a system TimeEntryActivity
6528
  #
6529
  # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
6530
  # does not successfully save.
6531
  def create_time_entry_activity_if_needed(activity)
6532
    if activity['parent_id']
6533
      parent_activity = TimeEntryActivity.find(activity['parent_id'])
6534
      activity['name'] = parent_activity.name
6535
      activity['position'] = parent_activity.position
6536
      if Enumeration.overridding_change?(activity, parent_activity)
6537
        project_activity = self.time_entry_activities.create(activity)
6538
        if project_activity.new_record?
6539
          raise ActiveRecord::Rollback, "Overridding TimeEntryActivity was not successfully saved"
6540
        else
6541 1517:dffacf8a6908 Chris
          self.time_entries.
6542
            where(["activity_id = ?", parent_activity.id]).
6543
            update_all("activity_id = #{project_activity.id}")
6544 0:513646585e45 Chris
        end
6545
      end
6546
    end
6547
  end
6548
6549
  # Returns a :conditions SQL string that can be used to find the issues associated with this project.
6550
  #
6551
  # Examples:
6552
  #   project.project_condition(true)  => "(projects.id = 1 OR (projects.lft > 1 AND projects.rgt < 10))"
6553
  #   project.project_condition(false) => "projects.id = 1"
6554
  def project_condition(with_subprojects)
6555
    cond = "#{Project.table_name}.id = #{id}"
6556
    cond = "(#{cond} OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt}))" if with_subprojects
6557
    cond
6558
  end
6559 909:cbb26bc654de Chris
6560 0:513646585e45 Chris
  def self.find(*args)
6561
    if args.first && args.first.is_a?(String) && !args.first.match(/^\d*$/)
6562
      project = find_by_identifier(*args)
6563
      raise ActiveRecord::RecordNotFound, "Couldn't find Project with identifier=#{args.first}" if project.nil?
6564
      project
6565
    else
6566
      super
6567
    end
6568
  end
6569 909:cbb26bc654de Chris
6570 1115:433d4f72a19b Chris
  def self.find_by_param(*args)
6571
    self.find(*args)
6572
  end
6573
6574 1464:261b3d9a4903 Chris
  alias :base_reload :reload
6575 1115:433d4f72a19b Chris
  def reload(*args)
6576 1464:261b3d9a4903 Chris
    @principals = nil
6577
    @users = nil
6578 1115:433d4f72a19b Chris
    @shared_versions = nil
6579
    @rolled_up_versions = nil
6580
    @rolled_up_trackers = nil
6581
    @all_issue_custom_fields = nil
6582
    @all_time_entry_custom_fields = nil
6583
    @to_param = nil
6584
    @allowed_parents = nil
6585
    @allowed_permissions = nil
6586
    @actions_allowed = nil
6587 1464:261b3d9a4903 Chris
    @start_date = nil
6588
    @due_date = nil
6589
    base_reload(*args)
6590 1115:433d4f72a19b Chris
  end
6591
6592 0:513646585e45 Chris
  def to_param
6593
    # id is used for projects with a numeric identifier (compatibility)
6594 929:5f33065ddc4b Chris
    @to_param ||= (identifier.to_s =~ %r{^\d*$} ? id.to_s : identifier)
6595 0:513646585e45 Chris
  end
6596 909:cbb26bc654de Chris
6597 0:513646585e45 Chris
  def active?
6598
    self.status == STATUS_ACTIVE
6599
  end
6600 909:cbb26bc654de Chris
6601 37:94944d00e43c chris
  def archived?
6602
    self.status == STATUS_ARCHIVED
6603
  end
6604 909:cbb26bc654de Chris
6605 0:513646585e45 Chris
  # Archives the project and its descendants
6606
  def archive
6607
    # Check that there is no issue of a non descendant project that is assigned
6608
    # to one of the project or descendant versions
6609
    v_ids = self_and_descendants.collect {|p| p.version_ids}.flatten
6610 1464:261b3d9a4903 Chris
    if v_ids.any? &&
6611
      Issue.
6612
        includes(:project).
6613
        where("#{Project.table_name}.lft < ? OR #{Project.table_name}.rgt > ?", lft, rgt).
6614
        where("#{Issue.table_name}.fixed_version_id IN (?)", v_ids).
6615
        exists?
6616 0:513646585e45 Chris
      return false
6617
    end
6618
    Project.transaction do
6619
      archive!
6620
    end
6621
    true
6622
  end
6623 909:cbb26bc654de Chris
6624 0:513646585e45 Chris
  # Unarchives the project
6625
  # All its ancestors must be active
6626
  def unarchive
6627
    return false if ancestors.detect {|a| !a.active?}
6628
    update_attribute :status, STATUS_ACTIVE
6629
  end
6630 909:cbb26bc654de Chris
6631 1115:433d4f72a19b Chris
  def close
6632
    self_and_descendants.status(STATUS_ACTIVE).update_all :status => STATUS_CLOSED
6633
  end
6634
6635
  def reopen
6636
    self_and_descendants.status(STATUS_CLOSED).update_all :status => STATUS_ACTIVE
6637
  end
6638
6639 0:513646585e45 Chris
  # Returns an array of projects the project can be moved to
6640
  # by the current user
6641
  def allowed_parents
6642
    return @allowed_parents if @allowed_parents
6643 1464:261b3d9a4903 Chris
    @allowed_parents = Project.where(Project.allowed_to_condition(User.current, :add_subprojects)).all
6644 0:513646585e45 Chris
    @allowed_parents = @allowed_parents - self_and_descendants
6645
    if User.current.allowed_to?(:add_project, nil, :global => true) || (!new_record? && parent.nil?)
6646
      @allowed_parents << nil
6647
    end
6648
    unless parent.nil? || @allowed_parents.empty? || @allowed_parents.include?(parent)
6649
      @allowed_parents << parent
6650
    end
6651
    @allowed_parents
6652
  end
6653 909:cbb26bc654de Chris
6654 0:513646585e45 Chris
  # Sets the parent of the project with authorization check
6655
  def set_allowed_parent!(p)
6656
    unless p.nil? || p.is_a?(Project)
6657
      if p.to_s.blank?
6658
        p = nil
6659
      else
6660
        p = Project.find_by_id(p)
6661
        return false unless p
6662
      end
6663
    end
6664
    if p.nil?
6665
      if !new_record? && allowed_parents.empty?
6666
        return false
6667
      end
6668
    elsif !allowed_parents.include?(p)
6669
      return false
6670
    end
6671
    set_parent!(p)
6672
  end
6673 909:cbb26bc654de Chris
6674 0:513646585e45 Chris
  # Sets the parent of the project
6675
  # Argument can be either a Project, a String, a Fixnum or nil
6676
  def set_parent!(p)
6677
    unless p.nil? || p.is_a?(Project)
6678
      if p.to_s.blank?
6679
        p = nil
6680
      else
6681
        p = Project.find_by_id(p)
6682
        return false unless p
6683
      end
6684
    end
6685
    if p == parent && !p.nil?
6686
      # Nothing to do
6687
      true
6688
    elsif p.nil? || (p.active? && move_possible?(p))
6689 1115:433d4f72a19b Chris
      set_or_update_position_under(p)
6690 0:513646585e45 Chris
      Issue.update_versions_from_hierarchy_change(self)
6691
      true
6692
    else
6693
      # Can not move to the given target
6694
      false
6695
    end
6696
  end
6697 909:cbb26bc654de Chris
6698 1115:433d4f72a19b Chris
  # Recalculates all lft and rgt values based on project names
6699
  # Unlike Project.rebuild!, these values are recalculated even if the tree "looks" valid
6700
  # Used in BuildProjectsTree migration
6701
  def self.rebuild_tree!
6702
    transaction do
6703
      update_all "lft = NULL, rgt = NULL"
6704
      rebuild!(false)
6705 1517:dffacf8a6908 Chris
      all.each { |p| p.set_or_update_position_under(p.parent) }
6706 1115:433d4f72a19b Chris
    end
6707
  end
6708
6709 0:513646585e45 Chris
  # Returns an array of the trackers used by the project and its active sub projects
6710
  def rolled_up_trackers
6711
    @rolled_up_trackers ||=
6712 1464:261b3d9a4903 Chris
      Tracker.
6713
        joins(:projects).
6714
        joins("JOIN #{EnabledModule.table_name} ON #{EnabledModule.table_name}.project_id = #{Project.table_name}.id AND #{EnabledModule.table_name}.name = 'issue_tracking'").
6715
        select("DISTINCT #{Tracker.table_name}.*").
6716
        where("#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ? AND #{Project.table_name}.status <> #{STATUS_ARCHIVED}", lft, rgt).
6717
        sorted.
6718
        all
6719 0:513646585e45 Chris
  end
6720 909:cbb26bc654de Chris
6721 0:513646585e45 Chris
  # Closes open and locked project versions that are completed
6722
  def close_completed_versions
6723
    Version.transaction do
6724 1517:dffacf8a6908 Chris
      versions.where(:status => %w(open locked)).each do |version|
6725 0:513646585e45 Chris
        if version.completed?
6726
          version.update_attribute(:status, 'closed')
6727
        end
6728
      end
6729
    end
6730
  end
6731
6732
  # Returns a scope of the Versions on subprojects
6733
  def rolled_up_versions
6734
    @rolled_up_versions ||=
6735 1464:261b3d9a4903 Chris
      Version.
6736
        includes(:project).
6737
        where("#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ? AND #{Project.table_name}.status <> ?", lft, rgt, STATUS_ARCHIVED)
6738 0:513646585e45 Chris
  end
6739 909:cbb26bc654de Chris
6740 0:513646585e45 Chris
  # Returns a scope of the Versions used by the project
6741
  def shared_versions
6742 929:5f33065ddc4b Chris
    if new_record?
6743 1464:261b3d9a4903 Chris
      Version.
6744
        includes(:project).
6745
        where("#{Project.table_name}.status <> ? AND #{Version.table_name}.sharing = 'system'", STATUS_ARCHIVED)
6746 929:5f33065ddc4b Chris
    else
6747
      @shared_versions ||= begin
6748
        r = root? ? self : root
6749 1464:261b3d9a4903 Chris
        Version.
6750
          includes(:project).
6751
          where("#{Project.table_name}.id = #{id}" +
6752
                  " OR (#{Project.table_name}.status <> #{Project::STATUS_ARCHIVED} AND (" +
6753
                    " #{Version.table_name}.sharing = 'system'" +
6754
                    " OR (#{Project.table_name}.lft >= #{r.lft} AND #{Project.table_name}.rgt <= #{r.rgt} AND #{Version.table_name}.sharing = 'tree')" +
6755
                    " OR (#{Project.table_name}.lft < #{lft} AND #{Project.table_name}.rgt > #{rgt} AND #{Version.table_name}.sharing IN ('hierarchy', 'descendants'))" +
6756
                    " OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt} AND #{Version.table_name}.sharing = 'hierarchy')" +
6757
                  "))")
6758 929:5f33065ddc4b Chris
      end
6759 441:cbce1fd3b1b7 Chris
    end
6760 0:513646585e45 Chris
  end
6761
6762
  # Returns a hash of project users grouped by role
6763
  def users_by_role
6764 1517:dffacf8a6908 Chris
    members.includes(:user, :roles).inject({}) do |h, m|
6765 0:513646585e45 Chris
      m.roles.each do |r|
6766
        h[r] ||= []
6767
        h[r] << m.user
6768
      end
6769
      h
6770
    end
6771
  end
6772 909:cbb26bc654de Chris
6773 0:513646585e45 Chris
  # Deletes all project's members
6774
  def delete_all_members
6775
    me, mr = Member.table_name, MemberRole.table_name
6776
    connection.delete("DELETE FROM #{mr} WHERE #{mr}.member_id IN (SELECT #{me}.id FROM #{me} WHERE #{me}.project_id = #{id})")
6777
    Member.delete_all(['project_id = ?', id])
6778
  end
6779 909:cbb26bc654de Chris
6780
  # Users/groups issues can be assigned to
6781 0:513646585e45 Chris
  def assignable_users
6782 909:cbb26bc654de Chris
    assignable = Setting.issue_group_assignment? ? member_principals : members
6783
    assignable.select {|m| m.roles.detect {|role| role.assignable?}}.collect {|m| m.principal}.sort
6784 0:513646585e45 Chris
  end
6785 909:cbb26bc654de Chris
6786 0:513646585e45 Chris
  # Returns the mail adresses of users that should be always notified on project events
6787
  def recipients
6788 37:94944d00e43c chris
    notified_users.collect {|user| user.mail}
6789 0:513646585e45 Chris
  end
6790 909:cbb26bc654de Chris
6791 0:513646585e45 Chris
  # Returns the users that should be notified on project events
6792
  def notified_users
6793 37:94944d00e43c chris
    # TODO: User part should be extracted to User#notify_about?
6794 1115:433d4f72a19b Chris
    members.select {|m| m.principal.present? && (m.mail_notification? || m.principal.mail_notification == 'all')}.collect {|m| m.principal}
6795 0:513646585e45 Chris
  end
6796 909:cbb26bc654de Chris
6797 1464:261b3d9a4903 Chris
  # Returns a scope of all custom fields enabled for project issues
6798 0:513646585e45 Chris
  # (explictly associated custom fields and custom fields enabled for all projects)
6799
  def all_issue_custom_fields
6800 1464:261b3d9a4903 Chris
    @all_issue_custom_fields ||= IssueCustomField.
6801
      sorted.
6802
      where("is_for_all = ? OR id IN (SELECT DISTINCT cfp.custom_field_id" +
6803
        " FROM #{table_name_prefix}custom_fields_projects#{table_name_suffix} cfp" +
6804
        " WHERE cfp.project_id = ?)", true, id)
6805 0:513646585e45 Chris
  end
6806 441:cbce1fd3b1b7 Chris
6807
  # Returns an array of all custom fields enabled for project time entries
6808
  # (explictly associated custom fields and custom fields enabled for all projects)
6809
  def all_time_entry_custom_fields
6810
    @all_time_entry_custom_fields ||= (TimeEntryCustomField.for_all + time_entry_custom_fields).uniq.sort
6811
  end
6812 909:cbb26bc654de Chris
6813 0:513646585e45 Chris
  def project
6814
    self
6815
  end
6816 909:cbb26bc654de Chris
6817 0:513646585e45 Chris
  def <=>(project)
6818
    name.downcase <=> project.name.downcase
6819
  end
6820 909:cbb26bc654de Chris
6821 0:513646585e45 Chris
  def to_s
6822
    name
6823
  end
6824 909:cbb26bc654de Chris
6825 0:513646585e45 Chris
  # Returns a short description of the projects (first lines)
6826 1215:2101a7c906b3 chris
  def short_description(length = 200)
6827 335:7acd282bee3c chris
6828
    ## The short description is used in lists, e.g. Latest projects,
6829
    ## My projects etc.  It should be no more than a line or two with
6830
    ## no text formatting.
6831
6832 130:db0caa9f0ff4 chris
    ## Original Redmine code: this truncates to the CR that is more
6833
    ## than "length" characters from the start.
6834
    # description.gsub(/^(.{#{length}}[^\n\r]*).*$/m, '\1...').strip if description
6835 335:7acd282bee3c chris
6836
    ## That can leave too much text for us, and also we want to omit
6837
    ## images and the like.  Truncate instead to the first CR that
6838
    ## follows _any_ non-blank text, and to the next word break beyond
6839
    ## "length" characters if the result is still longer than that.
6840
    ##
6841 1215:2101a7c906b3 chris
    description.gsub(/![^\s]+!/, '').gsub(/^(\s*[^\n\r]*).*$/m, '\1').gsub(/^(.{#{length}}[^\.;:,-]*).*$/m, '\1 ...').strip if description
6842 0:513646585e45 Chris
  end
6843 22:40f7cfd4df19 chris
6844
  def css_classes
6845
    s = 'project'
6846
    s << ' root' if root?
6847
    s << ' child' if child?
6848
    s << (leaf? ? ' leaf' : ' parent')
6849 1115:433d4f72a19b Chris
    unless active?
6850
      if archived?
6851
        s << ' archived'
6852
      else
6853
        s << ' closed'
6854
      end
6855
    end
6856 22:40f7cfd4df19 chris
    s
6857
  end
6858
6859
  # The earliest start date of a project, based on it's issues and versions
6860
  def start_date
6861 1464:261b3d9a4903 Chris
    @start_date ||= [
6862 117:af80e5618e9b Chris
     issues.minimum('start_date'),
6863 1464:261b3d9a4903 Chris
     shared_versions.minimum('effective_date'),
6864
     Issue.fixed_version(shared_versions).minimum('start_date')
6865
    ].compact.min
6866 22:40f7cfd4df19 chris
  end
6867
6868
  # The latest due date of an issue or version
6869
  def due_date
6870 1464:261b3d9a4903 Chris
    @due_date ||= [
6871 117:af80e5618e9b Chris
     issues.maximum('due_date'),
6872 1464:261b3d9a4903 Chris
     shared_versions.maximum('effective_date'),
6873
     Issue.fixed_version(shared_versions).maximum('due_date')
6874
    ].compact.max
6875 22:40f7cfd4df19 chris
  end
6876
6877
  def overdue?
6878
    active? && !due_date.nil? && (due_date < Date.today)
6879
  end
6880
6881
  # Returns the percent completed for this project, based on the
6882
  # progress on it's versions.
6883
  def completed_percent(options={:include_subprojects => false})
6884
    if options.delete(:include_subprojects)
6885
      total = self_and_descendants.collect(&:completed_percent).sum
6886
6887
      total / self_and_descendants.count
6888
    else
6889
      if versions.count > 0
6890 1464:261b3d9a4903 Chris
        total = versions.collect(&:completed_percent).sum
6891 22:40f7cfd4df19 chris
6892
        total / versions.count
6893
      else
6894
        100
6895
      end
6896
    end
6897
  end
6898 909:cbb26bc654de Chris
6899 1115:433d4f72a19b Chris
  # Return true if this project allows to do the specified action.
6900 0:513646585e45 Chris
  # action can be:
6901
  # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
6902
  # * a permission Symbol (eg. :edit_project)
6903
  def allows_to?(action)
6904 1115:433d4f72a19b Chris
    if archived?
6905
      # No action allowed on archived projects
6906
      return false
6907
    end
6908
    unless active? || Redmine::AccessControl.read_action?(action)
6909
      # No write action allowed on closed projects
6910
      return false
6911
    end
6912
    # No action allowed on disabled modules
6913 0:513646585e45 Chris
    if action.is_a? Hash
6914
      allowed_actions.include? "#{action[:controller]}/#{action[:action]}"
6915
    else
6916
      allowed_permissions.include? action
6917
    end
6918
  end
6919 909:cbb26bc654de Chris
6920 1517:dffacf8a6908 Chris
  # Return the enabled module with the given name
6921
  # or nil if the module is not enabled for the project
6922
  def enabled_module(name)
6923
    name = name.to_s
6924
    enabled_modules.detect {|m| m.name == name}
6925
  end
6926
6927
  # Return true if the module with the given name is enabled
6928
  def module_enabled?(name)
6929
    enabled_module(name).present?
6930 0:513646585e45 Chris
  end
6931 909:cbb26bc654de Chris
6932 0:513646585e45 Chris
  def enabled_module_names=(module_names)
6933
    if module_names && module_names.is_a?(Array)
6934 117:af80e5618e9b Chris
      module_names = module_names.collect(&:to_s).reject(&:blank?)
6935 441:cbce1fd3b1b7 Chris
      self.enabled_modules = module_names.collect {|name| enabled_modules.detect {|mod| mod.name == name} || EnabledModule.new(:name => name)}
6936 0:513646585e45 Chris
    else
6937
      enabled_modules.clear
6938
    end
6939
  end
6940 909:cbb26bc654de Chris
6941 117:af80e5618e9b Chris
  # Returns an array of the enabled modules names
6942
  def enabled_module_names
6943
    enabled_modules.collect(&:name)
6944
  end
6945 507:0c939c159af4 Chris
6946
  # Enable a specific module
6947
  #
6948
  # Examples:
6949
  #   project.enable_module!(:issue_tracking)
6950
  #   project.enable_module!("issue_tracking")
6951
  def enable_module!(name)
6952
    enabled_modules << EnabledModule.new(:name => name.to_s) unless module_enabled?(name)
6953
  end
6954
6955
  # Disable a module if it exists
6956
  #
6957
  # Examples:
6958
  #   project.disable_module!(:issue_tracking)
6959
  #   project.disable_module!("issue_tracking")
6960
  #   project.disable_module!(project.enabled_modules.first)
6961
  def disable_module!(target)
6962
    target = enabled_modules.detect{|mod| target.to_s == mod.name} unless enabled_modules.include?(target)
6963
    target.destroy unless target.blank?
6964
  end
6965
6966 117:af80e5618e9b Chris
  safe_attributes 'name',
6967
    'description',
6968
    'homepage',
6969
    'is_public',
6970
    'identifier',
6971
    'custom_field_values',
6972
    'custom_fields',
6973
    'tracker_ids',
6974 680:65abc6b39292 chris
    'issue_custom_field_ids',
6975
    'has_welcome_page'
6976 22:40f7cfd4df19 chris
6977 117:af80e5618e9b Chris
  safe_attributes 'enabled_module_names',
6978
    :if => lambda {|project, user| project.new_record? || user.allowed_to?(:select_project_modules, project) }
6979 909:cbb26bc654de Chris
6980 1464:261b3d9a4903 Chris
  safe_attributes 'inherit_members',
6981
    :if => lambda {|project, user| project.parent.nil? || project.parent.visible?(user)}
6982
6983 22:40f7cfd4df19 chris
  # Returns an array of projects that are in this project's hierarchy
6984
  #
6985
  # Example: parents, children, siblings
6986
  def hierarchy
6987
    parents = project.self_and_ancestors || []
6988
    descendants = project.descendants || []
6989
    project_hierarchy = parents | descendants # Set union
6990
  end
6991 909:cbb26bc654de Chris
6992 0:513646585e45 Chris
  # Returns an auto-generated project identifier based on the last identifier used
6993
  def self.next_identifier
6994 1464:261b3d9a4903 Chris
    p = Project.order('id DESC').first
6995 0:513646585e45 Chris
    p.nil? ? nil : p.identifier.to_s.succ
6996
  end
6997
6998
  # Copies and saves the Project instance based on the +project+.
6999
  # Duplicates the source project's:
7000
  # * Wiki
7001
  # * Versions
7002
  # * Categories
7003
  # * Issues
7004
  # * Members
7005
  # * Queries
7006
  #
7007
  # Accepts an +options+ argument to specify what to copy
7008
  #
7009
  # Examples:
7010
  #   project.copy(1)                                    # => copies everything
7011
  #   project.copy(1, :only => 'members')                # => copies members only
7012
  #   project.copy(1, :only => ['members', 'versions'])  # => copies members and versions
7013
  def copy(project, options={})
7014
    project = project.is_a?(Project) ? project : Project.find(project)
7015 909:cbb26bc654de Chris
7016 0:513646585e45 Chris
    to_be_copied = %w(wiki versions issue_categories issues members queries boards)
7017
    to_be_copied = to_be_copied & options[:only].to_a unless options[:only].nil?
7018 909:cbb26bc654de Chris
7019 0:513646585e45 Chris
    Project.transaction do
7020
      if save
7021
        reload
7022
        to_be_copied.each do |name|
7023
          send "copy_#{name}", project
7024
        end
7025
        Redmine::Hook.call_hook(:model_project_copy_before_save, :source_project => project, :destination_project => self)
7026
        save
7027
      end
7028
    end
7029
  end
7030
7031 1464:261b3d9a4903 Chris
  # Returns a new unsaved Project instance with attributes copied from +project+
7032 0:513646585e45 Chris
  def self.copy_from(project)
7033 1464:261b3d9a4903 Chris
    project = project.is_a?(Project) ? project : Project.find(project)
7034
    # clear unique attributes
7035
    attributes = project.attributes.dup.except('id', 'name', 'identifier', 'status', 'parent_id', 'lft', 'rgt')
7036
    copy = Project.new(attributes)
7037
    copy.enabled_modules = project.enabled_modules
7038
    copy.trackers = project.trackers
7039
    copy.custom_values = project.custom_values.collect {|v| v.clone}
7040
    copy.issue_custom_fields = project.issue_custom_fields
7041
    copy
7042 0:513646585e45 Chris
  end
7043 37:94944d00e43c chris
7044
  # Yields the given block for each project with its level in the tree
7045
  def self.project_tree(projects, &block)
7046
    ancestors = []
7047
    projects.sort_by(&:lft).each do |project|
7048 909:cbb26bc654de Chris
      while (ancestors.any? && !project.is_descendant_of?(ancestors.last))
7049 37:94944d00e43c chris
        ancestors.pop
7050
      end
7051
      yield project, ancestors.size
7052
      ancestors << project
7053
    end
7054
  end
7055 909:cbb26bc654de Chris
7056 0:513646585e45 Chris
  private
7057 909:cbb26bc654de Chris
7058 1464:261b3d9a4903 Chris
  def after_parent_changed(parent_was)
7059
    remove_inherited_member_roles
7060
    add_inherited_member_roles
7061
  end
7062
7063
  def update_inherited_members
7064
    if parent
7065
      if inherit_members? && !inherit_members_was
7066
        remove_inherited_member_roles
7067
        add_inherited_member_roles
7068
      elsif !inherit_members? && inherit_members_was
7069
        remove_inherited_member_roles
7070
      end
7071
    end
7072
  end
7073
7074
  def remove_inherited_member_roles
7075
    member_roles = memberships.map(&:member_roles).flatten
7076
    member_role_ids = member_roles.map(&:id)
7077
    member_roles.each do |member_role|
7078
      if member_role.inherited_from && !member_role_ids.include?(member_role.inherited_from)
7079
        member_role.destroy
7080
      end
7081
    end
7082
  end
7083
7084
  def add_inherited_member_roles
7085
    if inherit_members? && parent
7086
      parent.memberships.each do |parent_member|
7087
        member = Member.find_or_new(self.id, parent_member.user_id)
7088
        parent_member.member_roles.each do |parent_member_role|
7089
          member.member_roles << MemberRole.new(:role => parent_member_role.role, :inherited_from => parent_member_role.id)
7090
        end
7091
        member.save!
7092
      end
7093
    end
7094
  end
7095
7096 0:513646585e45 Chris
  # Copies wiki from +project+
7097
  def copy_wiki(project)
7098
    # Check that the source project has a wiki first
7099
    unless project.wiki.nil?
7100 1294:3e4c3460b6ca Chris
      wiki = self.wiki || Wiki.new
7101 0:513646585e45 Chris
      wiki.attributes = project.wiki.attributes.dup.except("id", "project_id")
7102
      wiki_pages_map = {}
7103
      project.wiki.pages.each do |page|
7104
        # Skip pages without content
7105
        next if page.content.nil?
7106
        new_wiki_content = WikiContent.new(page.content.attributes.dup.except("id", "page_id", "updated_on"))
7107
        new_wiki_page = WikiPage.new(page.attributes.dup.except("id", "wiki_id", "created_on", "parent_id"))
7108
        new_wiki_page.content = new_wiki_content
7109
        wiki.pages << new_wiki_page
7110
        wiki_pages_map[page.id] = new_wiki_page
7111
      end
7112 1294:3e4c3460b6ca Chris
7113
      self.wiki = wiki
7114 0:513646585e45 Chris
      wiki.save
7115
      # Reproduce page hierarchy
7116
      project.wiki.pages.each do |page|
7117
        if page.parent_id && wiki_pages_map[page.id]
7118
          wiki_pages_map[page.id].parent = wiki_pages_map[page.parent_id]
7119
          wiki_pages_map[page.id].save
7120
        end
7121
      end
7122
    end
7123
  end
7124
7125
  # Copies versions from +project+
7126
  def copy_versions(project)
7127
    project.versions.each do |version|
7128
      new_version = Version.new
7129
      new_version.attributes = version.attributes.dup.except("id", "project_id", "created_on", "updated_on")
7130
      self.versions << new_version
7131
    end
7132
  end
7133
7134
  # Copies issue categories from +project+
7135
  def copy_issue_categories(project)
7136
    project.issue_categories.each do |issue_category|
7137
      new_issue_category = IssueCategory.new
7138
      new_issue_category.attributes = issue_category.attributes.dup.except("id", "project_id")
7139
      self.issue_categories << new_issue_category
7140
    end
7141
  end
7142 909:cbb26bc654de Chris
7143 0:513646585e45 Chris
  # Copies issues from +project+
7144
  def copy_issues(project)
7145
    # Stores the source issue id as a key and the copied issues as the
7146
    # value.  Used to map the two togeather for issue relations.
7147
    issues_map = {}
7148 909:cbb26bc654de Chris
7149 1115:433d4f72a19b Chris
    # Store status and reopen locked/closed versions
7150
    version_statuses = versions.reject(&:open?).map {|version| [version, version.status]}
7151
    version_statuses.each do |version, status|
7152
      version.update_attribute :status, 'open'
7153
    end
7154
7155 0:513646585e45 Chris
    # Get issues sorted by root_id, lft so that parent issues
7156
    # get copied before their children
7157 1517:dffacf8a6908 Chris
    project.issues.reorder('root_id, lft').each do |issue|
7158 0:513646585e45 Chris
      new_issue = Issue.new
7159 1115:433d4f72a19b Chris
      new_issue.copy_from(issue, :subtasks => false, :link => false)
7160 0:513646585e45 Chris
      new_issue.project = self
7161 1464:261b3d9a4903 Chris
      # Changing project resets the custom field values
7162
      # TODO: handle this in Issue#project=
7163
      new_issue.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
7164 1115:433d4f72a19b Chris
      # Reassign fixed_versions by name, since names are unique per project
7165
      if issue.fixed_version && issue.fixed_version.project == project
7166
        new_issue.fixed_version = self.versions.detect {|v| v.name == issue.fixed_version.name}
7167 0:513646585e45 Chris
      end
7168 1115:433d4f72a19b Chris
      # Reassign the category by name, since names are unique per project
7169 0:513646585e45 Chris
      if issue.category
7170 1115:433d4f72a19b Chris
        new_issue.category = self.issue_categories.detect {|c| c.name == issue.category.name}
7171 0:513646585e45 Chris
      end
7172
      # Parent issue
7173
      if issue.parent_id
7174
        if copied_parent = issues_map[issue.parent_id]
7175
          new_issue.parent_issue_id = copied_parent.id
7176
        end
7177
      end
7178 909:cbb26bc654de Chris
7179 0:513646585e45 Chris
      self.issues << new_issue
7180 117:af80e5618e9b Chris
      if new_issue.new_record?
7181
        logger.info "Project#copy_issues: issue ##{issue.id} could not be copied: #{new_issue.errors.full_messages}" if logger && logger.info
7182
      else
7183
        issues_map[issue.id] = new_issue unless new_issue.new_record?
7184
      end
7185 0:513646585e45 Chris
    end
7186
7187 1115:433d4f72a19b Chris
    # Restore locked/closed version statuses
7188
    version_statuses.each do |version, status|
7189
      version.update_attribute :status, status
7190
    end
7191
7192 0:513646585e45 Chris
    # Relations after in case issues related each other
7193
    project.issues.each do |issue|
7194
      new_issue = issues_map[issue.id]
7195 117:af80e5618e9b Chris
      unless new_issue
7196
        # Issue was not copied
7197
        next
7198
      end
7199 909:cbb26bc654de Chris
7200 0:513646585e45 Chris
      # Relations
7201
      issue.relations_from.each do |source_relation|
7202
        new_issue_relation = IssueRelation.new
7203
        new_issue_relation.attributes = source_relation.attributes.dup.except("id", "issue_from_id", "issue_to_id")
7204
        new_issue_relation.issue_to = issues_map[source_relation.issue_to_id]
7205
        if new_issue_relation.issue_to.nil? && Setting.cross_project_issue_relations?
7206
          new_issue_relation.issue_to = source_relation.issue_to
7207
        end
7208
        new_issue.relations_from << new_issue_relation
7209
      end
7210 909:cbb26bc654de Chris
7211 0:513646585e45 Chris
      issue.relations_to.each do |source_relation|
7212
        new_issue_relation = IssueRelation.new
7213
        new_issue_relation.attributes = source_relation.attributes.dup.except("id", "issue_from_id", "issue_to_id")
7214
        new_issue_relation.issue_from = issues_map[source_relation.issue_from_id]
7215
        if new_issue_relation.issue_from.nil? && Setting.cross_project_issue_relations?
7216
          new_issue_relation.issue_from = source_relation.issue_from
7217
        end
7218
        new_issue.relations_to << new_issue_relation
7219
      end
7220
    end
7221
  end
7222
7223
  # Copies members from +project+
7224
  def copy_members(project)
7225 117:af80e5618e9b Chris
    # Copy users first, then groups to handle members with inherited and given roles
7226
    members_to_copy = []
7227
    members_to_copy += project.memberships.select {|m| m.principal.is_a?(User)}
7228
    members_to_copy += project.memberships.select {|m| !m.principal.is_a?(User)}
7229 909:cbb26bc654de Chris
7230 117:af80e5618e9b Chris
    members_to_copy.each do |member|
7231 0:513646585e45 Chris
      new_member = Member.new
7232
      new_member.attributes = member.attributes.dup.except("id", "project_id", "created_on")
7233
      # only copy non inherited roles
7234
      # inherited roles will be added when copying the group membership
7235
      role_ids = member.member_roles.reject(&:inherited?).collect(&:role_id)
7236
      next if role_ids.empty?
7237
      new_member.role_ids = role_ids
7238
      new_member.project = self
7239
      self.members << new_member
7240
    end
7241
  end
7242
7243
  # Copies queries from +project+
7244
  def copy_queries(project)
7245
    project.queries.each do |query|
7246 1464:261b3d9a4903 Chris
      new_query = IssueQuery.new
7247 0:513646585e45 Chris
      new_query.attributes = query.attributes.dup.except("id", "project_id", "sort_criteria")
7248
      new_query.sort_criteria = query.sort_criteria if query.sort_criteria
7249
      new_query.project = self
7250 909:cbb26bc654de Chris
      new_query.user_id = query.user_id
7251 0:513646585e45 Chris
      self.queries << new_query
7252
    end
7253
  end
7254
7255
  # Copies boards from +project+
7256
  def copy_boards(project)
7257
    project.boards.each do |board|
7258
      new_board = Board.new
7259
      new_board.attributes = board.attributes.dup.except("id", "project_id", "topics_count", "messages_count", "last_message_id")
7260
      new_board.project = self
7261
      self.boards << new_board
7262
    end
7263
  end
7264 909:cbb26bc654de Chris
7265 0:513646585e45 Chris
  def allowed_permissions
7266
    @allowed_permissions ||= begin
7267 1464:261b3d9a4903 Chris
      module_names = enabled_modules.loaded? ? enabled_modules.map(&:name) : enabled_modules.pluck(:name)
7268 0:513646585e45 Chris
      Redmine::AccessControl.modules_permissions(module_names).collect {|p| p.name}
7269
    end
7270
  end
7271
7272
  def allowed_actions
7273
    @actions_allowed ||= allowed_permissions.inject([]) { |actions, permission| actions += Redmine::AccessControl.allowed_actions(permission) }.flatten
7274
  end
7275
7276
  # Returns all the active Systemwide and project specific activities
7277
  def active_activities
7278
    overridden_activity_ids = self.time_entry_activities.collect(&:parent_id)
7279 909:cbb26bc654de Chris
7280 0:513646585e45 Chris
    if overridden_activity_ids.empty?
7281
      return TimeEntryActivity.shared.active
7282
    else
7283
      return system_activities_and_project_overrides
7284
    end
7285
  end
7286
7287
  # Returns all the Systemwide and project specific activities
7288
  # (inactive and active)
7289
  def all_activities
7290
    overridden_activity_ids = self.time_entry_activities.collect(&:parent_id)
7291
7292
    if overridden_activity_ids.empty?
7293
      return TimeEntryActivity.shared
7294
    else
7295
      return system_activities_and_project_overrides(true)
7296
    end
7297
  end
7298
7299
  # Returns the systemwide active activities merged with the project specific overrides
7300
  def system_activities_and_project_overrides(include_inactive=false)
7301 1517:dffacf8a6908 Chris
    t = TimeEntryActivity.table_name
7302
    scope = TimeEntryActivity.where(
7303
      "(#{t}.project_id IS NULL AND #{t}.id NOT IN (?)) OR (#{t}.project_id = ?)",
7304
      time_entry_activities.map(&:parent_id), id
7305
    )
7306
    unless include_inactive
7307
      scope = scope.active
7308 0:513646585e45 Chris
    end
7309 1517:dffacf8a6908 Chris
    scope
7310 0:513646585e45 Chris
  end
7311 909:cbb26bc654de Chris
7312 0:513646585e45 Chris
  # Archives subprojects recursively
7313
  def archive!
7314
    children.each do |subproject|
7315
      subproject.send :archive!
7316
    end
7317
    update_attribute :status, STATUS_ARCHIVED
7318
  end
7319 1115:433d4f72a19b Chris
7320
  def update_position_under_parent
7321
    set_or_update_position_under(parent)
7322
  end
7323
7324 1517:dffacf8a6908 Chris
  public
7325
7326 1115:433d4f72a19b Chris
  # Inserts/moves the project so that target's children or root projects stay alphabetically sorted
7327
  def set_or_update_position_under(target_parent)
7328 1464:261b3d9a4903 Chris
    parent_was = parent
7329 1115:433d4f72a19b Chris
    sibs = (target_parent.nil? ? self.class.roots : target_parent.children)
7330
    to_be_inserted_before = sibs.sort_by {|c| c.name.to_s.downcase}.detect {|c| c.name.to_s.downcase > name.to_s.downcase }
7331
7332
    if to_be_inserted_before
7333
      move_to_left_of(to_be_inserted_before)
7334
    elsif target_parent.nil?
7335
      if sibs.empty?
7336
        # move_to_root adds the project in first (ie. left) position
7337
        move_to_root
7338
      else
7339
        move_to_right_of(sibs.last) unless self == sibs.last
7340
      end
7341
    else
7342
      # move_to_child_of adds the project in last (ie.right) position
7343
      move_to_child_of(target_parent)
7344
    end
7345 1464:261b3d9a4903 Chris
    if parent_was != target_parent
7346
      after_parent_changed(parent_was)
7347
    end
7348 1115:433d4f72a19b Chris
  end
7349 0:513646585e45 Chris
end
7350 909:cbb26bc654de Chris
# Redmine - project management software
7351 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
7352 0:513646585e45 Chris
#
7353
# This program is free software; you can redistribute it and/or
7354
# modify it under the terms of the GNU General Public License
7355
# as published by the Free Software Foundation; either version 2
7356
# of the License, or (at your option) any later version.
7357 909:cbb26bc654de Chris
#
7358 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
7359
# but WITHOUT ANY WARRANTY; without even the implied warranty of
7360
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
7361
# GNU General Public License for more details.
7362 909:cbb26bc654de Chris
#
7363 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
7364
# along with this program; if not, write to the Free Software
7365
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
7366
7367
class ProjectCustomField < CustomField
7368
  def type_name
7369
    :label_project_plural
7370
  end
7371
end
7372
# Redmine - project management software
7373 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
7374 0:513646585e45 Chris
#
7375
# This program is free software; you can redistribute it and/or
7376
# modify it under the terms of the GNU General Public License
7377
# as published by the Free Software Foundation; either version 2
7378
# of the License, or (at your option) any later version.
7379 441:cbce1fd3b1b7 Chris
#
7380 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
7381
# but WITHOUT ANY WARRANTY; without even the implied warranty of
7382
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
7383
# GNU General Public License for more details.
7384 441:cbce1fd3b1b7 Chris
#
7385 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
7386
# along with this program; if not, write to the Free Software
7387
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
7388
7389 441:cbce1fd3b1b7 Chris
class QueryColumn
7390 0:513646585e45 Chris
  attr_accessor :name, :sortable, :groupable, :default_order
7391
  include Redmine::I18n
7392 441:cbce1fd3b1b7 Chris
7393 0:513646585e45 Chris
  def initialize(name, options={})
7394
    self.name = name
7395
    self.sortable = options[:sortable]
7396
    self.groupable = options[:groupable] || false
7397
    if groupable == true
7398
      self.groupable = name.to_s
7399
    end
7400
    self.default_order = options[:default_order]
7401 1115:433d4f72a19b Chris
    @inline = options.key?(:inline) ? options[:inline] : true
7402 1464:261b3d9a4903 Chris
    @caption_key = options[:caption] || "field_#{name}".to_sym
7403
    @frozen = options[:frozen]
7404 0:513646585e45 Chris
  end
7405 441:cbce1fd3b1b7 Chris
7406 0:513646585e45 Chris
  def caption
7407 1464:261b3d9a4903 Chris
    @caption_key.is_a?(Symbol) ? l(@caption_key) : @caption_key
7408 0:513646585e45 Chris
  end
7409 441:cbce1fd3b1b7 Chris
7410 0:513646585e45 Chris
  # Returns true if the column is sortable, otherwise false
7411
  def sortable?
7412 909:cbb26bc654de Chris
    !@sortable.nil?
7413
  end
7414 1115:433d4f72a19b Chris
7415 909:cbb26bc654de Chris
  def sortable
7416
    @sortable.is_a?(Proc) ? @sortable.call : @sortable
7417 0:513646585e45 Chris
  end
7418 441:cbce1fd3b1b7 Chris
7419 1115:433d4f72a19b Chris
  def inline?
7420
    @inline
7421
  end
7422
7423 1464:261b3d9a4903 Chris
  def frozen?
7424
    @frozen
7425
  end
7426
7427
  def value(object)
7428
    object.send name
7429 0:513646585e45 Chris
  end
7430 441:cbce1fd3b1b7 Chris
7431
  def css_classes
7432
    name
7433
  end
7434 0:513646585e45 Chris
end
7435
7436
class QueryCustomFieldColumn < QueryColumn
7437
7438
  def initialize(custom_field)
7439
    self.name = "cf_#{custom_field.id}".to_sym
7440
    self.sortable = custom_field.order_statement || false
7441 1115:433d4f72a19b Chris
    self.groupable = custom_field.group_statement || false
7442
    @inline = true
7443 0:513646585e45 Chris
    @cf = custom_field
7444
  end
7445 441:cbce1fd3b1b7 Chris
7446 0:513646585e45 Chris
  def caption
7447
    @cf.name
7448
  end
7449 441:cbce1fd3b1b7 Chris
7450 0:513646585e45 Chris
  def custom_field
7451
    @cf
7452
  end
7453 441:cbce1fd3b1b7 Chris
7454 1464:261b3d9a4903 Chris
  def value(object)
7455
    if custom_field.visible_by?(object.project, User.current)
7456
      cv = object.custom_values.select {|v| v.custom_field_id == @cf.id}.collect {|v| @cf.cast_value(v.value)}
7457
      cv.size > 1 ? cv.sort {|a,b| a.to_s <=> b.to_s} : cv.first
7458
    else
7459
      nil
7460
    end
7461 0:513646585e45 Chris
  end
7462 441:cbce1fd3b1b7 Chris
7463
  def css_classes
7464
    @css_classes ||= "#{name} #{@cf.field_format}"
7465
  end
7466 0:513646585e45 Chris
end
7467
7468 1464:261b3d9a4903 Chris
class QueryAssociationCustomFieldColumn < QueryCustomFieldColumn
7469
7470
  def initialize(association, custom_field)
7471
    super(custom_field)
7472
    self.name = "#{association}.cf_#{custom_field.id}".to_sym
7473
    # TODO: support sorting/grouping by association custom field
7474
    self.sortable = false
7475
    self.groupable = false
7476
    @association = association
7477
  end
7478
7479
  def value(object)
7480
    if assoc = object.send(@association)
7481
      super(assoc)
7482
    end
7483
  end
7484
7485
  def css_classes
7486
    @css_classes ||= "#{@association}_cf_#{@cf.id} #{@cf.field_format}"
7487
  end
7488
end
7489
7490 0:513646585e45 Chris
class Query < ActiveRecord::Base
7491
  class StatementInvalid < ::ActiveRecord::StatementInvalid
7492
  end
7493 441:cbce1fd3b1b7 Chris
7494 1464:261b3d9a4903 Chris
  VISIBILITY_PRIVATE = 0
7495
  VISIBILITY_ROLES   = 1
7496
  VISIBILITY_PUBLIC  = 2
7497
7498 0:513646585e45 Chris
  belongs_to :project
7499
  belongs_to :user
7500 1464:261b3d9a4903 Chris
  has_and_belongs_to_many :roles, :join_table => "#{table_name_prefix}queries_roles#{table_name_suffix}", :foreign_key => "query_id"
7501 0:513646585e45 Chris
  serialize :filters
7502
  serialize :column_names
7503
  serialize :sort_criteria, Array
7504 1464:261b3d9a4903 Chris
  serialize :options, Hash
7505 441:cbce1fd3b1b7 Chris
7506 0:513646585e45 Chris
  attr_protected :project_id, :user_id
7507 441:cbce1fd3b1b7 Chris
7508 1115:433d4f72a19b Chris
  validates_presence_of :name
7509 0:513646585e45 Chris
  validates_length_of :name, :maximum => 255
7510 1464:261b3d9a4903 Chris
  validates :visibility, :inclusion => { :in => [VISIBILITY_PUBLIC, VISIBILITY_ROLES, VISIBILITY_PRIVATE] }
7511 909:cbb26bc654de Chris
  validate :validate_query_filters
7512 1464:261b3d9a4903 Chris
  validate do |query|
7513
    errors.add(:base, l(:label_role_plural) + ' ' + l('activerecord.errors.messages.blank')) if query.visibility == VISIBILITY_ROLES && roles.blank?
7514
  end
7515 441:cbce1fd3b1b7 Chris
7516 1464:261b3d9a4903 Chris
  after_save do |query|
7517
    if query.visibility_changed? && query.visibility != VISIBILITY_ROLES
7518
	    query.roles.clear
7519
	  end
7520
  end
7521 0:513646585e45 Chris
7522 1464:261b3d9a4903 Chris
  class_attribute :operators
7523
  self.operators = {
7524
    "="   => :label_equals,
7525
    "!"   => :label_not_equals,
7526
    "o"   => :label_open_issues,
7527
    "c"   => :label_closed_issues,
7528
    "!*"  => :label_none,
7529
    "*"   => :label_any,
7530
    ">="  => :label_greater_or_equal,
7531
    "<="  => :label_less_or_equal,
7532
    "><"  => :label_between,
7533
    "<t+" => :label_in_less_than,
7534
    ">t+" => :label_in_more_than,
7535
    "><t+"=> :label_in_the_next_days,
7536
    "t+"  => :label_in,
7537
    "t"   => :label_today,
7538
    "ld"  => :label_yesterday,
7539
    "w"   => :label_this_week,
7540
    "lw"  => :label_last_week,
7541
    "l2w" => [:label_last_n_weeks, {:count => 2}],
7542
    "m"   => :label_this_month,
7543
    "lm"  => :label_last_month,
7544
    "y"   => :label_this_year,
7545
    ">t-" => :label_less_than_ago,
7546
    "<t-" => :label_more_than_ago,
7547
    "><t-"=> :label_in_the_past_days,
7548
    "t-"  => :label_ago,
7549
    "~"   => :label_contains,
7550
    "!~"  => :label_not_contains,
7551
    "=p"  => :label_any_issues_in_project,
7552
    "=!p" => :label_any_issues_not_in_project,
7553
    "!p"  => :label_no_issues_in_project
7554
  }
7555 441:cbce1fd3b1b7 Chris
7556 1464:261b3d9a4903 Chris
  class_attribute :operators_by_filter_type
7557
  self.operators_by_filter_type = {
7558
    :list => [ "=", "!" ],
7559
    :list_status => [ "o", "=", "!", "c", "*" ],
7560
    :list_optional => [ "=", "!", "!*", "*" ],
7561
    :list_subprojects => [ "*", "!*", "=" ],
7562
    :date => [ "=", ">=", "<=", "><", "<t+", ">t+", "><t+", "t+", "t", "ld", "w", "lw", "l2w", "m", "lm", "y", ">t-", "<t-", "><t-", "t-", "!*", "*" ],
7563
    :date_past => [ "=", ">=", "<=", "><", ">t-", "<t-", "><t-", "t-", "t", "ld", "w", "lw", "l2w", "m", "lm", "y", "!*", "*" ],
7564
    :string => [ "=", "~", "!", "!~", "!*", "*" ],
7565
    :text => [  "~", "!~", "!*", "*" ],
7566
    :integer => [ "=", ">=", "<=", "><", "!*", "*" ],
7567
    :float => [ "=", ">=", "<=", "><", "!*", "*" ],
7568
    :relation => ["=", "=p", "=!p", "!p", "!*", "*"]
7569
  }
7570 0:513646585e45 Chris
7571 1464:261b3d9a4903 Chris
  class_attribute :available_columns
7572
  self.available_columns = []
7573 0:513646585e45 Chris
7574 1464:261b3d9a4903 Chris
  class_attribute :queried_class
7575 441:cbce1fd3b1b7 Chris
7576 1464:261b3d9a4903 Chris
  def queried_table_name
7577
    @queried_table_name ||= self.class.queried_class.table_name
7578
  end
7579 909:cbb26bc654de Chris
7580 1115:433d4f72a19b Chris
  def initialize(attributes=nil, *args)
7581 0:513646585e45 Chris
    super attributes
7582
    @is_for_all = project.nil?
7583
  end
7584 441:cbce1fd3b1b7 Chris
7585 1464:261b3d9a4903 Chris
  # Builds the query from the given params
7586
  def build_from_params(params)
7587
    if params[:fields] || params[:f]
7588
      self.filters = {}
7589
      add_filters(params[:fields] || params[:f], params[:operators] || params[:op], params[:values] || params[:v])
7590
    else
7591
      available_filters.keys.each do |field|
7592
        add_short_filter(field, params[field]) if params[field]
7593
      end
7594
    end
7595
    self.group_by = params[:group_by] || (params[:query] && params[:query][:group_by])
7596
    self.column_names = params[:c] || (params[:query] && params[:query][:column_names])
7597
    self
7598
  end
7599
7600
  # Builds a new query from the given params and attributes
7601
  def self.build_from_params(params, attributes={})
7602
    new(attributes).build_from_params(params)
7603
  end
7604
7605 909:cbb26bc654de Chris
  def validate_query_filters
7606 0:513646585e45 Chris
    filters.each_key do |field|
7607 909:cbb26bc654de Chris
      if values_for(field)
7608
        case type_for(field)
7609
        when :integer
7610 1115:433d4f72a19b Chris
          add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && !v.match(/^[+-]?\d+$/) }
7611 909:cbb26bc654de Chris
        when :float
7612 1115:433d4f72a19b Chris
          add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && !v.match(/^[+-]?\d+(\.\d*)?$/) }
7613 909:cbb26bc654de Chris
        when :date, :date_past
7614
          case operator_for(field)
7615
          when "=", ">=", "<=", "><"
7616 1517:dffacf8a6908 Chris
            add_filter_error(field, :invalid) if values_for(field).detect {|v|
7617
              v.present? && (!v.match(/\A\d{4}-\d{2}-\d{2}(T\d{2}((:)?\d{2}){0,2}(Z|\d{2}:?\d{2})?)?\z/) || parse_date(v).nil?)
7618
            }
7619 1115:433d4f72a19b Chris
          when ">t-", "<t-", "t-", ">t+", "<t+", "t+", "><t+", "><t-"
7620
            add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && !v.match(/^\d+$/) }
7621 909:cbb26bc654de Chris
          end
7622
        end
7623
      end
7624
7625 1115:433d4f72a19b Chris
      add_filter_error(field, :blank) unless
7626 0:513646585e45 Chris
          # filter requires one or more values
7627 441:cbce1fd3b1b7 Chris
          (values_for(field) and !values_for(field).first.blank?) or
7628 0:513646585e45 Chris
          # filter doesn't require any value
7629 1464:261b3d9a4903 Chris
          ["o", "c", "!*", "*", "t", "ld", "w", "lw", "l2w", "m", "lm", "y"].include? operator_for(field)
7630 0:513646585e45 Chris
    end if filters
7631
  end
7632 909:cbb26bc654de Chris
7633 1115:433d4f72a19b Chris
  def add_filter_error(field, message)
7634
    m = label_for(field) + " " + l(message, :scope => 'activerecord.errors.messages')
7635
    errors.add(:base, m)
7636
  end
7637
7638 0:513646585e45 Chris
  def editable_by?(user)
7639
    return false unless user
7640
    # Admin can edit them all and regular users can edit their private queries
7641 1464:261b3d9a4903 Chris
    return true if user.admin? || (is_private? && self.user_id == user.id)
7642 0:513646585e45 Chris
    # Members can not edit public queries that are for all project (only admin is allowed to)
7643 1464:261b3d9a4903 Chris
    is_public? && !@is_for_all && user.allowed_to?(:manage_public_queries, project)
7644 0:513646585e45 Chris
  end
7645 441:cbce1fd3b1b7 Chris
7646 1115:433d4f72a19b Chris
  def trackers
7647 1464:261b3d9a4903 Chris
    @trackers ||= project.nil? ? Tracker.sorted.all : project.rolled_up_trackers
7648 1115:433d4f72a19b Chris
  end
7649
7650
  # Returns a hash of localized labels for all filter operators
7651
  def self.operators_labels
7652 1464:261b3d9a4903 Chris
    operators.inject({}) {|h, operator| h[operator.first] = l(*operator.last); h}
7653 0:513646585e45 Chris
  end
7654 441:cbce1fd3b1b7 Chris
7655 1115:433d4f72a19b Chris
  # Returns a representation of the available filters for JSON serialization
7656
  def available_filters_as_json
7657
    json = {}
7658
    available_filters.each do |field, options|
7659
      json[field] = options.slice(:type, :name, :values).stringify_keys
7660
    end
7661
    json
7662
  end
7663
7664
  def all_projects
7665
    @all_projects ||= Project.visible.all
7666
  end
7667
7668
  def all_projects_values
7669
    return @all_projects_values if @all_projects_values
7670
7671
    values = []
7672
    Project.project_tree(all_projects) do |p, level|
7673
      prefix = (level > 0 ? ('--' * level + ' ') : '')
7674
      values << ["#{prefix}#{p.name}", p.id.to_s]
7675
    end
7676
    @all_projects_values = values
7677
  end
7678
7679 1464:261b3d9a4903 Chris
  # Adds available filters
7680
  def initialize_available_filters
7681
    # implemented by sub-classes
7682
  end
7683
  protected :initialize_available_filters
7684
7685
  # Adds an available filter
7686
  def add_available_filter(field, options)
7687
    @available_filters ||= ActiveSupport::OrderedHash.new
7688
    @available_filters[field] = options
7689
    @available_filters
7690
  end
7691
7692
  # Removes an available filter
7693
  def delete_available_filter(field)
7694
    if @available_filters
7695
      @available_filters.delete(field)
7696
    end
7697
  end
7698
7699
  # Return a hash of available filters
7700
  def available_filters
7701
    unless @available_filters
7702
      initialize_available_filters
7703
      @available_filters.each do |field, options|
7704
        options[:name] ||= l(options[:label] || "field_#{field}".gsub(/_id$/, ''))
7705
      end
7706
    end
7707
    @available_filters
7708
  end
7709
7710
  def add_filter(field, operator, values=nil)
7711 0:513646585e45 Chris
    # values must be an array
7712 909:cbb26bc654de Chris
    return unless values.nil? || values.is_a?(Array)
7713 0:513646585e45 Chris
    # check if field is defined as an available filter
7714
    if available_filters.has_key? field
7715
      filter_options = available_filters[field]
7716 909:cbb26bc654de Chris
      filters[field] = {:operator => operator, :values => (values || [''])}
7717 0:513646585e45 Chris
    end
7718
  end
7719 441:cbce1fd3b1b7 Chris
7720 0:513646585e45 Chris
  def add_short_filter(field, expression)
7721 909:cbb26bc654de Chris
    return unless expression && available_filters.has_key?(field)
7722
    field_type = available_filters[field][:type]
7723 1464:261b3d9a4903 Chris
    operators_by_filter_type[field_type].sort.reverse.detect do |operator|
7724 909:cbb26bc654de Chris
      next unless expression =~ /^#{Regexp.escape(operator)}(.*)$/
7725 1464:261b3d9a4903 Chris
      values = $1
7726
      add_filter field, operator, values.present? ? values.split('|') : ['']
7727 909:cbb26bc654de Chris
    end || add_filter(field, '=', expression.split('|'))
7728 0:513646585e45 Chris
  end
7729
7730
  # Add multiple filters using +add_filter+
7731
  def add_filters(fields, operators, values)
7732 909:cbb26bc654de Chris
    if fields.is_a?(Array) && operators.is_a?(Hash) && (values.nil? || values.is_a?(Hash))
7733 37:94944d00e43c chris
      fields.each do |field|
7734 909:cbb26bc654de Chris
        add_filter(field, operators[field], values && values[field])
7735 37:94944d00e43c chris
      end
7736 0:513646585e45 Chris
    end
7737
  end
7738 441:cbce1fd3b1b7 Chris
7739 0:513646585e45 Chris
  def has_filter?(field)
7740
    filters and filters[field]
7741
  end
7742 441:cbce1fd3b1b7 Chris
7743 909:cbb26bc654de Chris
  def type_for(field)
7744
    available_filters[field][:type] if available_filters.has_key?(field)
7745
  end
7746
7747 0:513646585e45 Chris
  def operator_for(field)
7748
    has_filter?(field) ? filters[field][:operator] : nil
7749
  end
7750 441:cbce1fd3b1b7 Chris
7751 0:513646585e45 Chris
  def values_for(field)
7752
    has_filter?(field) ? filters[field][:values] : nil
7753
  end
7754 441:cbce1fd3b1b7 Chris
7755 909:cbb26bc654de Chris
  def value_for(field, index=0)
7756
    (values_for(field) || [])[index]
7757
  end
7758
7759 0:513646585e45 Chris
  def label_for(field)
7760
    label = available_filters[field][:name] if available_filters.has_key?(field)
7761 1115:433d4f72a19b Chris
    label ||= l("field_#{field.to_s.gsub(/_id$/, '')}", :default => field)
7762 0:513646585e45 Chris
  end
7763
7764
  def self.add_available_column(column)
7765
    self.available_columns << (column) if column.is_a?(QueryColumn)
7766
  end
7767 441:cbce1fd3b1b7 Chris
7768 0:513646585e45 Chris
  # Returns an array of columns that can be used to group the results
7769
  def groupable_columns
7770
    available_columns.select {|c| c.groupable}
7771
  end
7772
7773
  # Returns a Hash of columns and the key for sorting
7774
  def sortable_columns
7775 1464:261b3d9a4903 Chris
    available_columns.inject({}) {|h, column|
7776
      h[column.name.to_s] = column.sortable
7777
      h
7778
    }
7779 0:513646585e45 Chris
  end
7780 441:cbce1fd3b1b7 Chris
7781 0:513646585e45 Chris
  def columns
7782 909:cbb26bc654de Chris
    # preserve the column_names order
7783 1464:261b3d9a4903 Chris
    cols = (has_default_columns? ? default_columns_names : column_names).collect do |name|
7784 909:cbb26bc654de Chris
       available_columns.find { |col| col.name == name }
7785
    end.compact
7786 1464:261b3d9a4903 Chris
    available_columns.select(&:frozen?) | cols
7787 909:cbb26bc654de Chris
  end
7788
7789 1115:433d4f72a19b Chris
  def inline_columns
7790
    columns.select(&:inline?)
7791
  end
7792
7793
  def block_columns
7794
    columns.reject(&:inline?)
7795
  end
7796
7797
  def available_inline_columns
7798
    available_columns.select(&:inline?)
7799
  end
7800
7801
  def available_block_columns
7802
    available_columns.reject(&:inline?)
7803
  end
7804
7805 909:cbb26bc654de Chris
  def default_columns_names
7806 1464:261b3d9a4903 Chris
    []
7807 0:513646585e45 Chris
  end
7808 441:cbce1fd3b1b7 Chris
7809 0:513646585e45 Chris
  def column_names=(names)
7810
    if names
7811
      names = names.select {|n| n.is_a?(Symbol) || !n.blank? }
7812
      names = names.collect {|n| n.is_a?(Symbol) ? n : n.to_sym }
7813
      # Set column_names to nil if default columns
7814 909:cbb26bc654de Chris
      if names == default_columns_names
7815 0:513646585e45 Chris
        names = nil
7816
      end
7817
    end
7818
    write_attribute(:column_names, names)
7819
  end
7820 441:cbce1fd3b1b7 Chris
7821 0:513646585e45 Chris
  def has_column?(column)
7822 1115:433d4f72a19b Chris
    column_names && column_names.include?(column.is_a?(QueryColumn) ? column.name : column)
7823 0:513646585e45 Chris
  end
7824 441:cbce1fd3b1b7 Chris
7825 1464:261b3d9a4903 Chris
  def has_custom_field_column?
7826
    columns.any? {|column| column.is_a? QueryCustomFieldColumn}
7827
  end
7828
7829 0:513646585e45 Chris
  def has_default_columns?
7830
    column_names.nil? || column_names.empty?
7831
  end
7832 441:cbce1fd3b1b7 Chris
7833 0:513646585e45 Chris
  def sort_criteria=(arg)
7834
    c = []
7835
    if arg.is_a?(Hash)
7836
      arg = arg.keys.sort.collect {|k| arg[k]}
7837
    end
7838 1115:433d4f72a19b Chris
    c = arg.select {|k,o| !k.to_s.blank?}.slice(0,3).collect {|k,o| [k.to_s, (o == 'desc' || o == false) ? 'desc' : 'asc']}
7839 0:513646585e45 Chris
    write_attribute(:sort_criteria, c)
7840
  end
7841 441:cbce1fd3b1b7 Chris
7842 0:513646585e45 Chris
  def sort_criteria
7843
    read_attribute(:sort_criteria) || []
7844
  end
7845 441:cbce1fd3b1b7 Chris
7846 0:513646585e45 Chris
  def sort_criteria_key(arg)
7847
    sort_criteria && sort_criteria[arg] && sort_criteria[arg].first
7848
  end
7849 441:cbce1fd3b1b7 Chris
7850 0:513646585e45 Chris
  def sort_criteria_order(arg)
7851
    sort_criteria && sort_criteria[arg] && sort_criteria[arg].last
7852
  end
7853 441:cbce1fd3b1b7 Chris
7854 1115:433d4f72a19b Chris
  def sort_criteria_order_for(key)
7855
    sort_criteria.detect {|k, order| key.to_s == k}.try(:last)
7856
  end
7857
7858 0:513646585e45 Chris
  # Returns the SQL sort order that should be prepended for grouping
7859
  def group_by_sort_order
7860
    if grouped? && (column = group_by_column)
7861 1115:433d4f72a19b Chris
      order = sort_criteria_order_for(column.name) || column.default_order
7862 0:513646585e45 Chris
      column.sortable.is_a?(Array) ?
7863 1115:433d4f72a19b Chris
        column.sortable.collect {|s| "#{s} #{order}"}.join(',') :
7864
        "#{column.sortable} #{order}"
7865 0:513646585e45 Chris
    end
7866
  end
7867 441:cbce1fd3b1b7 Chris
7868 0:513646585e45 Chris
  # Returns true if the query is a grouped query
7869
  def grouped?
7870 119:8661b858af72 Chris
    !group_by_column.nil?
7871 0:513646585e45 Chris
  end
7872 441:cbce1fd3b1b7 Chris
7873 0:513646585e45 Chris
  def group_by_column
7874 119:8661b858af72 Chris
    groupable_columns.detect {|c| c.groupable && c.name.to_s == group_by}
7875 0:513646585e45 Chris
  end
7876 441:cbce1fd3b1b7 Chris
7877 0:513646585e45 Chris
  def group_by_statement
7878 119:8661b858af72 Chris
    group_by_column.try(:groupable)
7879 0:513646585e45 Chris
  end
7880 441:cbce1fd3b1b7 Chris
7881 0:513646585e45 Chris
  def project_statement
7882
    project_clauses = []
7883 909:cbb26bc654de Chris
    if project && !project.descendants.active.empty?
7884 0:513646585e45 Chris
      ids = [project.id]
7885
      if has_filter?("subproject_id")
7886
        case operator_for("subproject_id")
7887
        when '='
7888
          # include the selected subprojects
7889
          ids += values_for("subproject_id").each(&:to_i)
7890
        when '!*'
7891
          # main project only
7892
        else
7893
          # all subprojects
7894
          ids += project.descendants.collect(&:id)
7895
        end
7896
      elsif Setting.display_subprojects_issues?
7897
        ids += project.descendants.collect(&:id)
7898
      end
7899
      project_clauses << "#{Project.table_name}.id IN (%s)" % ids.join(',')
7900
    elsif project
7901
      project_clauses << "#{Project.table_name}.id = %d" % project.id
7902
    end
7903 441:cbce1fd3b1b7 Chris
    project_clauses.any? ? project_clauses.join(' AND ') : nil
7904 0:513646585e45 Chris
  end
7905
7906
  def statement
7907
    # filters clauses
7908
    filters_clauses = []
7909
    filters.each_key do |field|
7910
      next if field == "subproject_id"
7911
      v = values_for(field).clone
7912
      next unless v and !v.empty?
7913
      operator = operator_for(field)
7914 441:cbce1fd3b1b7 Chris
7915 0:513646585e45 Chris
      # "me" value subsitution
7916 1464:261b3d9a4903 Chris
      if %w(assigned_to_id author_id user_id watcher_id).include?(field)
7917 909:cbb26bc654de Chris
        if v.delete("me")
7918
          if User.current.logged?
7919
            v.push(User.current.id.to_s)
7920
            v += User.current.group_ids.map(&:to_s) if field == 'assigned_to_id'
7921
          else
7922
            v.push("0")
7923
          end
7924
        end
7925 0:513646585e45 Chris
      end
7926 441:cbce1fd3b1b7 Chris
7927 1115:433d4f72a19b Chris
      if field == 'project_id'
7928
        if v.delete('mine')
7929
          v += User.current.memberships.map(&:project_id).map(&:to_s)
7930
        end
7931
      end
7932
7933
      if field =~ /cf_(\d+)$/
7934 0:513646585e45 Chris
        # custom field
7935 909:cbb26bc654de Chris
        filters_clauses << sql_for_custom_field(field, operator, v, $1)
7936
      elsif respond_to?("sql_for_#{field}_field")
7937
        # specific statement
7938
        filters_clauses << send("sql_for_#{field}_field", field, operator, v)
7939 0:513646585e45 Chris
      else
7940
        # regular field
7941 1464:261b3d9a4903 Chris
        filters_clauses << '(' + sql_for_field(field, operator, v, queried_table_name, field) + ')'
7942 0:513646585e45 Chris
      end
7943
    end if filters and valid?
7944 441:cbce1fd3b1b7 Chris
7945 1464:261b3d9a4903 Chris
    if (c = group_by_column) && c.is_a?(QueryCustomFieldColumn)
7946
      # Excludes results for which the grouped custom field is not visible
7947
      filters_clauses << c.custom_field.visibility_by_project_condition
7948
    end
7949
7950 441:cbce1fd3b1b7 Chris
    filters_clauses << project_statement
7951
    filters_clauses.reject!(&:blank?)
7952
7953
    filters_clauses.any? ? filters_clauses.join(' AND ') : nil
7954 0:513646585e45 Chris
  end
7955 441:cbce1fd3b1b7 Chris
7956 0:513646585e45 Chris
  private
7957 441:cbce1fd3b1b7 Chris
7958 909:cbb26bc654de Chris
  def sql_for_custom_field(field, operator, value, custom_field_id)
7959
    db_table = CustomValue.table_name
7960
    db_field = 'value'
7961 1115:433d4f72a19b Chris
    filter = @available_filters[field]
7962
    return nil unless filter
7963 1517:dffacf8a6908 Chris
    if filter[:field].format.target_class && filter[:field].format.target_class <= User
7964 1115:433d4f72a19b Chris
      if value.delete('me')
7965
        value.push User.current.id.to_s
7966
      end
7967
    end
7968
    not_in = nil
7969
    if operator == '!'
7970
      # Makes ! operator work for custom fields with multiple values
7971
      operator = '='
7972
      not_in = 'NOT'
7973
    end
7974
    customized_key = "id"
7975 1464:261b3d9a4903 Chris
    customized_class = queried_class
7976 1115:433d4f72a19b Chris
    if field =~ /^(.+)\.cf_/
7977
      assoc = $1
7978
      customized_key = "#{assoc}_id"
7979 1464:261b3d9a4903 Chris
      customized_class = queried_class.reflect_on_association(assoc.to_sym).klass.base_class rescue nil
7980
      raise "Unknown #{queried_class.name} association #{assoc}" unless customized_class
7981 1115:433d4f72a19b Chris
    end
7982 1464:261b3d9a4903 Chris
    where = sql_for_field(field, operator, value, db_table, db_field, true)
7983
    if operator =~ /[<>]/
7984
      where = "(#{where}) AND #{db_table}.#{db_field} <> ''"
7985
    end
7986
    "#{queried_table_name}.#{customized_key} #{not_in} IN (" +
7987
      "SELECT #{customized_class.table_name}.id FROM #{customized_class.table_name}" +
7988
      " LEFT OUTER JOIN #{db_table} ON #{db_table}.customized_type='#{customized_class}' AND #{db_table}.customized_id=#{customized_class.table_name}.id AND #{db_table}.custom_field_id=#{custom_field_id}" +
7989
      " WHERE (#{where}) AND (#{filter[:field].visibility_by_project_condition}))"
7990 909:cbb26bc654de Chris
  end
7991
7992 0:513646585e45 Chris
  # Helper method to generate the WHERE sql for a +field+, +operator+ and a +value+
7993
  def sql_for_field(field, operator, value, db_table, db_field, is_custom_filter=false)
7994
    sql = ''
7995
    case operator
7996
    when "="
7997 245:051f544170fe Chris
      if value.any?
7998 909:cbb26bc654de Chris
        case type_for(field)
7999
        when :date, :date_past
8000 1517:dffacf8a6908 Chris
          sql = date_clause(db_table, db_field, parse_date(value.first), parse_date(value.first))
8001 909:cbb26bc654de Chris
        when :integer
8002 1115:433d4f72a19b Chris
          if is_custom_filter
8003 1464:261b3d9a4903 Chris
            sql = "(#{db_table}.#{db_field} <> '' AND CAST(CASE #{db_table}.#{db_field} WHEN '' THEN '0' ELSE #{db_table}.#{db_field} END AS decimal(30,3)) = #{value.first.to_i})"
8004 1115:433d4f72a19b Chris
          else
8005
            sql = "#{db_table}.#{db_field} = #{value.first.to_i}"
8006
          end
8007 909:cbb26bc654de Chris
        when :float
8008 1115:433d4f72a19b Chris
          if is_custom_filter
8009 1464:261b3d9a4903 Chris
            sql = "(#{db_table}.#{db_field} <> '' AND CAST(CASE #{db_table}.#{db_field} WHEN '' THEN '0' ELSE #{db_table}.#{db_field} END AS decimal(30,3)) BETWEEN #{value.first.to_f - 1e-5} AND #{value.first.to_f + 1e-5})"
8010 1115:433d4f72a19b Chris
          else
8011
            sql = "#{db_table}.#{db_field} BETWEEN #{value.first.to_f - 1e-5} AND #{value.first.to_f + 1e-5}"
8012
          end
8013 909:cbb26bc654de Chris
        else
8014
          sql = "#{db_table}.#{db_field} IN (" + value.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + ")"
8015
        end
8016 245:051f544170fe Chris
      else
8017
        # IN an empty set
8018
        sql = "1=0"
8019
      end
8020 0:513646585e45 Chris
    when "!"
8021 245:051f544170fe Chris
      if value.any?
8022
        sql = "(#{db_table}.#{db_field} IS NULL OR #{db_table}.#{db_field} NOT IN (" + value.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + "))"
8023
      else
8024
        # NOT IN an empty set
8025
        sql = "1=1"
8026
      end
8027 0:513646585e45 Chris
    when "!*"
8028
      sql = "#{db_table}.#{db_field} IS NULL"
8029
      sql << " OR #{db_table}.#{db_field} = ''" if is_custom_filter
8030
    when "*"
8031
      sql = "#{db_table}.#{db_field} IS NOT NULL"
8032
      sql << " AND #{db_table}.#{db_field} <> ''" if is_custom_filter
8033
    when ">="
8034 909:cbb26bc654de Chris
      if [:date, :date_past].include?(type_for(field))
8035 1517:dffacf8a6908 Chris
        sql = date_clause(db_table, db_field, parse_date(value.first), nil)
8036 909:cbb26bc654de Chris
      else
8037
        if is_custom_filter
8038 1464:261b3d9a4903 Chris
          sql = "(#{db_table}.#{db_field} <> '' AND CAST(CASE #{db_table}.#{db_field} WHEN '' THEN '0' ELSE #{db_table}.#{db_field} END AS decimal(30,3)) >= #{value.first.to_f})"
8039 909:cbb26bc654de Chris
        else
8040
          sql = "#{db_table}.#{db_field} >= #{value.first.to_f}"
8041
        end
8042
      end
8043 0:513646585e45 Chris
    when "<="
8044 909:cbb26bc654de Chris
      if [:date, :date_past].include?(type_for(field))
8045 1517:dffacf8a6908 Chris
        sql = date_clause(db_table, db_field, nil, parse_date(value.first))
8046 909:cbb26bc654de Chris
      else
8047
        if is_custom_filter
8048 1464:261b3d9a4903 Chris
          sql = "(#{db_table}.#{db_field} <> '' AND CAST(CASE #{db_table}.#{db_field} WHEN '' THEN '0' ELSE #{db_table}.#{db_field} END AS decimal(30,3)) <= #{value.first.to_f})"
8049 909:cbb26bc654de Chris
        else
8050
          sql = "#{db_table}.#{db_field} <= #{value.first.to_f}"
8051
        end
8052
      end
8053
    when "><"
8054
      if [:date, :date_past].include?(type_for(field))
8055 1517:dffacf8a6908 Chris
        sql = date_clause(db_table, db_field, parse_date(value[0]), parse_date(value[1]))
8056 909:cbb26bc654de Chris
      else
8057
        if is_custom_filter
8058 1464:261b3d9a4903 Chris
          sql = "(#{db_table}.#{db_field} <> '' AND CAST(CASE #{db_table}.#{db_field} WHEN '' THEN '0' ELSE #{db_table}.#{db_field} END AS decimal(30,3)) BETWEEN #{value[0].to_f} AND #{value[1].to_f})"
8059 909:cbb26bc654de Chris
        else
8060
          sql = "#{db_table}.#{db_field} BETWEEN #{value[0].to_f} AND #{value[1].to_f}"
8061
        end
8062
      end
8063 0:513646585e45 Chris
    when "o"
8064 1464:261b3d9a4903 Chris
      sql = "#{queried_table_name}.status_id IN (SELECT id FROM #{IssueStatus.table_name} WHERE is_closed=#{connection.quoted_false})" if field == "status_id"
8065 0:513646585e45 Chris
    when "c"
8066 1464:261b3d9a4903 Chris
      sql = "#{queried_table_name}.status_id IN (SELECT id FROM #{IssueStatus.table_name} WHERE is_closed=#{connection.quoted_true})" if field == "status_id"
8067 1115:433d4f72a19b Chris
    when "><t-"
8068
      # between today - n days and today
8069
      sql = relative_date_clause(db_table, db_field, - value.first.to_i, 0)
8070 0:513646585e45 Chris
    when ">t-"
8071 1115:433d4f72a19b Chris
      # >= today - n days
8072
      sql = relative_date_clause(db_table, db_field, - value.first.to_i, nil)
8073 0:513646585e45 Chris
    when "<t-"
8074 1115:433d4f72a19b Chris
      # <= today - n days
8075 909:cbb26bc654de Chris
      sql = relative_date_clause(db_table, db_field, nil, - value.first.to_i)
8076 0:513646585e45 Chris
    when "t-"
8077 1115:433d4f72a19b Chris
      # = n days in past
8078 909:cbb26bc654de Chris
      sql = relative_date_clause(db_table, db_field, - value.first.to_i, - value.first.to_i)
8079 1115:433d4f72a19b Chris
    when "><t+"
8080
      # between today and today + n days
8081
      sql = relative_date_clause(db_table, db_field, 0, value.first.to_i)
8082 0:513646585e45 Chris
    when ">t+"
8083 1115:433d4f72a19b Chris
      # >= today + n days
8084 909:cbb26bc654de Chris
      sql = relative_date_clause(db_table, db_field, value.first.to_i, nil)
8085 0:513646585e45 Chris
    when "<t+"
8086 1115:433d4f72a19b Chris
      # <= today + n days
8087
      sql = relative_date_clause(db_table, db_field, nil, value.first.to_i)
8088 0:513646585e45 Chris
    when "t+"
8089 1115:433d4f72a19b Chris
      # = today + n days
8090 909:cbb26bc654de Chris
      sql = relative_date_clause(db_table, db_field, value.first.to_i, value.first.to_i)
8091 0:513646585e45 Chris
    when "t"
8092 1115:433d4f72a19b Chris
      # = today
8093 909:cbb26bc654de Chris
      sql = relative_date_clause(db_table, db_field, 0, 0)
8094 1464:261b3d9a4903 Chris
    when "ld"
8095
      # = yesterday
8096
      sql = relative_date_clause(db_table, db_field, -1, -1)
8097 0:513646585e45 Chris
    when "w"
8098 1115:433d4f72a19b Chris
      # = this week
8099 441:cbce1fd3b1b7 Chris
      first_day_of_week = l(:general_first_day_of_week).to_i
8100
      day_of_week = Date.today.cwday
8101
      days_ago = (day_of_week >= first_day_of_week ? day_of_week - first_day_of_week : day_of_week + 7 - first_day_of_week)
8102 909:cbb26bc654de Chris
      sql = relative_date_clause(db_table, db_field, - days_ago, - days_ago + 6)
8103 1464:261b3d9a4903 Chris
    when "lw"
8104
      # = last week
8105
      first_day_of_week = l(:general_first_day_of_week).to_i
8106
      day_of_week = Date.today.cwday
8107
      days_ago = (day_of_week >= first_day_of_week ? day_of_week - first_day_of_week : day_of_week + 7 - first_day_of_week)
8108
      sql = relative_date_clause(db_table, db_field, - days_ago - 7, - days_ago - 1)
8109
    when "l2w"
8110
      # = last 2 weeks
8111
      first_day_of_week = l(:general_first_day_of_week).to_i
8112
      day_of_week = Date.today.cwday
8113
      days_ago = (day_of_week >= first_day_of_week ? day_of_week - first_day_of_week : day_of_week + 7 - first_day_of_week)
8114
      sql = relative_date_clause(db_table, db_field, - days_ago - 14, - days_ago - 1)
8115
    when "m"
8116
      # = this month
8117
      date = Date.today
8118
      sql = date_clause(db_table, db_field, date.beginning_of_month, date.end_of_month)
8119
    when "lm"
8120
      # = last month
8121
      date = Date.today.prev_month
8122
      sql = date_clause(db_table, db_field, date.beginning_of_month, date.end_of_month)
8123
    when "y"
8124
      # = this year
8125
      date = Date.today
8126
      sql = date_clause(db_table, db_field, date.beginning_of_year, date.end_of_year)
8127 0:513646585e45 Chris
    when "~"
8128
      sql = "LOWER(#{db_table}.#{db_field}) LIKE '%#{connection.quote_string(value.first.to_s.downcase)}%'"
8129
    when "!~"
8130
      sql = "LOWER(#{db_table}.#{db_field}) NOT LIKE '%#{connection.quote_string(value.first.to_s.downcase)}%'"
8131 909:cbb26bc654de Chris
    else
8132
      raise "Unknown query operator #{operator}"
8133 0:513646585e45 Chris
    end
8134 441:cbce1fd3b1b7 Chris
8135 0:513646585e45 Chris
    return sql
8136
  end
8137 441:cbce1fd3b1b7 Chris
8138 1464:261b3d9a4903 Chris
  # Adds a filter for the given custom field
8139
  def add_custom_field_filter(field, assoc=nil)
8140 1517:dffacf8a6908 Chris
    options = field.format.query_filter_options(field, self)
8141
    if field.format.target_class && field.format.target_class <= User
8142
      if options[:values].is_a?(Array) && User.current.logged?
8143
        options[:values].unshift ["<< #{l(:label_me)} >>", "me"]
8144 1464:261b3d9a4903 Chris
      end
8145
    end
8146 1517:dffacf8a6908 Chris
8147 1464:261b3d9a4903 Chris
    filter_id = "cf_#{field.id}"
8148
    filter_name = field.name
8149
    if assoc.present?
8150
      filter_id = "#{assoc}.#{filter_id}"
8151
      filter_name = l("label_attribute_of_#{assoc}", :name => filter_name)
8152
    end
8153
    add_available_filter filter_id, options.merge({
8154
      :name => filter_name,
8155
      :field => field
8156
    })
8157
  end
8158 441:cbce1fd3b1b7 Chris
8159 1464:261b3d9a4903 Chris
  # Adds filters for the given custom fields scope
8160
  def add_custom_fields_filters(scope, assoc=nil)
8161
    scope.visible.where(:is_filter => true).sorted.each do |field|
8162
      add_custom_field_filter(field, assoc)
8163 1115:433d4f72a19b Chris
    end
8164
  end
8165
8166 1464:261b3d9a4903 Chris
  # Adds filters for the given associations custom fields
8167 1115:433d4f72a19b Chris
  def add_associations_custom_fields_filters(*associations)
8168 1464:261b3d9a4903 Chris
    fields_by_class = CustomField.visible.where(:is_filter => true).group_by(&:class)
8169 1115:433d4f72a19b Chris
    associations.each do |assoc|
8170 1464:261b3d9a4903 Chris
      association_klass = queried_class.reflect_on_association(assoc).klass
8171 1115:433d4f72a19b Chris
      fields_by_class.each do |field_class, fields|
8172
        if field_class.customized_class <= association_klass
8173 1464:261b3d9a4903 Chris
          fields.sort.each do |field|
8174
            add_custom_field_filter(field, assoc)
8175
          end
8176 1115:433d4f72a19b Chris
        end
8177
      end
8178 0:513646585e45 Chris
    end
8179
  end
8180 441:cbce1fd3b1b7 Chris
8181 0:513646585e45 Chris
  # Returns a SQL clause for a date or datetime field.
8182 909:cbb26bc654de Chris
  def date_clause(table, field, from, to)
8183 0:513646585e45 Chris
    s = []
8184
    if from
8185 1517:dffacf8a6908 Chris
      if from.is_a?(Date)
8186
        from = Time.local(from.year, from.month, from.day).yesterday.end_of_day
8187
      else
8188
        from = from - 1 # second
8189
      end
8190 1115:433d4f72a19b Chris
      if self.class.default_timezone == :utc
8191 1517:dffacf8a6908 Chris
        from = from.utc
8192 1115:433d4f72a19b Chris
      end
8193 1517:dffacf8a6908 Chris
      s << ("#{table}.#{field} > '%s'" % [connection.quoted_date(from)])
8194 0:513646585e45 Chris
    end
8195
    if to
8196 1517:dffacf8a6908 Chris
      if to.is_a?(Date)
8197
        to = Time.local(to.year, to.month, to.day).end_of_day
8198
      end
8199 1115:433d4f72a19b Chris
      if self.class.default_timezone == :utc
8200 1517:dffacf8a6908 Chris
        to = to.utc
8201 1115:433d4f72a19b Chris
      end
8202 1517:dffacf8a6908 Chris
      s << ("#{table}.#{field} <= '%s'" % [connection.quoted_date(to)])
8203 0:513646585e45 Chris
    end
8204
    s.join(' AND ')
8205
  end
8206 909:cbb26bc654de Chris
8207
  # Returns a SQL clause for a date or datetime field using relative dates.
8208
  def relative_date_clause(table, field, days_from, days_to)
8209
    date_clause(table, field, (days_from ? Date.today + days_from : nil), (days_to ? Date.today + days_to : nil))
8210
  end
8211 1115:433d4f72a19b Chris
8212 1517:dffacf8a6908 Chris
  # Returns a Date or Time from the given filter value
8213
  def parse_date(arg)
8214
    if arg.to_s =~ /\A\d{4}-\d{2}-\d{2}T/
8215
      Time.parse(arg) rescue nil
8216
    else
8217
      Date.parse(arg) rescue nil
8218
    end
8219
  end
8220
8221 1115:433d4f72a19b Chris
  # Additional joins required for the given sort options
8222
  def joins_for_order_statement(order_options)
8223
    joins = []
8224
8225
    if order_options
8226
      if order_options.include?('authors')
8227 1464:261b3d9a4903 Chris
        joins << "LEFT OUTER JOIN #{User.table_name} authors ON authors.id = #{queried_table_name}.author_id"
8228 1115:433d4f72a19b Chris
      end
8229
      order_options.scan(/cf_\d+/).uniq.each do |name|
8230
        column = available_columns.detect {|c| c.name.to_s == name}
8231
        join = column && column.custom_field.join_for_order_statement
8232
        if join
8233
          joins << join
8234
        end
8235
      end
8236
    end
8237
8238
    joins.any? ? joins.join(' ') : nil
8239
  end
8240 0:513646585e45 Chris
end
8241 441:cbce1fd3b1b7 Chris
# Redmine - project management software
8242 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
8243 0:513646585e45 Chris
#
8244
# This program is free software; you can redistribute it and/or
8245
# modify it under the terms of the GNU General Public License
8246
# as published by the Free Software Foundation; either version 2
8247
# of the License, or (at your option) any later version.
8248 441:cbce1fd3b1b7 Chris
#
8249 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
8250
# but WITHOUT ANY WARRANTY; without even the implied warranty of
8251
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
8252
# GNU General Public License for more details.
8253 441:cbce1fd3b1b7 Chris
#
8254 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
8255
# along with this program; if not, write to the Free Software
8256
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
8257
8258 909:cbb26bc654de Chris
class ScmFetchError < Exception; end
8259
8260 0:513646585e45 Chris
class Repository < ActiveRecord::Base
8261 245:051f544170fe Chris
  include Redmine::Ciphering
8262 1115:433d4f72a19b Chris
  include Redmine::SafeAttributes
8263
8264
  # Maximum length for repository identifiers
8265
  IDENTIFIER_MAX_LENGTH = 255
8266 441:cbce1fd3b1b7 Chris
8267 0:513646585e45 Chris
  belongs_to :project
8268
  has_many :changesets, :order => "#{Changeset.table_name}.committed_on DESC, #{Changeset.table_name}.id DESC"
8269 1115:433d4f72a19b Chris
  has_many :filechanges, :class_name => 'Change', :through => :changesets
8270 441:cbce1fd3b1b7 Chris
8271
  serialize :extra_info
8272
8273 1115:433d4f72a19b Chris
  before_save :check_default
8274
8275 0:513646585e45 Chris
  # Raw SQL to delete changesets and changes in the database
8276
  # has_many :changesets, :dependent => :destroy is too slow for big repositories
8277
  before_destroy :clear_changesets
8278 441:cbce1fd3b1b7 Chris
8279 245:051f544170fe Chris
  validates_length_of :password, :maximum => 255, :allow_nil => true
8280 1115:433d4f72a19b Chris
  validates_length_of :identifier, :maximum => IDENTIFIER_MAX_LENGTH, :allow_blank => true
8281
  validates_presence_of :identifier, :unless => Proc.new { |r| r.is_default? || r.set_as_default? }
8282
  validates_uniqueness_of :identifier, :scope => :project_id, :allow_blank => true
8283 1517:dffacf8a6908 Chris
  validates_exclusion_of :identifier, :in => %w(browse show entry raw changes annotate diff statistics graph revisions revision)
8284 1115:433d4f72a19b Chris
  # donwcase letters, digits, dashes, underscores but not digits only
8285 1464:261b3d9a4903 Chris
  validates_format_of :identifier, :with => /\A(?!\d+$)[a-z0-9\-_]*\z/, :allow_blank => true
8286 0:513646585e45 Chris
  # Checks if the SCM is enabled when creating a repository
8287 909:cbb26bc654de Chris
  validate :repo_create_validation, :on => :create
8288
8289 1115:433d4f72a19b Chris
  safe_attributes 'identifier',
8290
    'login',
8291
    'password',
8292
    'path_encoding',
8293
    'log_encoding',
8294 1355:3d01be97cb5a chris
    'is_external',
8295
    'external_url',
8296 1115:433d4f72a19b Chris
    'is_default'
8297
8298
  safe_attributes 'url',
8299
    :if => lambda {|repository, user| repository.new_record?}
8300
8301 909:cbb26bc654de Chris
  def repo_create_validation
8302
    unless Setting.enabled_scm.include?(self.class.name.demodulize)
8303
      errors.add(:type, :invalid)
8304
    end
8305
  end
8306 245:051f544170fe Chris
8307 1115:433d4f72a19b Chris
  def self.human_attribute_name(attribute_key_name, *args)
8308
    attr_name = attribute_key_name.to_s
8309 441:cbce1fd3b1b7 Chris
    if attr_name == "log_encoding"
8310
      attr_name = "commit_logs_encoding"
8311
    end
8312 1115:433d4f72a19b Chris
    super(attr_name, *args)
8313 441:cbce1fd3b1b7 Chris
  end
8314
8315 0:513646585e45 Chris
  # Removes leading and trailing whitespace
8316
  def url=(arg)
8317
    write_attribute(:url, arg ? arg.to_s.strip : nil)
8318
  end
8319 245:051f544170fe Chris
8320 0:513646585e45 Chris
  # Removes leading and trailing whitespace
8321
  def root_url=(arg)
8322
    write_attribute(:root_url, arg ? arg.to_s.strip : nil)
8323
  end
8324 441:cbce1fd3b1b7 Chris
8325 245:051f544170fe Chris
  def password
8326
    read_ciphered_attribute(:password)
8327
  end
8328 441:cbce1fd3b1b7 Chris
8329 245:051f544170fe Chris
  def password=(arg)
8330
    write_ciphered_attribute(:password, arg)
8331
  end
8332
8333
  def scm_adapter
8334
    self.class.scm_adapter_class
8335
  end
8336 0:513646585e45 Chris
8337
  def scm
8338 1115:433d4f72a19b Chris
    unless @scm
8339
      @scm = self.scm_adapter.new(url, root_url,
8340 245:051f544170fe Chris
                                  login, password, path_encoding)
8341 1115:433d4f72a19b Chris
      if root_url.blank? && @scm.root_url.present?
8342
        update_attribute(:root_url, @scm.root_url)
8343
      end
8344
    end
8345 0:513646585e45 Chris
    @scm
8346
  end
8347 245:051f544170fe Chris
8348 0:513646585e45 Chris
  def scm_name
8349
    self.class.scm_name
8350
  end
8351 245:051f544170fe Chris
8352 1115:433d4f72a19b Chris
  def name
8353
    if identifier.present?
8354
      identifier
8355
    elsif is_default?
8356
      l(:field_repository_is_default)
8357
    else
8358
      scm_name
8359
    end
8360
  end
8361
8362
  def identifier=(identifier)
8363
    super unless identifier_frozen?
8364
  end
8365
8366
  def identifier_frozen?
8367
    errors[:identifier].blank? && !(new_record? || identifier.blank?)
8368
  end
8369
8370
  def identifier_param
8371
    if is_default?
8372
      nil
8373
    elsif identifier.present?
8374
      identifier
8375
    else
8376
      id.to_s
8377
    end
8378
  end
8379
8380
  def <=>(repository)
8381
    if is_default?
8382
      -1
8383
    elsif repository.is_default?
8384
      1
8385
    else
8386
      identifier.to_s <=> repository.identifier.to_s
8387
    end
8388
  end
8389
8390
  def self.find_by_identifier_param(param)
8391
    if param.to_s =~ /^\d+$/
8392
      find_by_id(param)
8393
    else
8394
      find_by_identifier(param)
8395
    end
8396
  end
8397
8398 1494:e248c7af89ec Chris
  # TODO: should return an empty hash instead of nil to avoid many ||{}
8399
  def extra_info
8400
    h = read_attribute(:extra_info)
8401
    h.is_a?(Hash) ? h : nil
8402
  end
8403
8404 441:cbce1fd3b1b7 Chris
  def merge_extra_info(arg)
8405
    h = extra_info || {}
8406
    return h if arg.nil?
8407
    h.merge!(arg)
8408
    write_attribute(:extra_info, h)
8409
  end
8410
8411
  def report_last_commit
8412
    true
8413
  end
8414
8415 0:513646585e45 Chris
  def supports_cat?
8416
    scm.supports_cat?
8417
  end
8418
8419
  def supports_annotate?
8420
    scm.supports_annotate?
8421
  end
8422 441:cbce1fd3b1b7 Chris
8423
  def supports_all_revisions?
8424
    true
8425
  end
8426
8427
  def supports_directory_revisions?
8428
    false
8429
  end
8430
8431 909:cbb26bc654de Chris
  def supports_revision_graph?
8432
    false
8433
  end
8434
8435 0:513646585e45 Chris
  def entry(path=nil, identifier=nil)
8436
    scm.entry(path, identifier)
8437
  end
8438 441:cbce1fd3b1b7 Chris
8439 1517:dffacf8a6908 Chris
  def scm_entries(path=nil, identifier=nil)
8440
    scm.entries(path, identifier)
8441
  end
8442
  protected :scm_entries
8443
8444 0:513646585e45 Chris
  def entries(path=nil, identifier=nil)
8445 1517:dffacf8a6908 Chris
    entries = scm_entries(path, identifier)
8446 1115:433d4f72a19b Chris
    load_entries_changesets(entries)
8447
    entries
8448 0:513646585e45 Chris
  end
8449
8450
  def branches
8451
    scm.branches
8452
  end
8453
8454
  def tags
8455
    scm.tags
8456
  end
8457
8458
  def default_branch
8459 507:0c939c159af4 Chris
    nil
8460 0:513646585e45 Chris
  end
8461 441:cbce1fd3b1b7 Chris
8462 0:513646585e45 Chris
  def properties(path, identifier=nil)
8463
    scm.properties(path, identifier)
8464
  end
8465 441:cbce1fd3b1b7 Chris
8466 0:513646585e45 Chris
  def cat(path, identifier=nil)
8467
    scm.cat(path, identifier)
8468
  end
8469 441:cbce1fd3b1b7 Chris
8470 0:513646585e45 Chris
  def diff(path, rev, rev_to)
8471
    scm.diff(path, rev, rev_to)
8472
  end
8473 119:8661b858af72 Chris
8474
  def diff_format_revisions(cs, cs_to, sep=':')
8475
    text = ""
8476
    text << cs_to.format_identifier + sep if cs_to
8477
    text << cs.format_identifier if cs
8478
    text
8479
  end
8480
8481 0:513646585e45 Chris
  # Returns a path relative to the url of the repository
8482
  def relative_path(path)
8483
    path
8484
  end
8485 128:07fa8a8b56a8 Chris
8486 0:513646585e45 Chris
  # Finds and returns a revision with a number or the beginning of a hash
8487
  def find_changeset_by_name(name)
8488 128:07fa8a8b56a8 Chris
    return nil if name.blank?
8489 1115:433d4f72a19b Chris
    s = name.to_s
8490 1464:261b3d9a4903 Chris
    if s.match(/^\d*$/)
8491
      changesets.where("revision = ?", s).first
8492
    else
8493
      changesets.where("revision LIKE ?", s + '%').first
8494
    end
8495 0:513646585e45 Chris
  end
8496 128:07fa8a8b56a8 Chris
8497 0:513646585e45 Chris
  def latest_changeset
8498 1464:261b3d9a4903 Chris
    @latest_changeset ||= changesets.first
8499 0:513646585e45 Chris
  end
8500
8501
  # Returns the latest changesets for +path+
8502
  # Default behaviour is to search in cached changesets
8503
  def latest_changesets(path, rev, limit=10)
8504
    if path.blank?
8505 1464:261b3d9a4903 Chris
      changesets.
8506
        reorder("#{Changeset.table_name}.committed_on DESC, #{Changeset.table_name}.id DESC").
8507
        limit(limit).
8508
        preload(:user).
8509
        all
8510 0:513646585e45 Chris
    else
8511 1464:261b3d9a4903 Chris
      filechanges.
8512
        where("path = ?", path.with_leading_slash).
8513
        reorder("#{Changeset.table_name}.committed_on DESC, #{Changeset.table_name}.id DESC").
8514
        limit(limit).
8515
        preload(:changeset => :user).
8516
        collect(&:changeset)
8517 0:513646585e45 Chris
    end
8518
  end
8519 441:cbce1fd3b1b7 Chris
8520 0:513646585e45 Chris
  def scan_changesets_for_issue_ids
8521
    self.changesets.each(&:scan_comment_for_issue_ids)
8522
  end
8523
8524
  # Returns an array of committers usernames and associated user_id
8525
  def committers
8526 441:cbce1fd3b1b7 Chris
    @committers ||= Changeset.connection.select_rows(
8527
         "SELECT DISTINCT committer, user_id FROM #{Changeset.table_name} WHERE repository_id = #{id}")
8528 0:513646585e45 Chris
  end
8529 441:cbce1fd3b1b7 Chris
8530 0:513646585e45 Chris
  # Maps committers username to a user ids
8531
  def committer_ids=(h)
8532
    if h.is_a?(Hash)
8533
      committers.each do |committer, user_id|
8534
        new_user_id = h[committer]
8535
        if new_user_id && (new_user_id.to_i != user_id.to_i)
8536
          new_user_id = (new_user_id.to_i > 0 ? new_user_id.to_i : nil)
8537 1517:dffacf8a6908 Chris
          Changeset.where(["repository_id = ? AND committer = ?", id, committer]).
8538
            update_all("user_id = #{new_user_id.nil? ? 'NULL' : new_user_id}")
8539 0:513646585e45 Chris
        end
8540
      end
8541 441:cbce1fd3b1b7 Chris
      @committers            = nil
8542 0:513646585e45 Chris
      @found_committer_users = nil
8543
      true
8544
    else
8545
      false
8546
    end
8547
  end
8548 441:cbce1fd3b1b7 Chris
8549 0:513646585e45 Chris
  # Returns the Redmine User corresponding to the given +committer+
8550
  # It will return nil if the committer is not yet mapped and if no User
8551
  # with the same username or email was found
8552
  def find_committer_user(committer)
8553
    unless committer.blank?
8554
      @found_committer_users ||= {}
8555
      return @found_committer_users[committer] if @found_committer_users.has_key?(committer)
8556 441:cbce1fd3b1b7 Chris
8557 0:513646585e45 Chris
      user = nil
8558 1464:261b3d9a4903 Chris
      c = changesets.where(:committer => committer).includes(:user).first
8559 0:513646585e45 Chris
      if c && c.user
8560
        user = c.user
8561
      elsif committer.strip =~ /^([^<]+)(<(.*)>)?$/
8562
        username, email = $1.strip, $3
8563
        u = User.find_by_login(username)
8564 1532:a0460a3d154f Chris
        if u.nil?
8565
          if email.blank?
8566
            if username.strip =~ /^([^ ]+) ([^ ]+)$/
8567
              first, last = $1, $2
8568 1533:59e13100ea95 Chris
              uu = User.where(:firstname => first, :lastname => last)
8569
              if uu.length == 1
8570
                u = uu.first
8571
              else
8572
                logger.warn "find_committer_user: found more than one (#{uu.length}) results for user named #{username}, ignoring"
8573
              end
8574 1532:a0460a3d154f Chris
            end
8575
          else
8576
            u = User.find_by_mail(email)
8577
          end
8578
        end
8579 0:513646585e45 Chris
        user = u
8580
      end
8581
      @found_committer_users[committer] = user
8582
      user
8583
    end
8584
  end
8585 245:051f544170fe Chris
8586
  def repo_log_encoding
8587
    encoding = log_encoding.to_s.strip
8588
    encoding.blank? ? 'UTF-8' : encoding
8589
  end
8590
8591 0:513646585e45 Chris
  # Fetches new changesets for all repositories of active projects
8592
  # Can be called periodically by an external script
8593
  # eg. ruby script/runner "Repository.fetch_changesets"
8594
  def self.fetch_changesets
8595 1115:433d4f72a19b Chris
    Project.active.has_module(:repository).all.each do |project|
8596
      project.repositories.each do |repository|
8597 245:051f544170fe Chris
        begin
8598 1115:433d4f72a19b Chris
          repository.fetch_changesets
8599 245:051f544170fe Chris
        rescue Redmine::Scm::Adapters::CommandFailed => e
8600
          logger.error "scm: error during fetching changesets: #{e.message}"
8601
        end
8602 0:513646585e45 Chris
      end
8603
    end
8604
  end
8605 245:051f544170fe Chris
8606 0:513646585e45 Chris
  # scan changeset comments to find related and fixed issues for all repositories
8607
  def self.scan_changesets_for_issue_ids
8608 1464:261b3d9a4903 Chris
    all.each(&:scan_changesets_for_issue_ids)
8609 0:513646585e45 Chris
  end
8610
8611
  def self.scm_name
8612
    'Abstract'
8613
  end
8614 441:cbce1fd3b1b7 Chris
8615 0:513646585e45 Chris
  def self.available_scm
8616
    subclasses.collect {|klass| [klass.scm_name, klass.name]}
8617
  end
8618 245:051f544170fe Chris
8619 0:513646585e45 Chris
  def self.factory(klass_name, *args)
8620
    klass = "Repository::#{klass_name}".constantize
8621
    klass.new(*args)
8622
  rescue
8623
    nil
8624
  end
8625 245:051f544170fe Chris
8626 437:102056ec2de9 chris
  def clear_cache
8627
    clear_changesets
8628
  end
8629 443:350acce374a2 Chris
8630 245:051f544170fe Chris
  def self.scm_adapter_class
8631
    nil
8632
  end
8633
8634
  def self.scm_command
8635
    ret = ""
8636
    begin
8637
      ret = self.scm_adapter_class.client_command if self.scm_adapter_class
8638 441:cbce1fd3b1b7 Chris
    rescue Exception => e
8639 245:051f544170fe Chris
      logger.error "scm: error during get command: #{e.message}"
8640
    end
8641
    ret
8642
  end
8643
8644
  def self.scm_version_string
8645
    ret = ""
8646
    begin
8647
      ret = self.scm_adapter_class.client_version_string if self.scm_adapter_class
8648 441:cbce1fd3b1b7 Chris
    rescue Exception => e
8649 245:051f544170fe Chris
      logger.error "scm: error during get version string: #{e.message}"
8650
    end
8651
    ret
8652
  end
8653
8654
  def self.scm_available
8655
    ret = false
8656
    begin
8657 441:cbce1fd3b1b7 Chris
      ret = self.scm_adapter_class.client_available if self.scm_adapter_class
8658
    rescue Exception => e
8659 245:051f544170fe Chris
      logger.error "scm: error during get scm available: #{e.message}"
8660
    end
8661
    ret
8662
  end
8663
8664 1115:433d4f72a19b Chris
  def set_as_default?
8665 1464:261b3d9a4903 Chris
    new_record? && project && Repository.where(:project_id => project.id).empty?
8666 1115:433d4f72a19b Chris
  end
8667
8668
  protected
8669
8670
  def check_default
8671
    if !is_default? && set_as_default?
8672
      self.is_default = true
8673
    end
8674
    if is_default? && is_default_changed?
8675 1517:dffacf8a6908 Chris
      Repository.where(["project_id = ?", project_id]).update_all(["is_default = ?", false])
8676 1115:433d4f72a19b Chris
    end
8677
  end
8678
8679
  def load_entries_changesets(entries)
8680
    if entries
8681
      entries.each do |entry|
8682
        if entry.lastrev && entry.lastrev.identifier
8683
          entry.changeset = find_changeset_by_name(entry.lastrev.identifier)
8684
        end
8685
      end
8686
    end
8687
  end
8688
8689 0:513646585e45 Chris
  private
8690 245:051f544170fe Chris
8691 1115:433d4f72a19b Chris
  # Deletes repository data
8692 0:513646585e45 Chris
  def clear_changesets
8693 1115:433d4f72a19b Chris
    cs = Changeset.table_name
8694
    ch = Change.table_name
8695
    ci = "#{table_name_prefix}changesets_issues#{table_name_suffix}"
8696
    cp = "#{table_name_prefix}changeset_parents#{table_name_suffix}"
8697
8698 0:513646585e45 Chris
    connection.delete("DELETE FROM #{ch} WHERE #{ch}.changeset_id IN (SELECT #{cs}.id FROM #{cs} WHERE #{cs}.repository_id = #{id})")
8699
    connection.delete("DELETE FROM #{ci} WHERE #{ci}.changeset_id IN (SELECT #{cs}.id FROM #{cs} WHERE #{cs}.repository_id = #{id})")
8700 1115:433d4f72a19b Chris
    connection.delete("DELETE FROM #{cp} WHERE #{cp}.changeset_id IN (SELECT #{cs}.id FROM #{cs} WHERE #{cs}.repository_id = #{id})")
8701 0:513646585e45 Chris
    connection.delete("DELETE FROM #{cs} WHERE #{cs}.repository_id = #{id}")
8702 1115:433d4f72a19b Chris
    clear_extra_info_of_changesets
8703
  end
8704
8705
  def clear_extra_info_of_changesets
8706 0:513646585e45 Chris
  end
8707
end
8708 441:cbce1fd3b1b7 Chris
# Redmine - project management software
8709 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
8710 0:513646585e45 Chris
#
8711
# This program is free software; you can redistribute it and/or
8712
# modify it under the terms of the GNU General Public License
8713
# as published by the Free Software Foundation; either version 2
8714
# of the License, or (at your option) any later version.
8715 441:cbce1fd3b1b7 Chris
#
8716 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
8717
# but WITHOUT ANY WARRANTY; without even the implied warranty of
8718
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
8719
# GNU General Public License for more details.
8720 441:cbce1fd3b1b7 Chris
#
8721 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
8722
# along with this program; if not, write to the Free Software
8723
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
8724
8725 1136:51d7f3e06556 chris
require_dependency 'redmine/scm/adapters/bazaar_adapter'
8726 0:513646585e45 Chris
8727
class Repository::Bazaar < Repository
8728
  attr_protected :root_url
8729 245:051f544170fe Chris
  validates_presence_of :url, :log_encoding
8730 0:513646585e45 Chris
8731 1115:433d4f72a19b Chris
  def self.human_attribute_name(attribute_key_name, *args)
8732
    attr_name = attribute_key_name.to_s
8733 441:cbce1fd3b1b7 Chris
    if attr_name == "url"
8734
      attr_name = "path_to_repository"
8735
    end
8736 1115:433d4f72a19b Chris
    super(attr_name, *args)
8737 245:051f544170fe Chris
  end
8738
8739
  def self.scm_adapter_class
8740 0:513646585e45 Chris
    Redmine::Scm::Adapters::BazaarAdapter
8741
  end
8742 245:051f544170fe Chris
8743 0:513646585e45 Chris
  def self.scm_name
8744
    'Bazaar'
8745
  end
8746 245:051f544170fe Chris
8747 1115:433d4f72a19b Chris
  def entry(path=nil, identifier=nil)
8748
    scm.bzr_path_encodig = log_encoding
8749
    scm.entry(path, identifier)
8750
  end
8751
8752
  def cat(path, identifier=nil)
8753
    scm.bzr_path_encodig = log_encoding
8754
    scm.cat(path, identifier)
8755
  end
8756
8757
  def annotate(path, identifier=nil)
8758
    scm.bzr_path_encodig = log_encoding
8759
    scm.annotate(path, identifier)
8760
  end
8761
8762
  def diff(path, rev, rev_to)
8763
    scm.bzr_path_encodig = log_encoding
8764
    scm.diff(path, rev, rev_to)
8765
  end
8766
8767 1517:dffacf8a6908 Chris
  def scm_entries(path=nil, identifier=nil)
8768 1115:433d4f72a19b Chris
    scm.bzr_path_encodig = log_encoding
8769 0:513646585e45 Chris
    entries = scm.entries(path, identifier)
8770
    if entries
8771
      entries.each do |e|
8772
        next if e.lastrev.revision.blank?
8773
        # Set the filesize unless browsing a specific revision
8774
        if identifier.nil? && e.is_file?
8775
          full_path = File.join(root_url, e.path)
8776
          e.size = File.stat(full_path).size if File.file?(full_path)
8777
        end
8778 1464:261b3d9a4903 Chris
        c = Change.
8779
              includes(:changeset).
8780
              where("#{Change.table_name}.revision = ? and #{Changeset.table_name}.repository_id = ?", e.lastrev.revision, id).
8781
              order("#{Changeset.table_name}.revision DESC").
8782
              first
8783 0:513646585e45 Chris
        if c
8784
          e.lastrev.identifier = c.changeset.revision
8785 441:cbce1fd3b1b7 Chris
          e.lastrev.name       = c.changeset.revision
8786
          e.lastrev.author     = c.changeset.committer
8787 0:513646585e45 Chris
        end
8788
      end
8789
    end
8790 1115:433d4f72a19b Chris
    entries
8791 0:513646585e45 Chris
  end
8792 1517:dffacf8a6908 Chris
  protected :scm_entries
8793 441:cbce1fd3b1b7 Chris
8794 0:513646585e45 Chris
  def fetch_changesets
8795 1115:433d4f72a19b Chris
    scm.bzr_path_encodig = log_encoding
8796 0:513646585e45 Chris
    scm_info = scm.info
8797
    if scm_info
8798
      # latest revision found in database
8799
      db_revision = latest_changeset ? latest_changeset.revision.to_i : 0
8800
      # latest revision in the repository
8801
      scm_revision = scm_info.lastrev.identifier.to_i
8802
      if db_revision < scm_revision
8803
        logger.debug "Fetching changesets for repository #{url}" if logger && logger.debug?
8804
        identifier_from = db_revision + 1
8805
        while (identifier_from <= scm_revision)
8806
          # loads changesets by batches of 200
8807
          identifier_to = [identifier_from + 199, scm_revision].min
8808 1115:433d4f72a19b Chris
          revisions = scm.revisions('', identifier_to, identifier_from)
8809 0:513646585e45 Chris
          transaction do
8810
            revisions.reverse_each do |revision|
8811 441:cbce1fd3b1b7 Chris
              changeset = Changeset.create(:repository   => self,
8812
                                           :revision     => revision.identifier,
8813
                                           :committer    => revision.author,
8814 0:513646585e45 Chris
                                           :committed_on => revision.time,
8815 441:cbce1fd3b1b7 Chris
                                           :scmid        => revision.scmid,
8816
                                           :comments     => revision.message)
8817
8818 0:513646585e45 Chris
              revision.paths.each do |change|
8819
                Change.create(:changeset => changeset,
8820 441:cbce1fd3b1b7 Chris
                              :action    => change[:action],
8821
                              :path      => change[:path],
8822
                              :revision  => change[:revision])
8823 0:513646585e45 Chris
              end
8824
            end
8825
          end unless revisions.nil?
8826
          identifier_from = identifier_to + 1
8827
        end
8828
      end
8829
    end
8830
  end
8831
end
8832 441:cbce1fd3b1b7 Chris
# Redmine - project management software
8833 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
8834 0:513646585e45 Chris
#
8835
# This program is free software; you can redistribute it and/or
8836
# modify it under the terms of the GNU General Public License
8837
# as published by the Free Software Foundation; either version 2
8838
# of the License, or (at your option) any later version.
8839 441:cbce1fd3b1b7 Chris
#
8840 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
8841
# but WITHOUT ANY WARRANTY; without even the implied warranty of
8842
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
8843
# GNU General Public License for more details.
8844 441:cbce1fd3b1b7 Chris
#
8845 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
8846
# along with this program; if not, write to the Free Software
8847
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
8848
8849 1136:51d7f3e06556 chris
require_dependency 'redmine/scm/adapters/cvs_adapter'
8850 0:513646585e45 Chris
require 'digest/sha1'
8851
8852
class Repository::Cvs < Repository
8853 245:051f544170fe Chris
  validates_presence_of :url, :root_url, :log_encoding
8854 0:513646585e45 Chris
8855 1115:433d4f72a19b Chris
  safe_attributes 'root_url',
8856
    :if => lambda {|repository, user| repository.new_record?}
8857
8858
  def self.human_attribute_name(attribute_key_name, *args)
8859
    attr_name = attribute_key_name.to_s
8860 441:cbce1fd3b1b7 Chris
    if attr_name == "root_url"
8861
      attr_name = "cvsroot"
8862
    elsif attr_name == "url"
8863
      attr_name = "cvs_module"
8864
    end
8865 1115:433d4f72a19b Chris
    super(attr_name, *args)
8866 245:051f544170fe Chris
  end
8867
8868
  def self.scm_adapter_class
8869 0:513646585e45 Chris
    Redmine::Scm::Adapters::CvsAdapter
8870
  end
8871 245:051f544170fe Chris
8872 0:513646585e45 Chris
  def self.scm_name
8873
    'CVS'
8874
  end
8875 245:051f544170fe Chris
8876 0:513646585e45 Chris
  def entry(path=nil, identifier=nil)
8877
    rev = identifier.nil? ? nil : changesets.find_by_revision(identifier)
8878
    scm.entry(path, rev.nil? ? nil : rev.committed_on)
8879
  end
8880 441:cbce1fd3b1b7 Chris
8881 1517:dffacf8a6908 Chris
  def scm_entries(path=nil, identifier=nil)
8882 441:cbce1fd3b1b7 Chris
    rev = nil
8883
    if ! identifier.nil?
8884
      rev = changesets.find_by_revision(identifier)
8885
      return nil if rev.nil?
8886
    end
8887 0:513646585e45 Chris
    entries = scm.entries(path, rev.nil? ? nil : rev.committed_on)
8888
    if entries
8889
      entries.each() do |entry|
8890 441:cbce1fd3b1b7 Chris
        if ( ! entry.lastrev.nil? ) && ( ! entry.lastrev.revision.nil? )
8891 1115:433d4f72a19b Chris
          change = filechanges.find_by_revision_and_path(
8892 441:cbce1fd3b1b7 Chris
                     entry.lastrev.revision,
8893
                     scm.with_leading_slash(entry.path) )
8894 0:513646585e45 Chris
          if change
8895 441:cbce1fd3b1b7 Chris
            entry.lastrev.identifier = change.changeset.revision
8896
            entry.lastrev.revision   = change.changeset.revision
8897
            entry.lastrev.author     = change.changeset.committer
8898
            # entry.lastrev.branch     = change.branch
8899 0:513646585e45 Chris
          end
8900
        end
8901
      end
8902
    end
8903
    entries
8904
  end
8905 1517:dffacf8a6908 Chris
  protected :scm_entries
8906 441:cbce1fd3b1b7 Chris
8907 0:513646585e45 Chris
  def cat(path, identifier=nil)
8908 441:cbce1fd3b1b7 Chris
    rev = nil
8909
    if ! identifier.nil?
8910
      rev = changesets.find_by_revision(identifier)
8911
      return nil if rev.nil?
8912
    end
8913 0:513646585e45 Chris
    scm.cat(path, rev.nil? ? nil : rev.committed_on)
8914
  end
8915 441:cbce1fd3b1b7 Chris
8916
  def annotate(path, identifier=nil)
8917
    rev = nil
8918
    if ! identifier.nil?
8919
      rev = changesets.find_by_revision(identifier)
8920
      return nil if rev.nil?
8921
    end
8922
    scm.annotate(path, rev.nil? ? nil : rev.committed_on)
8923
  end
8924
8925 0:513646585e45 Chris
  def diff(path, rev, rev_to)
8926 441:cbce1fd3b1b7 Chris
    # convert rev to revision. CVS can't handle changesets here
8927 0:513646585e45 Chris
    diff=[]
8928 441:cbce1fd3b1b7 Chris
    changeset_from = changesets.find_by_revision(rev)
8929
    if rev_to.to_i > 0
8930
      changeset_to = changesets.find_by_revision(rev_to)
8931 0:513646585e45 Chris
    end
8932 1115:433d4f72a19b Chris
    changeset_from.filechanges.each() do |change_from|
8933 441:cbce1fd3b1b7 Chris
      revision_from = nil
8934
      revision_to   = nil
8935
      if path.nil? || (change_from.path.starts_with? scm.with_leading_slash(path))
8936
        revision_from = change_from.revision
8937
      end
8938 0:513646585e45 Chris
      if revision_from
8939
        if changeset_to
8940 1115:433d4f72a19b Chris
          changeset_to.filechanges.each() do |change_to|
8941 441:cbce1fd3b1b7 Chris
            revision_to = change_to.revision if change_to.path == change_from.path
8942 0:513646585e45 Chris
          end
8943
        end
8944
        unless revision_to
8945 441:cbce1fd3b1b7 Chris
          revision_to = scm.get_previous_revision(revision_from)
8946 0:513646585e45 Chris
        end
8947
        file_diff = scm.diff(change_from.path, revision_from, revision_to)
8948
        diff = diff + file_diff unless file_diff.nil?
8949
      end
8950
    end
8951
    return diff
8952
  end
8953 441:cbce1fd3b1b7 Chris
8954 0:513646585e45 Chris
  def fetch_changesets
8955
    # some nifty bits to introduce a commit-id with cvs
8956 441:cbce1fd3b1b7 Chris
    # natively cvs doesn't provide any kind of changesets,
8957
    # there is only a revision per file.
8958 0:513646585e45 Chris
    # we now take a guess using the author, the commitlog and the commit-date.
8959 441:cbce1fd3b1b7 Chris
8960
    # last one is the next step to take. the commit-date is not equal for all
8961 0:513646585e45 Chris
    # commits in one changeset. cvs update the commit-date when the *,v file was touched. so
8962
    # we use a small delta here, to merge all changes belonging to _one_ changeset
8963 441:cbce1fd3b1b7 Chris
    time_delta  = 10.seconds
8964 0:513646585e45 Chris
    fetch_since = latest_changeset ? latest_changeset.committed_on : nil
8965
    transaction do
8966
      tmp_rev_num = 1
8967 441:cbce1fd3b1b7 Chris
      scm.revisions('', fetch_since, nil, :log_encoding => repo_log_encoding) do |revision|
8968 0:513646585e45 Chris
        # only add the change to the database, if it doen't exists. the cvs log
8969 441:cbce1fd3b1b7 Chris
        # is not exclusive at all.
8970 210:0579821a129a Chris
        tmp_time = revision.time.clone
8971 1115:433d4f72a19b Chris
        unless filechanges.find_by_path_and_revision(
8972 441:cbce1fd3b1b7 Chris
	                         scm.with_leading_slash(revision.paths[0][:path]),
8973
	                         revision.paths[0][:revision]
8974
	                           )
8975 245:051f544170fe Chris
          cmt = Changeset.normalize_comments(revision.message, repo_log_encoding)
8976 441:cbce1fd3b1b7 Chris
          author_utf8 = Changeset.to_utf8(revision.author, repo_log_encoding)
8977 1464:261b3d9a4903 Chris
          cs  = changesets.where(
8978
                  :committed_on => tmp_time - time_delta .. tmp_time + time_delta,
8979
                  :committer    => author_utf8,
8980
                  :comments     => cmt
8981
                ).first
8982 441:cbce1fd3b1b7 Chris
          # create a new changeset....
8983 0:513646585e45 Chris
          unless cs
8984
            # we use a temporaray revision number here (just for inserting)
8985
            # later on, we calculate a continous positive number
8986 210:0579821a129a Chris
            tmp_time2 = tmp_time.clone.gmtime
8987 441:cbce1fd3b1b7 Chris
            branch    = revision.paths[0][:branch]
8988
            scmid     = branch + "-" + tmp_time2.strftime("%Y%m%d-%H%M%S")
8989
            cs = Changeset.create(:repository   => self,
8990
                                  :revision     => "tmp#{tmp_rev_num}",
8991
                                  :scmid        => scmid,
8992
                                  :committer    => revision.author,
8993 210:0579821a129a Chris
                                  :committed_on => tmp_time,
8994 441:cbce1fd3b1b7 Chris
                                  :comments     => revision.message)
8995 0:513646585e45 Chris
            tmp_rev_num += 1
8996
          end
8997 441:cbce1fd3b1b7 Chris
          # convert CVS-File-States to internal Action-abbrevations
8998
          # default action is (M)odified
8999
          action = "M"
9000
          if revision.paths[0][:action] == "Exp" && revision.paths[0][:revision] == "1.1"
9001
            action = "A" # add-action always at first revision (= 1.1)
9002
          elsif revision.paths[0][:action] == "dead"
9003
            action = "D" # dead-state is similar to Delete
9004 0:513646585e45 Chris
          end
9005 441:cbce1fd3b1b7 Chris
          Change.create(
9006
             :changeset => cs,
9007
             :action    => action,
9008
             :path      => scm.with_leading_slash(revision.paths[0][:path]),
9009
             :revision  => revision.paths[0][:revision],
9010
             :branch    => revision.paths[0][:branch]
9011
              )
9012 0:513646585e45 Chris
        end
9013
      end
9014 441:cbce1fd3b1b7 Chris
9015 0:513646585e45 Chris
      # Renumber new changesets in chronological order
9016 1464:261b3d9a4903 Chris
      Changeset.
9017
        order('committed_on ASC, id ASC').
9018
        where("repository_id = ? AND revision LIKE 'tmp%'", id).
9019
        each do |changeset|
9020 1517:dffacf8a6908 Chris
          changeset.update_attribute :revision, next_revision_number
9021 0:513646585e45 Chris
      end
9022
    end # transaction
9023 210:0579821a129a Chris
    @current_revision_number = nil
9024 0:513646585e45 Chris
  end
9025 441:cbce1fd3b1b7 Chris
9026 0:513646585e45 Chris
  private
9027 441:cbce1fd3b1b7 Chris
9028 0:513646585e45 Chris
  # Returns the next revision number to assign to a CVS changeset
9029
  def next_revision_number
9030
    # Need to retrieve existing revision numbers to sort them as integers
9031 210:0579821a129a Chris
    sql = "SELECT revision FROM #{Changeset.table_name} "
9032
    sql << "WHERE repository_id = #{id} AND revision NOT LIKE 'tmp%'"
9033
    @current_revision_number ||= (connection.select_values(sql).collect(&:to_i).max || 0)
9034 0:513646585e45 Chris
    @current_revision_number += 1
9035
  end
9036
end
9037 441:cbce1fd3b1b7 Chris
# Redmine - project management software
9038 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
9039 0:513646585e45 Chris
#
9040
# This program is free software; you can redistribute it and/or
9041
# modify it under the terms of the GNU General Public License
9042
# as published by the Free Software Foundation; either version 2
9043
# of the License, or (at your option) any later version.
9044 441:cbce1fd3b1b7 Chris
#
9045 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
9046
# but WITHOUT ANY WARRANTY; without even the implied warranty of
9047
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
9048
# GNU General Public License for more details.
9049 441:cbce1fd3b1b7 Chris
#
9050 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
9051
# along with this program; if not, write to the Free Software
9052
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
9053
9054 1136:51d7f3e06556 chris
require_dependency 'redmine/scm/adapters/darcs_adapter'
9055 0:513646585e45 Chris
9056
class Repository::Darcs < Repository
9057 245:051f544170fe Chris
  validates_presence_of :url, :log_encoding
9058 0:513646585e45 Chris
9059 1115:433d4f72a19b Chris
  def self.human_attribute_name(attribute_key_name, *args)
9060
    attr_name = attribute_key_name.to_s
9061 441:cbce1fd3b1b7 Chris
    if attr_name == "url"
9062
      attr_name = "path_to_repository"
9063
    end
9064 1115:433d4f72a19b Chris
    super(attr_name, *args)
9065 245:051f544170fe Chris
  end
9066
9067
  def self.scm_adapter_class
9068 0:513646585e45 Chris
    Redmine::Scm::Adapters::DarcsAdapter
9069
  end
9070 245:051f544170fe Chris
9071 0:513646585e45 Chris
  def self.scm_name
9072
    'Darcs'
9073
  end
9074 245:051f544170fe Chris
9075 441:cbce1fd3b1b7 Chris
  def supports_directory_revisions?
9076
    true
9077
  end
9078
9079 0:513646585e45 Chris
  def entry(path=nil, identifier=nil)
9080
    patch = identifier.nil? ? nil : changesets.find_by_revision(identifier)
9081
    scm.entry(path, patch.nil? ? nil : patch.scmid)
9082
  end
9083 441:cbce1fd3b1b7 Chris
9084 1517:dffacf8a6908 Chris
  def scm_entries(path=nil, identifier=nil)
9085 441:cbce1fd3b1b7 Chris
    patch = nil
9086
    if ! identifier.nil?
9087
      patch = changesets.find_by_revision(identifier)
9088
      return nil if patch.nil?
9089
    end
9090 0:513646585e45 Chris
    entries = scm.entries(path, patch.nil? ? nil : patch.scmid)
9091
    if entries
9092
      entries.each do |entry|
9093
        # Search the DB for the entry's last change
9094 441:cbce1fd3b1b7 Chris
        if entry.lastrev && !entry.lastrev.scmid.blank?
9095
          changeset = changesets.find_by_scmid(entry.lastrev.scmid)
9096
        end
9097 0:513646585e45 Chris
        if changeset
9098
          entry.lastrev.identifier = changeset.revision
9099 441:cbce1fd3b1b7 Chris
          entry.lastrev.name       = changeset.revision
9100
          entry.lastrev.time       = changeset.committed_on
9101
          entry.lastrev.author     = changeset.committer
9102 0:513646585e45 Chris
        end
9103
      end
9104
    end
9105
    entries
9106
  end
9107 1517:dffacf8a6908 Chris
  protected :scm_entries
9108 441:cbce1fd3b1b7 Chris
9109 0:513646585e45 Chris
  def cat(path, identifier=nil)
9110
    patch = identifier.nil? ? nil : changesets.find_by_revision(identifier.to_s)
9111
    scm.cat(path, patch.nil? ? nil : patch.scmid)
9112
  end
9113 441:cbce1fd3b1b7 Chris
9114 0:513646585e45 Chris
  def diff(path, rev, rev_to)
9115
    patch_from = changesets.find_by_revision(rev)
9116
    return nil if patch_from.nil?
9117
    patch_to = changesets.find_by_revision(rev_to) if rev_to
9118
    if path.blank?
9119 1115:433d4f72a19b Chris
      path = patch_from.filechanges.collect{|change| change.path}.join(' ')
9120 0:513646585e45 Chris
    end
9121
    patch_from ? scm.diff(path, patch_from.scmid, patch_to ? patch_to.scmid : nil) : nil
9122
  end
9123 441:cbce1fd3b1b7 Chris
9124 0:513646585e45 Chris
  def fetch_changesets
9125
    scm_info = scm.info
9126
    if scm_info
9127
      db_last_id = latest_changeset ? latest_changeset.scmid : nil
9128 441:cbce1fd3b1b7 Chris
      next_rev   = latest_changeset ? latest_changeset.revision.to_i + 1 : 1
9129 0:513646585e45 Chris
      # latest revision in the repository
9130 441:cbce1fd3b1b7 Chris
      scm_revision = scm_info.lastrev.scmid
9131 0:513646585e45 Chris
      unless changesets.find_by_scmid(scm_revision)
9132
        revisions = scm.revisions('', db_last_id, nil, :with_path => true)
9133
        transaction do
9134
          revisions.reverse_each do |revision|
9135 441:cbce1fd3b1b7 Chris
            changeset = Changeset.create(:repository   => self,
9136
                                         :revision     => next_rev,
9137
                                         :scmid        => revision.scmid,
9138
                                         :committer    => revision.author,
9139 0:513646585e45 Chris
                                         :committed_on => revision.time,
9140 441:cbce1fd3b1b7 Chris
                                         :comments     => revision.message)
9141 0:513646585e45 Chris
            revision.paths.each do |change|
9142
              changeset.create_change(change)
9143
            end
9144
            next_rev += 1
9145
          end if revisions
9146
        end
9147
      end
9148
    end
9149
  end
9150
end
9151 441:cbce1fd3b1b7 Chris
# Redmine - project management software
9152 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
9153 0:513646585e45 Chris
#
9154
# FileSystem adapter
9155
# File written by Paul Rivier, at Demotera.
9156
#
9157
# This program is free software; you can redistribute it and/or
9158
# modify it under the terms of the GNU General Public License
9159
# as published by the Free Software Foundation; either version 2
9160
# of the License, or (at your option) any later version.
9161 441:cbce1fd3b1b7 Chris
#
9162 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
9163
# but WITHOUT ANY WARRANTY; without even the implied warranty of
9164
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
9165
# GNU General Public License for more details.
9166 441:cbce1fd3b1b7 Chris
#
9167 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
9168
# along with this program; if not, write to the Free Software
9169
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
9170
9171 1136:51d7f3e06556 chris
require_dependency 'redmine/scm/adapters/filesystem_adapter'
9172 0:513646585e45 Chris
9173
class Repository::Filesystem < Repository
9174
  attr_protected :root_url
9175
  validates_presence_of :url
9176
9177 1115:433d4f72a19b Chris
  def self.human_attribute_name(attribute_key_name, *args)
9178
    attr_name = attribute_key_name.to_s
9179 441:cbce1fd3b1b7 Chris
    if attr_name == "url"
9180
      attr_name = "root_directory"
9181
    end
9182 1115:433d4f72a19b Chris
    super(attr_name, *args)
9183 245:051f544170fe Chris
  end
9184
9185
  def self.scm_adapter_class
9186 0:513646585e45 Chris
    Redmine::Scm::Adapters::FilesystemAdapter
9187
  end
9188 245:051f544170fe Chris
9189 0:513646585e45 Chris
  def self.scm_name
9190
    'Filesystem'
9191
  end
9192 245:051f544170fe Chris
9193 441:cbce1fd3b1b7 Chris
  def supports_all_revisions?
9194
    false
9195
  end
9196
9197 0:513646585e45 Chris
  def fetch_changesets
9198
    nil
9199
  end
9200
end
9201 441:cbce1fd3b1b7 Chris
# Redmine - project management software
9202 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
9203 0:513646585e45 Chris
# Copyright (C) 2007  Patrick Aljord patcito@ŋmail.com
9204 441:cbce1fd3b1b7 Chris
#
9205 0:513646585e45 Chris
# This program is free software; you can redistribute it and/or
9206
# modify it under the terms of the GNU General Public License
9207
# as published by the Free Software Foundation; either version 2
9208
# of the License, or (at your option) any later version.
9209 441:cbce1fd3b1b7 Chris
#
9210 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
9211
# but WITHOUT ANY WARRANTY; without even the implied warranty of
9212
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
9213
# GNU General Public License for more details.
9214 441:cbce1fd3b1b7 Chris
#
9215 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
9216
# along with this program; if not, write to the Free Software
9217
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
9218
9219 1136:51d7f3e06556 chris
require_dependency 'redmine/scm/adapters/git_adapter'
9220 0:513646585e45 Chris
9221
class Repository::Git < Repository
9222
  attr_protected :root_url
9223
  validates_presence_of :url
9224
9225 1115:433d4f72a19b Chris
  def self.human_attribute_name(attribute_key_name, *args)
9226
    attr_name = attribute_key_name.to_s
9227 441:cbce1fd3b1b7 Chris
    if attr_name == "url"
9228
      attr_name = "path_to_repository"
9229
    end
9230 1115:433d4f72a19b Chris
    super(attr_name, *args)
9231 245:051f544170fe Chris
  end
9232
9233
  def self.scm_adapter_class
9234 0:513646585e45 Chris
    Redmine::Scm::Adapters::GitAdapter
9235
  end
9236 245:051f544170fe Chris
9237 0:513646585e45 Chris
  def self.scm_name
9238
    'Git'
9239
  end
9240
9241 441:cbce1fd3b1b7 Chris
  def report_last_commit
9242
    extra_report_last_commit
9243
  end
9244
9245
  def extra_report_last_commit
9246
    return false if extra_info.nil?
9247
    v = extra_info["extra_report_last_commit"]
9248
    return false if v.nil?
9249
    v.to_s != '0'
9250
  end
9251
9252
  def supports_directory_revisions?
9253
    true
9254
  end
9255
9256 909:cbb26bc654de Chris
  def supports_revision_graph?
9257
    true
9258
  end
9259
9260 245:051f544170fe Chris
  def repo_log_encoding
9261
    'UTF-8'
9262
  end
9263
9264 117:af80e5618e9b Chris
  # Returns the identifier for the given git changeset
9265
  def self.changeset_identifier(changeset)
9266
    changeset.scmid
9267
  end
9268
9269
  # Returns the readable identifier for the given git changeset
9270
  def self.format_changeset_identifier(changeset)
9271
    changeset.revision[0, 8]
9272
  end
9273
9274 0:513646585e45 Chris
  def branches
9275
    scm.branches
9276
  end
9277
9278
  def tags
9279
    scm.tags
9280
  end
9281
9282 507:0c939c159af4 Chris
  def default_branch
9283
    scm.default_branch
9284 909:cbb26bc654de Chris
  rescue Exception => e
9285
    logger.error "git: error during get default branch: #{e.message}"
9286
    nil
9287 507:0c939c159af4 Chris
  end
9288
9289 245:051f544170fe Chris
  def find_changeset_by_name(name)
9290 1115:433d4f72a19b Chris
    if name.present?
9291
      changesets.where(:revision => name.to_s).first ||
9292
        changesets.where('scmid LIKE ?', "#{name}%").first
9293
    end
9294 245:051f544170fe Chris
  end
9295
9296 1517:dffacf8a6908 Chris
  def scm_entries(path=nil, identifier=nil)
9297
    scm.entries(path, identifier, :report_last_commit => extra_report_last_commit)
9298 441:cbce1fd3b1b7 Chris
  end
9299 1517:dffacf8a6908 Chris
  protected :scm_entries
9300 441:cbce1fd3b1b7 Chris
9301 909:cbb26bc654de Chris
  # With SCMs that have a sequential commit numbering,
9302
  # such as Subversion and Mercurial,
9303
  # Redmine is able to be clever and only fetch changesets
9304
  # going forward from the most recent one it knows about.
9305 1115:433d4f72a19b Chris
  #
9306 909:cbb26bc654de Chris
  # However, Git does not have a sequential commit numbering.
9307
  #
9308
  # In order to fetch only new adding revisions,
9309 1115:433d4f72a19b Chris
  # Redmine needs to save "heads".
9310 909:cbb26bc654de Chris
  #
9311 441:cbce1fd3b1b7 Chris
  # In Git and Mercurial, revisions are not in date order.
9312
  # Redmine Mercurial fixed issues.
9313
  #    * Redmine Takes Too Long On Large Mercurial Repository
9314
  #      http://www.redmine.org/issues/3449
9315
  #    * Sorting for changesets might go wrong on Mercurial repos
9316
  #      http://www.redmine.org/issues/3567
9317
  #
9318
  # Database revision column is text, so Redmine can not sort by revision.
9319
  # Mercurial has revision number, and revision number guarantees revision order.
9320
  # Redmine Mercurial model stored revisions ordered by database id to database.
9321
  # So, Redmine Mercurial model can use correct ordering revisions.
9322
  #
9323
  # Redmine Mercurial adapter uses "hg log -r 0:tip --limit 10"
9324
  # to get limited revisions from old to new.
9325
  # But, Git 1.7.3.4 does not support --reverse with -n or --skip.
9326
  #
9327 0:513646585e45 Chris
  # The repository can still be fully reloaded by calling #clear_changesets
9328
  # before fetching changesets (eg. for offline resync)
9329
  def fetch_changesets
9330 441:cbce1fd3b1b7 Chris
    scm_brs = branches
9331
    return if scm_brs.nil? || scm_brs.empty?
9332 1115:433d4f72a19b Chris
9333 441:cbce1fd3b1b7 Chris
    h1 = extra_info || {}
9334
    h  = h1.dup
9335 1115:433d4f72a19b Chris
    repo_heads = scm_brs.map{ |br| br.scmid }
9336
    h["heads"] ||= []
9337
    prev_db_heads = h["heads"].dup
9338
    if prev_db_heads.empty?
9339
      prev_db_heads += heads_from_branches_hash
9340
    end
9341
    return if prev_db_heads.sort == repo_heads.sort
9342
9343 441:cbce1fd3b1b7 Chris
    h["db_consistent"]  ||= {}
9344
    if changesets.count == 0
9345
      h["db_consistent"]["ordering"] = 1
9346
      merge_extra_info(h)
9347
      self.save
9348
    elsif ! h["db_consistent"].has_key?("ordering")
9349
      h["db_consistent"]["ordering"] = 0
9350
      merge_extra_info(h)
9351
      self.save
9352
    end
9353 1115:433d4f72a19b Chris
    save_revisions(prev_db_heads, repo_heads)
9354
  end
9355
9356
  def save_revisions(prev_db_heads, repo_heads)
9357
    h = {}
9358
    opts = {}
9359
    opts[:reverse]  = true
9360
    opts[:excludes] = prev_db_heads
9361
    opts[:includes] = repo_heads
9362
9363
    revisions = scm.revisions('', nil, nil, opts)
9364
    return if revisions.blank?
9365
9366
    # Make the search for existing revisions in the database in a more sufficient manner
9367
    #
9368
    # Git branch is the reference to the specific revision.
9369
    # Git can *delete* remote branch and *re-push* branch.
9370
    #
9371
    #  $ git push remote :branch
9372
    #  $ git push remote branch
9373
    #
9374
    # After deleting branch, revisions remain in repository until "git gc".
9375
    # On git 1.7.2.3, default pruning date is 2 weeks.
9376
    # So, "git log --not deleted_branch_head_revision" return code is 0.
9377
    #
9378
    # After re-pushing branch, "git log" returns revisions which are saved in database.
9379
    # So, Redmine needs to scan revisions and database every time.
9380
    #
9381
    # This is replacing the one-after-one queries.
9382 1517:dffacf8a6908 Chris
    # Find all revisions, that are in the database, and then remove them
9383
    # from the revision array.
9384 1115:433d4f72a19b Chris
    # Then later we won't need any conditions for db existence.
9385 1517:dffacf8a6908 Chris
    # Query for several revisions at once, and remove them
9386
    # from the revisions array, if they are there.
9387
    # Do this in chunks, to avoid eventual memory problems
9388
    # (in case of tens of thousands of commits).
9389 1115:433d4f72a19b Chris
    # If there are no revisions (because the original code's algorithm filtered them),
9390
    # then this part will be stepped over.
9391
    # We make queries, just if there is any revision.
9392
    limit = 100
9393
    offset = 0
9394
    revisions_copy = revisions.clone # revisions will change
9395
    while offset < revisions_copy.size
9396 1464:261b3d9a4903 Chris
      scmids = revisions_copy.slice(offset, limit).map{|x| x.scmid}
9397 1517:dffacf8a6908 Chris
      recent_changesets_slice = changesets.where(:scmid => scmids)
9398 1115:433d4f72a19b Chris
      # Subtract revisions that redmine already knows about
9399
      recent_revisions = recent_changesets_slice.map{|c| c.scmid}
9400
      revisions.reject!{|r| recent_revisions.include?(r.scmid)}
9401
      offset += limit
9402
    end
9403
    revisions.each do |rev|
9404
      transaction do
9405
        # There is no search in the db for this revision, because above we ensured,
9406
        # that it's not in the db.
9407
        save_revision(rev)
9408 245:051f544170fe Chris
      end
9409
    end
9410 1115:433d4f72a19b Chris
    h["heads"] = repo_heads.dup
9411
    merge_extra_info(h)
9412
    self.save
9413 0:513646585e45 Chris
  end
9414 1115:433d4f72a19b Chris
  private :save_revisions
9415 0:513646585e45 Chris
9416 441:cbce1fd3b1b7 Chris
  def save_revision(rev)
9417 1115:433d4f72a19b Chris
    parents = (rev.parents || []).collect{|rp| find_changeset_by_name(rp)}.compact
9418
    changeset = Changeset.create(
9419 441:cbce1fd3b1b7 Chris
              :repository   => self,
9420
              :revision     => rev.identifier,
9421
              :scmid        => rev.scmid,
9422
              :committer    => rev.author,
9423
              :committed_on => rev.time,
9424 1115:433d4f72a19b Chris
              :comments     => rev.message,
9425
              :parents      => parents
9426 441:cbce1fd3b1b7 Chris
              )
9427 1115:433d4f72a19b Chris
    unless changeset.new_record?
9428
      rev.paths.each { |change| changeset.create_change(change) }
9429 441:cbce1fd3b1b7 Chris
    end
9430 909:cbb26bc654de Chris
    changeset
9431 441:cbce1fd3b1b7 Chris
  end
9432
  private :save_revision
9433
9434 1115:433d4f72a19b Chris
  def heads_from_branches_hash
9435
    h1 = extra_info || {}
9436
    h  = h1.dup
9437
    h["branches"] ||= {}
9438
    h['branches'].map{|br, hs| hs['last_scmid']}
9439
  end
9440
9441 0:513646585e45 Chris
  def latest_changesets(path,rev,limit=10)
9442
    revisions = scm.revisions(path, nil, rev, :limit => limit, :all => false)
9443
    return [] if revisions.nil? || revisions.empty?
9444 1464:261b3d9a4903 Chris
    changesets.where(:scmid => revisions.map {|c| c.scmid}).all
9445 0:513646585e45 Chris
  end
9446 1115:433d4f72a19b Chris
9447
  def clear_extra_info_of_changesets
9448
    return if extra_info.nil?
9449
    v = extra_info["extra_report_last_commit"]
9450
    write_attribute(:extra_info, nil)
9451
    h = {}
9452
    h["extra_report_last_commit"] = v
9453
    merge_extra_info(h)
9454
    self.save
9455
  end
9456
  private :clear_extra_info_of_changesets
9457 0:513646585e45 Chris
end
9458 441:cbce1fd3b1b7 Chris
# Redmine - project management software
9459 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
9460 0:513646585e45 Chris
#
9461
# This program is free software; you can redistribute it and/or
9462
# modify it under the terms of the GNU General Public License
9463
# as published by the Free Software Foundation; either version 2
9464
# of the License, or (at your option) any later version.
9465 441:cbce1fd3b1b7 Chris
#
9466 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
9467
# but WITHOUT ANY WARRANTY; without even the implied warranty of
9468
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
9469
# GNU General Public License for more details.
9470 441:cbce1fd3b1b7 Chris
#
9471 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
9472
# along with this program; if not, write to the Free Software
9473
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
9474
9475 1136:51d7f3e06556 chris
require_dependency 'redmine/scm/adapters/mercurial_adapter'
9476 0:513646585e45 Chris
9477
class Repository::Mercurial < Repository
9478 119:8661b858af72 Chris
  # sort changesets by revision number
9479 909:cbb26bc654de Chris
  has_many :changesets,
9480
           :order       => "#{Changeset.table_name}.id DESC",
9481
           :foreign_key => 'repository_id'
9482 119:8661b858af72 Chris
9483 909:cbb26bc654de Chris
  attr_protected        :root_url
9484 225:6056b3c5f8f2 luis
  # validates_presence_of :url
9485 0:513646585e45 Chris
9486 909:cbb26bc654de Chris
  # number of changesets to fetch at once
9487
  FETCH_AT_ONCE = 100
9488 245:051f544170fe Chris
9489 1115:433d4f72a19b Chris
  def self.human_attribute_name(attribute_key_name, *args)
9490
    attr_name = attribute_key_name.to_s
9491 441:cbce1fd3b1b7 Chris
    if attr_name == "url"
9492
      attr_name = "path_to_repository"
9493
    end
9494 1115:433d4f72a19b Chris
    super(attr_name, *args)
9495 245:051f544170fe Chris
  end
9496
9497
  def self.scm_adapter_class
9498 0:513646585e45 Chris
    Redmine::Scm::Adapters::MercurialAdapter
9499
  end
9500 119:8661b858af72 Chris
9501 0:513646585e45 Chris
  def self.scm_name
9502
    'Mercurial'
9503
  end
9504 119:8661b858af72 Chris
9505 441:cbce1fd3b1b7 Chris
  def supports_directory_revisions?
9506
    true
9507
  end
9508
9509 909:cbb26bc654de Chris
  def supports_revision_graph?
9510
    true
9511
  end
9512
9513 245:051f544170fe Chris
  def repo_log_encoding
9514
    'UTF-8'
9515
  end
9516
9517 119:8661b858af72 Chris
  # Returns the readable identifier for the given mercurial changeset
9518
  def self.format_changeset_identifier(changeset)
9519 1517:dffacf8a6908 Chris
    "#{changeset.revision}:#{changeset.scmid[0, 12]}"
9520 119:8661b858af72 Chris
  end
9521
9522
  # Returns the identifier for the given Mercurial changeset
9523
  def self.changeset_identifier(changeset)
9524
    changeset.scmid
9525
  end
9526
9527
  def diff_format_revisions(cs, cs_to, sep=':')
9528
    super(cs, cs_to, ' ')
9529
  end
9530
9531 1517:dffacf8a6908 Chris
  def modify_entry_lastrev_identifier(entry)
9532
    if entry.lastrev && entry.lastrev.identifier
9533
      entry.lastrev.identifier = scmid_for_inserting_db(entry.lastrev.identifier)
9534
    end
9535
  end
9536
  private :modify_entry_lastrev_identifier
9537
9538
  def entry(path=nil, identifier=nil)
9539
    entry = scm.entry(path, identifier)
9540
    return nil if entry.nil?
9541
    modify_entry_lastrev_identifier(entry)
9542
    entry
9543
  end
9544
9545
  def scm_entries(path=nil, identifier=nil)
9546
    entries = scm.entries(path, identifier)
9547
    return nil if entries.nil?
9548
    entries.each {|entry| modify_entry_lastrev_identifier(entry)}
9549
    entries
9550
  end
9551
  protected :scm_entries
9552
9553 119:8661b858af72 Chris
  # Finds and returns a revision with a number or the beginning of a hash
9554
  def find_changeset_by_name(name)
9555 1115:433d4f72a19b Chris
    return nil if name.blank?
9556
    s = name.to_s
9557
    if /[^\d]/ =~ s or s.size > 8
9558
      cs = changesets.where(:scmid => s).first
9559 119:8661b858af72 Chris
    else
9560 1115:433d4f72a19b Chris
      cs = changesets.where(:revision => s).first
9561 119:8661b858af72 Chris
    end
9562 1115:433d4f72a19b Chris
    return cs if cs
9563
    changesets.where('scmid LIKE ?', "#{s}%").first
9564 119:8661b858af72 Chris
  end
9565
9566
  # Returns the latest changesets for +path+; sorted by revision number
9567 441:cbce1fd3b1b7 Chris
  #
9568
  # Because :order => 'id DESC' is defined at 'has_many',
9569
  # there is no need to set 'order'.
9570
  # But, MySQL test fails.
9571
  # Sqlite3 and PostgreSQL pass.
9572
  # Is this MySQL bug?
9573 119:8661b858af72 Chris
  def latest_changesets(path, rev, limit=10)
9574 1464:261b3d9a4903 Chris
    changesets.
9575
      includes(:user).
9576
      where(latest_changesets_cond(path, rev, limit)).
9577
      limit(limit).
9578
      order("#{Changeset.table_name}.id DESC").
9579
      all
9580 441:cbce1fd3b1b7 Chris
  end
9581
9582 1517:dffacf8a6908 Chris
  def is_short_id_in_db?
9583
    return @is_short_id_in_db unless @is_short_id_in_db.nil?
9584
    cs = changesets.first
9585
    @is_short_id_in_db = (!cs.nil? && cs.scmid.length != 40)
9586
  end
9587
  private :is_short_id_in_db?
9588
9589
  def scmid_for_inserting_db(scmid)
9590
    is_short_id_in_db? ? scmid[0, 12] : scmid
9591
  end
9592
9593
  def nodes_in_branch(rev, branch_limit)
9594
    scm.nodes_in_branch(rev, :limit => branch_limit).collect do |b|
9595
      scmid_for_inserting_db(b)
9596
    end
9597
  end
9598
9599
  def tag_scmid(rev)
9600
    scmid = scm.tagmap[rev]
9601
    scmid.nil? ? nil : scmid_for_inserting_db(scmid)
9602
  end
9603
9604 441:cbce1fd3b1b7 Chris
  def latest_changesets_cond(path, rev, limit)
9605
    cond, args = [], []
9606
    if scm.branchmap.member? rev
9607
      # Mercurial named branch is *stable* in each revision.
9608
      # So, named branch can be stored in database.
9609
      # Mercurial provides *bookmark* which is equivalent with git branch.
9610
      # But, bookmark is not implemented.
9611
      cond << "#{Changeset.table_name}.scmid IN (?)"
9612
      # Revisions in root directory and sub directory are not equal.
9613
      # So, in order to get correct limit, we need to get all revisions.
9614
      # But, it is very heavy.
9615
      # Mercurial does not treat direcotry.
9616
      # So, "hg log DIR" is very heavy.
9617
      branch_limit = path.blank? ? limit : ( limit * 5 )
9618 1517:dffacf8a6908 Chris
      args << nodes_in_branch(rev, branch_limit)
9619
    elsif last = rev ? find_changeset_by_name(tag_scmid(rev) || rev) : nil
9620 441:cbce1fd3b1b7 Chris
      cond << "#{Changeset.table_name}.id <= ?"
9621
      args << last.id
9622 119:8661b858af72 Chris
    end
9623 441:cbce1fd3b1b7 Chris
    unless path.blank?
9624
      cond << "EXISTS (SELECT * FROM #{Change.table_name}
9625
                 WHERE #{Change.table_name}.changeset_id = #{Changeset.table_name}.id
9626
                 AND (#{Change.table_name}.path = ?
9627
                       OR #{Change.table_name}.path LIKE ? ESCAPE ?))"
9628
      args << path.with_leading_slash
9629 909:cbb26bc654de Chris
      args << "#{path.with_leading_slash.gsub(%r{[%_\\]}) { |s| "\\#{s}" }}/%" << '\\'
9630 441:cbce1fd3b1b7 Chris
    end
9631
    [cond.join(' AND '), *args] unless cond.empty?
9632 119:8661b858af72 Chris
  end
9633 441:cbce1fd3b1b7 Chris
  private :latest_changesets_cond
9634 119:8661b858af72 Chris
9635 0:513646585e45 Chris
  def fetch_changesets
9636 507:0c939c159af4 Chris
    return if scm.info.nil?
9637 245:051f544170fe Chris
    scm_rev = scm.info.lastrev.revision.to_i
9638 909:cbb26bc654de Chris
    db_rev  = latest_changeset ? latest_changeset.revision.to_i : -1
9639 245:051f544170fe Chris
    return unless db_rev < scm_rev  # already up-to-date
9640
9641
    logger.debug "Fetching changesets for repository #{url}" if logger
9642
    (db_rev + 1).step(scm_rev, FETCH_AT_ONCE) do |i|
9643 1115:433d4f72a19b Chris
      scm.each_revision('', i, [i + FETCH_AT_ONCE - 1, scm_rev].min) do |re|
9644
        transaction do
9645 1517:dffacf8a6908 Chris
          parents = (re.parents || []).collect do |rp|
9646
            find_changeset_by_name(scmid_for_inserting_db(rp))
9647
          end.compact
9648 909:cbb26bc654de Chris
          cs = Changeset.create(:repository   => self,
9649
                                :revision     => re.revision,
9650 1517:dffacf8a6908 Chris
                                :scmid        => scmid_for_inserting_db(re.scmid),
9651 909:cbb26bc654de Chris
                                :committer    => re.author,
9652 245:051f544170fe Chris
                                :committed_on => re.time,
9653 1115:433d4f72a19b Chris
                                :comments     => re.message,
9654
                                :parents      => parents)
9655
          unless cs.new_record?
9656 1517:dffacf8a6908 Chris
            re.paths.each do |e|
9657
              if from_revision = e[:from_revision]
9658
                e[:from_revision] = scmid_for_inserting_db(from_revision)
9659
              end
9660
              cs.create_change(e)
9661
            end
9662 909:cbb26bc654de Chris
          end
9663 0:513646585e45 Chris
        end
9664
      end
9665
    end
9666
  end
9667
end
9668 441:cbce1fd3b1b7 Chris
# Redmine - project management software
9669 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
9670 0:513646585e45 Chris
#
9671
# This program is free software; you can redistribute it and/or
9672
# modify it under the terms of the GNU General Public License
9673
# as published by the Free Software Foundation; either version 2
9674
# of the License, or (at your option) any later version.
9675 441:cbce1fd3b1b7 Chris
#
9676 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
9677
# but WITHOUT ANY WARRANTY; without even the implied warranty of
9678
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
9679
# GNU General Public License for more details.
9680 441:cbce1fd3b1b7 Chris
#
9681 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
9682
# along with this program; if not, write to the Free Software
9683
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
9684
9685 1136:51d7f3e06556 chris
require_dependency 'redmine/scm/adapters/subversion_adapter'
9686 0:513646585e45 Chris
9687
class Repository::Subversion < Repository
9688
  attr_protected :root_url
9689
  validates_presence_of :url
9690 1464:261b3d9a4903 Chris
  validates_format_of :url, :with => %r{\A(http|https|svn(\+[^\s:\/\\]+)?|file):\/\/.+}i
9691 0:513646585e45 Chris
9692 245:051f544170fe Chris
  def self.scm_adapter_class
9693 0:513646585e45 Chris
    Redmine::Scm::Adapters::SubversionAdapter
9694
  end
9695 245:051f544170fe Chris
9696 0:513646585e45 Chris
  def self.scm_name
9697
    'Subversion'
9698
  end
9699
9700 441:cbce1fd3b1b7 Chris
  def supports_directory_revisions?
9701
    true
9702
  end
9703
9704 245:051f544170fe Chris
  def repo_log_encoding
9705
    'UTF-8'
9706
  end
9707
9708 0:513646585e45 Chris
  def latest_changesets(path, rev, limit=10)
9709
    revisions = scm.revisions(path, rev, nil, :limit => limit)
9710 1115:433d4f72a19b Chris
    if revisions
9711
      identifiers = revisions.collect(&:identifier).compact
9712
      changesets.where(:revision => identifiers).reorder("committed_on DESC").includes(:repository, :user).all
9713
    else
9714
      []
9715
    end
9716 0:513646585e45 Chris
  end
9717 441:cbce1fd3b1b7 Chris
9718 0:513646585e45 Chris
  # Returns a path relative to the url of the repository
9719
  def relative_path(path)
9720
    path.gsub(Regexp.new("^\/?#{Regexp.escape(relative_url)}"), '')
9721
  end
9722 441:cbce1fd3b1b7 Chris
9723 0:513646585e45 Chris
  def fetch_changesets
9724
    scm_info = scm.info
9725
    if scm_info
9726
      # latest revision found in database
9727
      db_revision = latest_changeset ? latest_changeset.revision.to_i : 0
9728
      # latest revision in the repository
9729
      scm_revision = scm_info.lastrev.identifier.to_i
9730
      if db_revision < scm_revision
9731
        logger.debug "Fetching changesets for repository #{url}" if logger && logger.debug?
9732
        identifier_from = db_revision + 1
9733
        while (identifier_from <= scm_revision)
9734
          # loads changesets by batches of 200
9735
          identifier_to = [identifier_from + 199, scm_revision].min
9736
          revisions = scm.revisions('', identifier_to, identifier_from, :with_paths => true)
9737
          revisions.reverse_each do |revision|
9738
            transaction do
9739 441:cbce1fd3b1b7 Chris
              changeset = Changeset.create(:repository   => self,
9740
                                           :revision     => revision.identifier,
9741
                                           :committer    => revision.author,
9742 0:513646585e45 Chris
                                           :committed_on => revision.time,
9743 441:cbce1fd3b1b7 Chris
                                           :comments     => revision.message)
9744
9745 0:513646585e45 Chris
              revision.paths.each do |change|
9746
                changeset.create_change(change)
9747
              end unless changeset.new_record?
9748
            end
9749
          end unless revisions.nil?
9750
          identifier_from = identifier_to + 1
9751
        end
9752
      end
9753
    end
9754
  end
9755 441:cbce1fd3b1b7 Chris
9756 1115:433d4f72a19b Chris
  protected
9757
9758
  def load_entries_changesets(entries)
9759
    return unless entries
9760 1517:dffacf8a6908 Chris
    entries_with_identifier =
9761
      entries.select {|entry| entry.lastrev && entry.lastrev.identifier.present?}
9762 1115:433d4f72a19b Chris
    identifiers = entries_with_identifier.map {|entry| entry.lastrev.identifier}.compact.uniq
9763
    if identifiers.any?
9764 1517:dffacf8a6908 Chris
      changesets_by_identifier =
9765
        changesets.where(:revision => identifiers).
9766
          includes(:user, :repository).group_by(&:revision)
9767 1115:433d4f72a19b Chris
      entries_with_identifier.each do |entry|
9768
        if m = changesets_by_identifier[entry.lastrev.identifier]
9769
          entry.changeset = m.first
9770
        end
9771
      end
9772
    end
9773
  end
9774
9775 0:513646585e45 Chris
  private
9776 441:cbce1fd3b1b7 Chris
9777 0:513646585e45 Chris
  # Returns the relative url of the repository
9778
  # Eg: root_url = file:///var/svn/foo
9779
  #     url      = file:///var/svn/foo/bar
9780
  #     => returns /bar
9781
  def relative_url
9782
    @relative_url ||= url.gsub(Regexp.new("^#{Regexp.escape(root_url || scm.root_url)}", Regexp::IGNORECASE), '')
9783
  end
9784
end
9785 441:cbce1fd3b1b7 Chris
# Redmine - project management software
9786 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
9787 0:513646585e45 Chris
#
9788
# This program is free software; you can redistribute it and/or
9789
# modify it under the terms of the GNU General Public License
9790
# as published by the Free Software Foundation; either version 2
9791
# of the License, or (at your option) any later version.
9792 909:cbb26bc654de Chris
#
9793 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
9794
# but WITHOUT ANY WARRANTY; without even the implied warranty of
9795
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
9796
# GNU General Public License for more details.
9797 909:cbb26bc654de Chris
#
9798 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
9799
# along with this program; if not, write to the Free Software
9800
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
9801
9802
class Role < ActiveRecord::Base
9803 1115:433d4f72a19b Chris
  # Custom coder for the permissions attribute that should be an
9804
  # array of symbols. Rails 3 uses Psych which can be *unbelievably*
9805
  # slow on some platforms (eg. mingw32).
9806
  class PermissionsAttributeCoder
9807
    def self.load(str)
9808
      str.to_s.scan(/:([a-z0-9_]+)/).flatten.map(&:to_sym)
9809
    end
9810
9811
    def self.dump(value)
9812
      YAML.dump(value)
9813
    end
9814
  end
9815
9816 0:513646585e45 Chris
  # Built-in roles
9817
  BUILTIN_NON_MEMBER = 1
9818
  BUILTIN_ANONYMOUS  = 2
9819 909:cbb26bc654de Chris
9820 441:cbce1fd3b1b7 Chris
  ISSUES_VISIBILITY_OPTIONS = [
9821
    ['all', :label_issues_visibility_all],
9822
    ['default', :label_issues_visibility_public],
9823
    ['own', :label_issues_visibility_own]
9824
  ]
9825 0:513646585e45 Chris
9826 1464:261b3d9a4903 Chris
  scope :sorted, lambda { order("#{table_name}.builtin ASC, #{table_name}.position ASC") }
9827
  scope :givable, lambda { order("#{table_name}.position ASC").where(:builtin => 0) }
9828 1115:433d4f72a19b Chris
  scope :builtin, lambda { |*args|
9829
    compare = (args.first == true ? 'not' : '')
9830
    where("#{compare} builtin = 0")
9831 0:513646585e45 Chris
  }
9832 909:cbb26bc654de Chris
9833 0:513646585e45 Chris
  before_destroy :check_deletable
9834 1115:433d4f72a19b Chris
  has_many :workflow_rules, :dependent => :delete_all do
9835 0:513646585e45 Chris
    def copy(source_role)
9836 1115:433d4f72a19b Chris
      WorkflowRule.copy(nil, source_role, nil, proxy_association.owner)
9837 0:513646585e45 Chris
    end
9838
  end
9839 1464:261b3d9a4903 Chris
  has_and_belongs_to_many :custom_fields, :join_table => "#{table_name_prefix}custom_fields_roles#{table_name_suffix}", :foreign_key => "role_id"
9840 909:cbb26bc654de Chris
9841 0:513646585e45 Chris
  has_many :member_roles, :dependent => :destroy
9842
  has_many :members, :through => :member_roles
9843
  acts_as_list
9844 909:cbb26bc654de Chris
9845 1115:433d4f72a19b Chris
  serialize :permissions, ::Role::PermissionsAttributeCoder
9846 0:513646585e45 Chris
  attr_protected :builtin
9847
9848
  validates_presence_of :name
9849
  validates_uniqueness_of :name
9850
  validates_length_of :name, :maximum => 30
9851 441:cbce1fd3b1b7 Chris
  validates_inclusion_of :issues_visibility,
9852
    :in => ISSUES_VISIBILITY_OPTIONS.collect(&:first),
9853
    :if => lambda {|role| role.respond_to?(:issues_visibility)}
9854 909:cbb26bc654de Chris
9855 1115:433d4f72a19b Chris
  # Copies attributes from another role, arg can be an id or a Role
9856
  def copy_from(arg, options={})
9857
    return unless arg.present?
9858
    role = arg.is_a?(Role) ? arg : Role.find_by_id(arg.to_s)
9859
    self.attributes = role.attributes.dup.except("id", "name", "position", "builtin", "permissions")
9860
    self.permissions = role.permissions.dup
9861
    self
9862 0:513646585e45 Chris
  end
9863 909:cbb26bc654de Chris
9864 0:513646585e45 Chris
  def permissions=(perms)
9865
    perms = perms.collect {|p| p.to_sym unless p.blank? }.compact.uniq if perms
9866
    write_attribute(:permissions, perms)
9867
  end
9868
9869
  def add_permission!(*perms)
9870
    self.permissions = [] unless permissions.is_a?(Array)
9871
9872
    permissions_will_change!
9873
    perms.each do |p|
9874
      p = p.to_sym
9875
      permissions << p unless permissions.include?(p)
9876
    end
9877
    save!
9878
  end
9879
9880
  def remove_permission!(*perms)
9881
    return unless permissions.is_a?(Array)
9882
    permissions_will_change!
9883
    perms.each { |p| permissions.delete(p.to_sym) }
9884
    save!
9885
  end
9886 909:cbb26bc654de Chris
9887 0:513646585e45 Chris
  # Returns true if the role has the given permission
9888
  def has_permission?(perm)
9889
    !permissions.nil? && permissions.include?(perm.to_sym)
9890
  end
9891 909:cbb26bc654de Chris
9892 0:513646585e45 Chris
  def <=>(role)
9893 1115:433d4f72a19b Chris
    if role
9894
      if builtin == role.builtin
9895
        position <=> role.position
9896
      else
9897
        builtin <=> role.builtin
9898
      end
9899
    else
9900
      -1
9901
    end
9902 0:513646585e45 Chris
  end
9903 909:cbb26bc654de Chris
9904 0:513646585e45 Chris
  def to_s
9905
    name
9906
  end
9907 909:cbb26bc654de Chris
9908 441:cbce1fd3b1b7 Chris
  def name
9909
    case builtin
9910
    when 1; l(:label_role_non_member, :default => read_attribute(:name))
9911
    when 2; l(:label_role_anonymous,  :default => read_attribute(:name))
9912
    else; read_attribute(:name)
9913
    end
9914
  end
9915 909:cbb26bc654de Chris
9916 0:513646585e45 Chris
  # Return true if the role is a builtin role
9917
  def builtin?
9918
    self.builtin != 0
9919
  end
9920 909:cbb26bc654de Chris
9921 1115:433d4f72a19b Chris
  # Return true if the role is the anonymous role
9922
  def anonymous?
9923
    builtin == 2
9924
  end
9925 1464:261b3d9a4903 Chris
9926 0:513646585e45 Chris
  # Return true if the role is a project member role
9927
  def member?
9928
    !self.builtin?
9929
  end
9930 909:cbb26bc654de Chris
9931 0:513646585e45 Chris
  # Return true if role is allowed to do the specified action
9932
  # action can be:
9933
  # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
9934
  # * a permission Symbol (eg. :edit_project)
9935
  def allowed_to?(action)
9936
    if action.is_a? Hash
9937
      allowed_actions.include? "#{action[:controller]}/#{action[:action]}"
9938
    else
9939
      allowed_permissions.include? action
9940
    end
9941
  end
9942 909:cbb26bc654de Chris
9943 0:513646585e45 Chris
  # Return all the permissions that can be given to the role
9944
  def setable_permissions
9945
    setable_permissions = Redmine::AccessControl.permissions - Redmine::AccessControl.public_permissions
9946
    setable_permissions -= Redmine::AccessControl.members_only_permissions if self.builtin == BUILTIN_NON_MEMBER
9947
    setable_permissions -= Redmine::AccessControl.loggedin_only_permissions if self.builtin == BUILTIN_ANONYMOUS
9948
    setable_permissions
9949
  end
9950
9951
  # Find all the roles that can be given to a project member
9952
  def self.find_all_givable
9953 1115:433d4f72a19b Chris
    Role.givable.all
9954 0:513646585e45 Chris
  end
9955
9956
  # Return the builtin 'non member' role.  If the role doesn't exist,
9957
  # it will be created on the fly.
9958
  def self.non_member
9959 909:cbb26bc654de Chris
    find_or_create_system_role(BUILTIN_NON_MEMBER, 'Non member')
9960 0:513646585e45 Chris
  end
9961
9962
  # Return the builtin 'anonymous' role.  If the role doesn't exist,
9963
  # it will be created on the fly.
9964
  def self.anonymous
9965 909:cbb26bc654de Chris
    find_or_create_system_role(BUILTIN_ANONYMOUS, 'Anonymous')
9966 0:513646585e45 Chris
  end
9967
9968
private
9969 909:cbb26bc654de Chris
9970 0:513646585e45 Chris
  def allowed_permissions
9971
    @allowed_permissions ||= permissions + Redmine::AccessControl.public_permissions.collect {|p| p.name}
9972
  end
9973
9974
  def allowed_actions
9975
    @actions_allowed ||= allowed_permissions.inject([]) { |actions, permission| actions += Redmine::AccessControl.allowed_actions(permission) }.flatten
9976
  end
9977 909:cbb26bc654de Chris
9978 0:513646585e45 Chris
  def check_deletable
9979
    raise "Can't delete role" if members.any?
9980
    raise "Can't delete builtin role" if builtin?
9981
  end
9982 909:cbb26bc654de Chris
9983
  def self.find_or_create_system_role(builtin, name)
9984 1115:433d4f72a19b Chris
    role = where(:builtin => builtin).first
9985 909:cbb26bc654de Chris
    if role.nil?
9986
      role = create(:name => name, :position => 0) do |r|
9987
        r.builtin = builtin
9988
      end
9989
      raise "Unable to create the #{name} role." if role.new_record?
9990
    end
9991
    role
9992
  end
9993 0:513646585e45 Chris
end
9994 441:cbce1fd3b1b7 Chris
# Redmine - project management software
9995 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
9996 0:513646585e45 Chris
#
9997
# This program is free software; you can redistribute it and/or
9998
# modify it under the terms of the GNU General Public License
9999
# as published by the Free Software Foundation; either version 2
10000
# of the License, or (at your option) any later version.
10001 441:cbce1fd3b1b7 Chris
#
10002 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
10003
# but WITHOUT ANY WARRANTY; without even the implied warranty of
10004
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
10005
# GNU General Public License for more details.
10006 441:cbce1fd3b1b7 Chris
#
10007 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
10008
# along with this program; if not, write to the Free Software
10009
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
10010
10011
class Setting < ActiveRecord::Base
10012
10013
  DATE_FORMATS = [
10014 1464:261b3d9a4903 Chris
        '%Y-%m-%d',
10015
        '%d/%m/%Y',
10016
        '%d.%m.%Y',
10017
        '%d-%m-%Y',
10018
        '%m/%d/%Y',
10019
        '%d %b %Y',
10020
        '%d %B %Y',
10021
        '%b %d, %Y',
10022
        '%B %d, %Y'
10023 0:513646585e45 Chris
    ]
10024 441:cbce1fd3b1b7 Chris
10025 0:513646585e45 Chris
  TIME_FORMATS = [
10026
    '%H:%M',
10027
    '%I:%M %p'
10028
    ]
10029 441:cbce1fd3b1b7 Chris
10030 0:513646585e45 Chris
  ENCODINGS = %w(US-ASCII
10031
                  windows-1250
10032
                  windows-1251
10033
                  windows-1252
10034
                  windows-1253
10035
                  windows-1254
10036
                  windows-1255
10037
                  windows-1256
10038
                  windows-1257
10039
                  windows-1258
10040
                  windows-31j
10041
                  ISO-2022-JP
10042
                  ISO-2022-KR
10043
                  ISO-8859-1
10044
                  ISO-8859-2
10045
                  ISO-8859-3
10046
                  ISO-8859-4
10047
                  ISO-8859-5
10048
                  ISO-8859-6
10049
                  ISO-8859-7
10050
                  ISO-8859-8
10051
                  ISO-8859-9
10052
                  ISO-8859-13
10053
                  ISO-8859-15
10054
                  KOI8-R
10055
                  UTF-8
10056
                  UTF-16
10057
                  UTF-16BE
10058
                  UTF-16LE
10059
                  EUC-JP
10060
                  Shift_JIS
10061 245:051f544170fe Chris
                  CP932
10062 0:513646585e45 Chris
                  GB18030
10063
                  GBK
10064
                  ISCII91
10065
                  EUC-KR
10066
                  Big5
10067
                  Big5-HKSCS
10068
                  TIS-620)
10069 441:cbce1fd3b1b7 Chris
10070 0:513646585e45 Chris
  cattr_accessor :available_settings
10071 909:cbb26bc654de Chris
  @@available_settings = YAML::load(File.open("#{Rails.root}/config/settings.yml"))
10072 0:513646585e45 Chris
  Redmine::Plugin.all.each do |plugin|
10073
    next unless plugin.settings
10074 441:cbce1fd3b1b7 Chris
    @@available_settings["plugin_#{plugin.id}"] = {'default' => plugin.settings[:default], 'serialized' => true}
10075 0:513646585e45 Chris
  end
10076 441:cbce1fd3b1b7 Chris
10077 0:513646585e45 Chris
  validates_uniqueness_of :name
10078
  validates_inclusion_of :name, :in => @@available_settings.keys
10079 1517:dffacf8a6908 Chris
  validates_numericality_of :value, :only_integer => true, :if => Proc.new { |setting|
10080
    (s = @@available_settings[setting.name]) && s['format'] == 'int'
10081
  }
10082 0:513646585e45 Chris
10083
  # Hash used to cache setting values
10084
  @cached_settings = {}
10085
  @cached_cleared_on = Time.now
10086 441:cbce1fd3b1b7 Chris
10087 0:513646585e45 Chris
  def value
10088
    v = read_attribute(:value)
10089
    # Unserialize serialized settings
10090
    v = YAML::load(v) if @@available_settings[name]['serialized'] && v.is_a?(String)
10091
    v = v.to_sym if @@available_settings[name]['format'] == 'symbol' && !v.blank?
10092
    v
10093
  end
10094 441:cbce1fd3b1b7 Chris
10095 0:513646585e45 Chris
  def value=(v)
10096
    v = v.to_yaml if v && @@available_settings[name] && @@available_settings[name]['serialized']
10097
    write_attribute(:value, v.to_s)
10098
  end
10099 441:cbce1fd3b1b7 Chris
10100 0:513646585e45 Chris
  # Returns the value of the setting named name
10101
  def self.[](name)
10102
    v = @cached_settings[name]
10103
    v ? v : (@cached_settings[name] = find_or_default(name).value)
10104
  end
10105 441:cbce1fd3b1b7 Chris
10106 0:513646585e45 Chris
  def self.[]=(name, v)
10107
    setting = find_or_default(name)
10108
    setting.value = (v ? v : "")
10109
    @cached_settings[name] = nil
10110
    setting.save
10111
    setting.value
10112
  end
10113 441:cbce1fd3b1b7 Chris
10114 0:513646585e45 Chris
  # Defines getter and setter for each setting
10115
  # Then setting values can be read using: Setting.some_setting_name
10116
  # or set using Setting.some_setting_name = "some value"
10117
  @@available_settings.each do |name, params|
10118
    src = <<-END_SRC
10119
    def self.#{name}
10120
      self[:#{name}]
10121
    end
10122
10123
    def self.#{name}?
10124
      self[:#{name}].to_i > 0
10125
    end
10126
10127
    def self.#{name}=(value)
10128
      self[:#{name}] = value
10129
    end
10130 1464:261b3d9a4903 Chris
END_SRC
10131 0:513646585e45 Chris
    class_eval src, __FILE__, __LINE__
10132
  end
10133 441:cbce1fd3b1b7 Chris
10134 1464:261b3d9a4903 Chris
  # Sets a setting value from params
10135
  def self.set_from_params(name, params)
10136
    params = params.dup
10137
    params.delete_if {|v| v.blank? } if params.is_a?(Array)
10138
10139
    m = "#{name}_from_params"
10140
    if respond_to? m
10141
      self[name.to_sym] = send m, params
10142
    else
10143
      self[name.to_sym] = params
10144
    end
10145
  end
10146
10147
  # Returns a hash suitable for commit_update_keywords setting
10148
  #
10149
  # Example:
10150
  # params = {:keywords => ['fixes', 'closes'], :status_id => ["3", "5"], :done_ratio => ["", "100"]}
10151
  # Setting.commit_update_keywords_from_params(params)
10152
  # # => [{'keywords => 'fixes', 'status_id' => "3"}, {'keywords => 'closes', 'status_id' => "5", 'done_ratio' => "100"}]
10153
  def self.commit_update_keywords_from_params(params)
10154
    s = []
10155
    if params.is_a?(Hash) && params.key?(:keywords) && params.values.all? {|v| v.is_a? Array}
10156
      attributes = params.except(:keywords).keys
10157
      params[:keywords].each_with_index do |keywords, i|
10158
        next if keywords.blank?
10159
        s << attributes.inject({}) {|h, a|
10160
          value = params[a][i].to_s
10161
          h[a.to_s] = value if value.present?
10162
          h
10163
        }.merge('keywords' => keywords)
10164
      end
10165
    end
10166
    s
10167
  end
10168
10169 0:513646585e45 Chris
  # Helper that returns an array based on per_page_options setting
10170
  def self.per_page_options_array
10171
    per_page_options.split(%r{[\s,]}).collect(&:to_i).select {|n| n > 0}.sort
10172
  end
10173 441:cbce1fd3b1b7 Chris
10174 1464:261b3d9a4903 Chris
  # Helper that returns a Hash with single update keywords as keys
10175
  def self.commit_update_keywords_array
10176
    a = []
10177
    if commit_update_keywords.is_a?(Array)
10178
      commit_update_keywords.each do |rule|
10179
        next unless rule.is_a?(Hash)
10180
        rule = rule.dup
10181
        rule.delete_if {|k, v| v.blank?}
10182
        keywords = rule['keywords'].to_s.downcase.split(",").map(&:strip).reject(&:blank?)
10183
        next if keywords.empty?
10184
        a << rule.merge('keywords' => keywords)
10185
      end
10186
    end
10187
    a
10188
  end
10189
10190
  def self.commit_fix_keywords
10191
    ActiveSupport::Deprecation.warn "Setting.commit_fix_keywords is deprecated and will be removed in Redmine 3"
10192
    if commit_update_keywords.is_a?(Array)
10193
      commit_update_keywords.first && commit_update_keywords.first['keywords']
10194
    end
10195
  end
10196
10197
  def self.commit_fix_status_id
10198
    ActiveSupport::Deprecation.warn "Setting.commit_fix_status_id is deprecated and will be removed in Redmine 3"
10199
    if commit_update_keywords.is_a?(Array)
10200
      commit_update_keywords.first && commit_update_keywords.first['status_id']
10201
    end
10202
  end
10203
10204
  def self.commit_fix_done_ratio
10205
    ActiveSupport::Deprecation.warn "Setting.commit_fix_done_ratio is deprecated and will be removed in Redmine 3"
10206
    if commit_update_keywords.is_a?(Array)
10207
      commit_update_keywords.first && commit_update_keywords.first['done_ratio']
10208
    end
10209
  end
10210
10211 0:513646585e45 Chris
  def self.openid?
10212
    Object.const_defined?(:OpenID) && self[:openid].to_i > 0
10213
  end
10214 441:cbce1fd3b1b7 Chris
10215 0:513646585e45 Chris
  # Checks if settings have changed since the values were read
10216
  # and clears the cache hash if it's the case
10217
  # Called once per request
10218
  def self.check_cache
10219
    settings_updated_on = Setting.maximum(:updated_on)
10220
    if settings_updated_on && @cached_cleared_on <= settings_updated_on
10221 909:cbb26bc654de Chris
      clear_cache
10222 0:513646585e45 Chris
    end
10223
  end
10224 1464:261b3d9a4903 Chris
10225 909:cbb26bc654de Chris
  # Clears the settings cache
10226
  def self.clear_cache
10227
    @cached_settings.clear
10228
    @cached_cleared_on = Time.now
10229
    logger.info "Settings cache cleared." if logger
10230
  end
10231 441:cbce1fd3b1b7 Chris
10232 0:513646585e45 Chris
private
10233
  # Returns the Setting instance for the setting named name
10234
  # (record found in database or new record with default value)
10235
  def self.find_or_default(name)
10236
    name = name.to_s
10237 441:cbce1fd3b1b7 Chris
    raise "There's no setting named #{name}" unless @@available_settings.has_key?(name)
10238 1517:dffacf8a6908 Chris
    setting = where(:name => name).first
10239 909:cbb26bc654de Chris
    unless setting
10240 1517:dffacf8a6908 Chris
      setting = new
10241
      setting.name = name
10242 909:cbb26bc654de Chris
      setting.value = @@available_settings[name]['default']
10243
    end
10244
    setting
10245 0:513646585e45 Chris
  end
10246
end
10247 56:1d072f771b4d luisf
class SsamrUserDetail < ActiveRecord::Base
10248 60:cf39b52d24b4 luisf
  belongs_to :user
10249 64:9d42bcda8cea luisf
10250
  validates_presence_of :description
10251 163:9a5a265e77f0 luisf
10252
  validate :check_institution
10253
10254
  def check_institution()
10255
    errors.add(:institution_id, "Please insert an institution") if
10256
      institution_id.blank? and other_institution.blank?
10257
  end
10258
10259 525:8a26a0e291cf luis
  def institution_name()
10260
    if not self.institution_type.nil?
10261
      if self.institution_type
10262
        Institution.find(self.institution_id).name
10263
      else
10264
        self.other_institution
10265
      end
10266
    else
10267
      ""
10268
    end
10269
  end
10270 56:1d072f771b4d luisf
end
10271 441:cbce1fd3b1b7 Chris
# Redmine - project management software
10272 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
10273 0:513646585e45 Chris
#
10274
# This program is free software; you can redistribute it and/or
10275
# modify it under the terms of the GNU General Public License
10276
# as published by the Free Software Foundation; either version 2
10277
# of the License, or (at your option) any later version.
10278 441:cbce1fd3b1b7 Chris
#
10279 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
10280
# but WITHOUT ANY WARRANTY; without even the implied warranty of
10281
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
10282
# GNU General Public License for more details.
10283 441:cbce1fd3b1b7 Chris
#
10284 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
10285
# along with this program; if not, write to the Free Software
10286
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
10287
10288
class TimeEntry < ActiveRecord::Base
10289 929:5f33065ddc4b Chris
  include Redmine::SafeAttributes
10290 0:513646585e45 Chris
  # could have used polymorphic association
10291
  # project association here allows easy loading of time entries at project level with one database trip
10292
  belongs_to :project
10293
  belongs_to :issue
10294
  belongs_to :user
10295
  belongs_to :activity, :class_name => 'TimeEntryActivity', :foreign_key => 'activity_id'
10296 441:cbce1fd3b1b7 Chris
10297 0:513646585e45 Chris
  attr_protected :project_id, :user_id, :tyear, :tmonth, :tweek
10298
10299
  acts_as_customizable
10300
  acts_as_event :title => Proc.new {|o| "#{l_hours(o.hours)} (#{(o.issue || o.project).event_title})"},
10301 37:94944d00e43c chris
                :url => Proc.new {|o| {:controller => 'timelog', :action => 'index', :project_id => o.project, :issue_id => o.issue}},
10302 0:513646585e45 Chris
                :author => :user,
10303 1464:261b3d9a4903 Chris
                :group => :issue,
10304 0:513646585e45 Chris
                :description => :comments
10305
10306
  acts_as_activity_provider :timestamp => "#{table_name}.created_on",
10307
                            :author_key => :user_id,
10308 441:cbce1fd3b1b7 Chris
                            :find_options => {:include => :project}
10309 0:513646585e45 Chris
10310
  validates_presence_of :user_id, :activity_id, :project_id, :hours, :spent_on
10311
  validates_numericality_of :hours, :allow_nil => true, :message => :invalid
10312
  validates_length_of :comments, :maximum => 255, :allow_nil => true
10313 1464:261b3d9a4903 Chris
  validates :spent_on, :date => true
10314 909:cbb26bc654de Chris
  before_validation :set_project_if_nil
10315
  validate :validate_time_entry
10316 0:513646585e45 Chris
10317 1464:261b3d9a4903 Chris
  scope :visible, lambda {|*args|
10318
    includes(:project).where(Project.allowed_to_condition(args.shift || User.current, :view_time_entries, *args))
10319
  }
10320
  scope :on_issue, lambda {|issue|
10321
    includes(:issue).where("#{Issue.table_name}.root_id = #{issue.root_id} AND #{Issue.table_name}.lft >= #{issue.lft} AND #{Issue.table_name}.rgt <= #{issue.rgt}")
10322
  }
10323
  scope :on_project, lambda {|project, include_subprojects|
10324
    includes(:project).where(project.project_condition(include_subprojects))
10325
  }
10326 1115:433d4f72a19b Chris
  scope :spent_between, lambda {|from, to|
10327
    if from && to
10328 1464:261b3d9a4903 Chris
     where("#{TimeEntry.table_name}.spent_on BETWEEN ? AND ?", from, to)
10329 1115:433d4f72a19b Chris
    elsif from
10330 1464:261b3d9a4903 Chris
     where("#{TimeEntry.table_name}.spent_on >= ?", from)
10331 1115:433d4f72a19b Chris
    elsif to
10332 1464:261b3d9a4903 Chris
     where("#{TimeEntry.table_name}.spent_on <= ?", to)
10333 1115:433d4f72a19b Chris
    else
10334 1464:261b3d9a4903 Chris
     where(nil)
10335 1115:433d4f72a19b Chris
    end
10336
  }
10337 441:cbce1fd3b1b7 Chris
10338 1115:433d4f72a19b Chris
  safe_attributes 'hours', 'comments', 'issue_id', 'activity_id', 'spent_on', 'custom_field_values', 'custom_fields'
10339 929:5f33065ddc4b Chris
10340 1115:433d4f72a19b Chris
  def initialize(attributes=nil, *args)
10341
    super
10342 0:513646585e45 Chris
    if new_record? && self.activity.nil?
10343
      if default_activity = TimeEntryActivity.default
10344
        self.activity_id = default_activity.id
10345
      end
10346
      self.hours = nil if hours == 0
10347
    end
10348
  end
10349 441:cbce1fd3b1b7 Chris
10350 1517:dffacf8a6908 Chris
  def safe_attributes=(attrs, user=User.current)
10351
    attrs = super
10352
    if !new_record? && issue && issue.project_id != project_id
10353
      if user.allowed_to?(:log_time, issue.project)
10354
        self.project_id = issue.project_id
10355
      end
10356
    end
10357
    attrs
10358
  end
10359
10360 909:cbb26bc654de Chris
  def set_project_if_nil
10361 0:513646585e45 Chris
    self.project = issue.project if issue && project.nil?
10362
  end
10363 441:cbce1fd3b1b7 Chris
10364 909:cbb26bc654de Chris
  def validate_time_entry
10365 0:513646585e45 Chris
    errors.add :hours, :invalid if hours && (hours < 0 || hours >= 1000)
10366
    errors.add :project_id, :invalid if project.nil?
10367
    errors.add :issue_id, :invalid if (issue_id && !issue) || (issue && project!=issue.project)
10368
  end
10369 441:cbce1fd3b1b7 Chris
10370 0:513646585e45 Chris
  def hours=(h)
10371
    write_attribute :hours, (h.is_a?(String) ? (h.to_hours || h) : h)
10372
  end
10373 441:cbce1fd3b1b7 Chris
10374 1115:433d4f72a19b Chris
  def hours
10375
    h = read_attribute(:hours)
10376
    if h.is_a?(Float)
10377
      h.round(2)
10378
    else
10379
      h
10380
    end
10381
  end
10382
10383 0:513646585e45 Chris
  # tyear, tmonth, tweek assigned where setting spent_on attributes
10384
  # these attributes make time aggregations easier
10385
  def spent_on=(date)
10386
    super
10387 128:07fa8a8b56a8 Chris
    if spent_on.is_a?(Time)
10388
      self.spent_on = spent_on.to_date
10389
    end
10390 0:513646585e45 Chris
    self.tyear = spent_on ? spent_on.year : nil
10391
    self.tmonth = spent_on ? spent_on.month : nil
10392
    self.tweek = spent_on ? Date.civil(spent_on.year, spent_on.month, spent_on.day).cweek : nil
10393
  end
10394 441:cbce1fd3b1b7 Chris
10395 0:513646585e45 Chris
  # Returns true if the time entry can be edited by usr, otherwise false
10396
  def editable_by?(usr)
10397
    (usr == user && usr.allowed_to?(:edit_own_time_entries, project)) || usr.allowed_to?(:edit_time_entries, project)
10398
  end
10399
end
10400 909:cbb26bc654de Chris
# Redmine - project management software
10401 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
10402 0:513646585e45 Chris
#
10403
# This program is free software; you can redistribute it and/or
10404
# modify it under the terms of the GNU General Public License
10405
# as published by the Free Software Foundation; either version 2
10406
# of the License, or (at your option) any later version.
10407 909:cbb26bc654de Chris
#
10408 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
10409
# but WITHOUT ANY WARRANTY; without even the implied warranty of
10410
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
10411
# GNU General Public License for more details.
10412 909:cbb26bc654de Chris
#
10413 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
10414
# along with this program; if not, write to the Free Software
10415
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
10416
10417
class TimeEntryActivity < Enumeration
10418
  has_many :time_entries, :foreign_key => 'activity_id'
10419
10420
  OptionName = :enumeration_activities
10421 909:cbb26bc654de Chris
10422 0:513646585e45 Chris
  def option_name
10423
    OptionName
10424
  end
10425
10426 1464:261b3d9a4903 Chris
  def objects
10427
    TimeEntry.where(:activity_id => self_and_descendants(1).map(&:id))
10428
  end
10429
10430 0:513646585e45 Chris
  def objects_count
10431 1464:261b3d9a4903 Chris
    objects.count
10432 0:513646585e45 Chris
  end
10433
10434
  def transfer_relations(to)
10435 1464:261b3d9a4903 Chris
    objects.update_all(:activity_id => to.id)
10436 0:513646585e45 Chris
  end
10437
end
10438 909:cbb26bc654de Chris
# Redmine - project management software
10439 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
10440 0:513646585e45 Chris
#
10441
# This program is free software; you can redistribute it and/or
10442
# modify it under the terms of the GNU General Public License
10443
# as published by the Free Software Foundation; either version 2
10444
# of the License, or (at your option) any later version.
10445 909:cbb26bc654de Chris
#
10446 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
10447
# but WITHOUT ANY WARRANTY; without even the implied warranty of
10448
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
10449
# GNU General Public License for more details.
10450 909:cbb26bc654de Chris
#
10451 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
10452
# along with this program; if not, write to the Free Software
10453
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
10454
10455
class TimeEntryActivityCustomField < CustomField
10456
  def type_name
10457
    :enumeration_activities
10458
  end
10459
end
10460 909:cbb26bc654de Chris
# Redmine - project management software
10461 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
10462 0:513646585e45 Chris
#
10463
# This program is free software; you can redistribute it and/or
10464
# modify it under the terms of the GNU General Public License
10465
# as published by the Free Software Foundation; either version 2
10466
# of the License, or (at your option) any later version.
10467 909:cbb26bc654de Chris
#
10468 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
10469
# but WITHOUT ANY WARRANTY; without even the implied warranty of
10470
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
10471
# GNU General Public License for more details.
10472 909:cbb26bc654de Chris
#
10473 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
10474
# along with this program; if not, write to the Free Software
10475
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
10476
10477
class TimeEntryCustomField < CustomField
10478
  def type_name
10479
    :label_spent_time
10480
  end
10481
end
10482
10483 1464:261b3d9a4903 Chris
# Redmine - project management software
10484 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
10485 1464:261b3d9a4903 Chris
#
10486
# This program is free software; you can redistribute it and/or
10487
# modify it under the terms of the GNU General Public License
10488
# as published by the Free Software Foundation; either version 2
10489
# of the License, or (at your option) any later version.
10490
#
10491
# This program is distributed in the hope that it will be useful,
10492
# but WITHOUT ANY WARRANTY; without even the implied warranty of
10493
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
10494
# GNU General Public License for more details.
10495
#
10496
# You should have received a copy of the GNU General Public License
10497
# along with this program; if not, write to the Free Software
10498
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
10499
10500
class TimeEntryQuery < Query
10501
10502
  self.queried_class = TimeEntry
10503
10504
  self.available_columns = [
10505
    QueryColumn.new(:project, :sortable => "#{Project.table_name}.name", :groupable => true),
10506
    QueryColumn.new(:spent_on, :sortable => ["#{TimeEntry.table_name}.spent_on", "#{TimeEntry.table_name}.created_on"], :default_order => 'desc', :groupable => true),
10507
    QueryColumn.new(:user, :sortable => lambda {User.fields_for_order_statement}, :groupable => true),
10508
    QueryColumn.new(:activity, :sortable => "#{TimeEntryActivity.table_name}.position", :groupable => true),
10509
    QueryColumn.new(:issue, :sortable => "#{Issue.table_name}.id"),
10510
    QueryColumn.new(:comments),
10511
    QueryColumn.new(:hours, :sortable => "#{TimeEntry.table_name}.hours"),
10512
  ]
10513
10514
  def initialize(attributes=nil, *args)
10515
    super attributes
10516
    self.filters ||= {}
10517
    add_filter('spent_on', '*') unless filters.present?
10518
  end
10519
10520
  def initialize_available_filters
10521
    add_available_filter "spent_on", :type => :date_past
10522
10523
    principals = []
10524
    if project
10525
      principals += project.principals.sort
10526
      unless project.leaf?
10527
        subprojects = project.descendants.visible.all
10528
        if subprojects.any?
10529
          add_available_filter "subproject_id",
10530
            :type => :list_subprojects,
10531
            :values => subprojects.collect{|s| [s.name, s.id.to_s] }
10532
          principals += Principal.member_of(subprojects)
10533
        end
10534
      end
10535
    else
10536
      if all_projects.any?
10537
        # members of visible projects
10538
        principals += Principal.member_of(all_projects)
10539
        # project filter
10540
        project_values = []
10541
        if User.current.logged? && User.current.memberships.any?
10542
          project_values << ["<< #{l(:label_my_projects).downcase} >>", "mine"]
10543
        end
10544
        project_values += all_projects_values
10545
        add_available_filter("project_id",
10546
          :type => :list, :values => project_values
10547
        ) unless project_values.empty?
10548
      end
10549
    end
10550
    principals.uniq!
10551
    principals.sort!
10552
    users = principals.select {|p| p.is_a?(User)}
10553
10554
    users_values = []
10555
    users_values << ["<< #{l(:label_me)} >>", "me"] if User.current.logged?
10556
    users_values += users.collect{|s| [s.name, s.id.to_s] }
10557
    add_available_filter("user_id",
10558
      :type => :list_optional, :values => users_values
10559
    ) unless users_values.empty?
10560
10561
    activities = (project ? project.activities : TimeEntryActivity.shared.active)
10562
    add_available_filter("activity_id",
10563
      :type => :list, :values => activities.map {|a| [a.name, a.id.to_s]}
10564
    ) unless activities.empty?
10565
10566
    add_available_filter "comments", :type => :text
10567
    add_available_filter "hours", :type => :float
10568
10569
    add_custom_fields_filters(TimeEntryCustomField)
10570
    add_associations_custom_fields_filters :project, :issue, :user
10571
  end
10572
10573
  def available_columns
10574
    return @available_columns if @available_columns
10575
    @available_columns = self.class.available_columns.dup
10576 1517:dffacf8a6908 Chris
    @available_columns += TimeEntryCustomField.visible.
10577
                            map {|cf| QueryCustomFieldColumn.new(cf) }
10578
    @available_columns += IssueCustomField.visible.
10579
                            map {|cf| QueryAssociationCustomFieldColumn.new(:issue, cf) }
10580 1464:261b3d9a4903 Chris
    @available_columns
10581
  end
10582
10583
  def default_columns_names
10584
    @default_columns_names ||= [:project, :spent_on, :user, :activity, :issue, :comments, :hours]
10585
  end
10586
10587
  def results_scope(options={})
10588
    order_option = [group_by_sort_order, options[:order]].flatten.reject(&:blank?)
10589
10590
    TimeEntry.visible.
10591
      where(statement).
10592
      order(order_option).
10593
      joins(joins_for_order_statement(order_option.join(','))).
10594
      includes(:activity)
10595
  end
10596
10597
  def sql_for_activity_id_field(field, operator, value)
10598
    condition_on_id = sql_for_field(field, operator, value, Enumeration.table_name, 'id')
10599
    condition_on_parent_id = sql_for_field(field, operator, value, Enumeration.table_name, 'parent_id')
10600
    ids = value.map(&:to_i).join(',')
10601
    table_name = Enumeration.table_name
10602
    if operator == '='
10603
      "(#{table_name}.id IN (#{ids}) OR #{table_name}.parent_id IN (#{ids}))"
10604
    else
10605
      "(#{table_name}.id NOT IN (#{ids}) AND (#{table_name}.parent_id IS NULL OR #{table_name}.parent_id NOT IN (#{ids})))"
10606
    end
10607
  end
10608
10609
  # Accepts :from/:to params as shortcut filters
10610
  def build_from_params(params)
10611
    super
10612
    if params[:from].present? && params[:to].present?
10613
      add_filter('spent_on', '><', [params[:from], params[:to]])
10614
    elsif params[:from].present?
10615
      add_filter('spent_on', '>=', [params[:from]])
10616
    elsif params[:to].present?
10617
      add_filter('spent_on', '<=', [params[:to]])
10618
    end
10619
    self
10620
  end
10621
end
10622 0:513646585e45 Chris
# Redmine - project management software
10623 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
10624 0:513646585e45 Chris
#
10625
# This program is free software; you can redistribute it and/or
10626
# modify it under the terms of the GNU General Public License
10627
# as published by the Free Software Foundation; either version 2
10628
# of the License, or (at your option) any later version.
10629 909:cbb26bc654de Chris
#
10630 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
10631
# but WITHOUT ANY WARRANTY; without even the implied warranty of
10632
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
10633
# GNU General Public License for more details.
10634 909:cbb26bc654de Chris
#
10635 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
10636
# along with this program; if not, write to the Free Software
10637
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
10638
10639
class Token < ActiveRecord::Base
10640
  belongs_to :user
10641
  validates_uniqueness_of :value
10642 909:cbb26bc654de Chris
10643
  before_create :delete_previous_tokens, :generate_new_token
10644
10645 0:513646585e45 Chris
  @@validity_time = 1.day
10646 909:cbb26bc654de Chris
10647
  def generate_new_token
10648 0:513646585e45 Chris
    self.value = Token.generate_token_value
10649
  end
10650
10651 909:cbb26bc654de Chris
  # Return true if token has expired
10652 0:513646585e45 Chris
  def expired?
10653
    return Time.now > self.created_on + @@validity_time
10654
  end
10655 909:cbb26bc654de Chris
10656 0:513646585e45 Chris
  # Delete all expired tokens
10657
  def self.destroy_expired
10658 1115:433d4f72a19b Chris
    Token.delete_all ["action NOT IN (?) AND created_on < ?", ['feeds', 'api'], Time.now - @@validity_time]
10659 0:513646585e45 Chris
  end
10660 909:cbb26bc654de Chris
10661 1464:261b3d9a4903 Chris
  # Returns the active user who owns the key for the given action
10662
  def self.find_active_user(action, key, validity_days=nil)
10663
    user = find_user(action, key, validity_days)
10664
    if user && user.active?
10665
      user
10666
    end
10667
  end
10668
10669
  # Returns the user who owns the key for the given action
10670
  def self.find_user(action, key, validity_days=nil)
10671
    token = find_token(action, key, validity_days)
10672
    if token
10673
      token.user
10674
    end
10675
  end
10676
10677
  # Returns the token for action and key with an optional
10678
  # validity duration (in number of days)
10679
  def self.find_token(action, key, validity_days=nil)
10680
    action = action.to_s
10681
    key = key.to_s
10682
    return nil unless action.present? && key =~ /\A[a-z0-9]+\z/i
10683
10684
    token = Token.where(:action => action, :value => key).first
10685
    if token && (token.action == action) && (token.value == key) && token.user
10686
      if validity_days.nil? || (token.created_on > validity_days.days.ago)
10687
        token
10688
      end
10689
    end
10690
  end
10691
10692 0:513646585e45 Chris
  def self.generate_token_value
10693 1115:433d4f72a19b Chris
    Redmine::Utils.random_hex(20)
10694 0:513646585e45 Chris
  end
10695 909:cbb26bc654de Chris
10696 1464:261b3d9a4903 Chris
  private
10697
10698 0:513646585e45 Chris
  # Removes obsolete tokens (same user and action)
10699
  def delete_previous_tokens
10700
    if user
10701
      Token.delete_all(['user_id = ? AND action = ?', user.id, action])
10702
    end
10703
  end
10704
end
10705 909:cbb26bc654de Chris
# Redmine - project management software
10706 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
10707 0:513646585e45 Chris
#
10708
# This program is free software; you can redistribute it and/or
10709
# modify it under the terms of the GNU General Public License
10710
# as published by the Free Software Foundation; either version 2
10711
# of the License, or (at your option) any later version.
10712 909:cbb26bc654de Chris
#
10713 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
10714
# but WITHOUT ANY WARRANTY; without even the implied warranty of
10715
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
10716
# GNU General Public License for more details.
10717 909:cbb26bc654de Chris
#
10718 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
10719
# along with this program; if not, write to the Free Software
10720
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
10721
10722
class Tracker < ActiveRecord::Base
10723 1115:433d4f72a19b Chris
10724
  CORE_FIELDS_UNDISABLABLE = %w(project_id tracker_id subject description priority_id is_private).freeze
10725
  # Fields that can be disabled
10726
  # Other (future) fields should be appended, not inserted!
10727
  CORE_FIELDS = %w(assigned_to_id category_id fixed_version_id parent_issue_id start_date due_date estimated_hours done_ratio).freeze
10728
  CORE_FIELDS_ALL = (CORE_FIELDS_UNDISABLABLE + CORE_FIELDS).freeze
10729
10730 909:cbb26bc654de Chris
  before_destroy :check_integrity
10731 0:513646585e45 Chris
  has_many :issues
10732 1115:433d4f72a19b Chris
  has_many :workflow_rules, :dependent => :delete_all do
10733 0:513646585e45 Chris
    def copy(source_tracker)
10734 1115:433d4f72a19b Chris
      WorkflowRule.copy(source_tracker, nil, proxy_association.owner, nil)
10735 0:513646585e45 Chris
    end
10736
  end
10737 909:cbb26bc654de Chris
10738 0:513646585e45 Chris
  has_and_belongs_to_many :projects
10739
  has_and_belongs_to_many :custom_fields, :class_name => 'IssueCustomField', :join_table => "#{table_name_prefix}custom_fields_trackers#{table_name_suffix}", :association_foreign_key => 'custom_field_id'
10740
  acts_as_list
10741
10742 1464:261b3d9a4903 Chris
  attr_protected :fields_bits
10743 1115:433d4f72a19b Chris
10744 0:513646585e45 Chris
  validates_presence_of :name
10745
  validates_uniqueness_of :name
10746
  validates_length_of :name, :maximum => 30
10747
10748 1464:261b3d9a4903 Chris
  scope :sorted, lambda { order("#{table_name}.position ASC") }
10749 1115:433d4f72a19b Chris
  scope :named, lambda {|arg| where("LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip)}
10750 909:cbb26bc654de Chris
10751 0:513646585e45 Chris
  def to_s; name end
10752 909:cbb26bc654de Chris
10753 0:513646585e45 Chris
  def <=>(tracker)
10754 1115:433d4f72a19b Chris
    position <=> tracker.position
10755 0:513646585e45 Chris
  end
10756 909:cbb26bc654de Chris
10757 0:513646585e45 Chris
  # Returns an array of IssueStatus that are used
10758
  # in the tracker's workflows
10759
  def issue_statuses
10760
    if @issue_statuses
10761 909:cbb26bc654de Chris
      return @issue_statuses
10762 0:513646585e45 Chris
    elsif new_record?
10763
      return []
10764
    end
10765 909:cbb26bc654de Chris
10766 1115:433d4f72a19b Chris
    ids = WorkflowTransition.
10767
            connection.select_rows("SELECT DISTINCT old_status_id, new_status_id FROM #{WorkflowTransition.table_name} WHERE tracker_id = #{id} AND type = 'WorkflowTransition'").
10768 0:513646585e45 Chris
            flatten.
10769
            uniq
10770 1517:dffacf8a6908 Chris
    @issue_statuses = IssueStatus.where(:id => ids).all.sort
10771 0:513646585e45 Chris
  end
10772 909:cbb26bc654de Chris
10773 1115:433d4f72a19b Chris
  def disabled_core_fields
10774
    i = -1
10775
    @disabled_core_fields ||= CORE_FIELDS.select { i += 1; (fields_bits || 0) & (2 ** i) != 0}
10776
  end
10777
10778
  def core_fields
10779
    CORE_FIELDS - disabled_core_fields
10780
  end
10781
10782
  def core_fields=(fields)
10783
    raise ArgumentError.new("Tracker.core_fields takes an array") unless fields.is_a?(Array)
10784
10785
    bits = 0
10786
    CORE_FIELDS.each_with_index do |field, i|
10787
      unless fields.include?(field)
10788
        bits |= 2 ** i
10789
      end
10790
    end
10791
    self.fields_bits = bits
10792
    @disabled_core_fields = nil
10793
    core_fields
10794
  end
10795
10796
  # Returns the fields that are disabled for all the given trackers
10797
  def self.disabled_core_fields(trackers)
10798
    if trackers.present?
10799
      trackers.uniq.map(&:disabled_core_fields).reduce(:&)
10800
    else
10801
      []
10802
    end
10803
  end
10804
10805
  # Returns the fields that are enabled for one tracker at least
10806
  def self.core_fields(trackers)
10807
    if trackers.present?
10808
      trackers.uniq.map(&:core_fields).reduce(:|)
10809
    else
10810
      CORE_FIELDS.dup
10811
    end
10812
  end
10813
10814 0:513646585e45 Chris
private
10815
  def check_integrity
10816 1115:433d4f72a19b Chris
    raise Exception.new("Can't delete tracker") if Issue.where(:tracker_id => self.id).any?
10817 0:513646585e45 Chris
  end
10818
end
10819
# Redmine - project management software
10820 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
10821 0:513646585e45 Chris
#
10822
# This program is free software; you can redistribute it and/or
10823
# modify it under the terms of the GNU General Public License
10824
# as published by the Free Software Foundation; either version 2
10825
# of the License, or (at your option) any later version.
10826 909:cbb26bc654de Chris
#
10827 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
10828
# but WITHOUT ANY WARRANTY; without even the implied warranty of
10829
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
10830
# GNU General Public License for more details.
10831 909:cbb26bc654de Chris
#
10832 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
10833
# along with this program; if not, write to the Free Software
10834
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
10835
10836
require "digest/sha1"
10837
10838
class User < Principal
10839 119:8661b858af72 Chris
  include Redmine::SafeAttributes
10840 909:cbb26bc654de Chris
10841
  # Different ways of displaying/sorting users
10842 0:513646585e45 Chris
  USER_FORMATS = {
10843 1115:433d4f72a19b Chris
    :firstname_lastname => {
10844
        :string => '#{firstname} #{lastname}',
10845
        :order => %w(firstname lastname id),
10846
        :setting_order => 1
10847
      },
10848
    :firstname_lastinitial => {
10849
        :string => '#{firstname} #{lastname.to_s.chars.first}.',
10850
        :order => %w(firstname lastname id),
10851
        :setting_order => 2
10852
      },
10853 1517:dffacf8a6908 Chris
    :firstinitial_lastname => {
10854
        :string => '#{firstname.to_s.gsub(/(([[:alpha:]])[[:alpha:]]*\.?)/, \'\2.\')} #{lastname}',
10855
        :order => %w(firstname lastname id),
10856
        :setting_order => 2
10857
      },
10858 1115:433d4f72a19b Chris
    :firstname => {
10859
        :string => '#{firstname}',
10860
        :order => %w(firstname id),
10861
        :setting_order => 3
10862
      },
10863
    :lastname_firstname => {
10864
        :string => '#{lastname} #{firstname}',
10865
        :order => %w(lastname firstname id),
10866
        :setting_order => 4
10867
      },
10868
    :lastname_coma_firstname => {
10869
        :string => '#{lastname}, #{firstname}',
10870
        :order => %w(lastname firstname id),
10871
        :setting_order => 5
10872
      },
10873
    :lastname => {
10874
        :string => '#{lastname}',
10875
        :order => %w(lastname id),
10876
        :setting_order => 6
10877
      },
10878
    :username => {
10879
        :string => '#{login}',
10880
        :order => %w(login id),
10881
        :setting_order => 7
10882
      },
10883 0:513646585e45 Chris
  }
10884
10885 37:94944d00e43c chris
  MAIL_NOTIFICATION_OPTIONS = [
10886 119:8661b858af72 Chris
    ['all', :label_user_mail_option_all],
10887
    ['selected', :label_user_mail_option_selected],
10888
    ['only_my_events', :label_user_mail_option_only_my_events],
10889
    ['only_assigned', :label_user_mail_option_only_assigned],
10890
    ['only_owner', :label_user_mail_option_only_owner],
10891
    ['none', :label_user_mail_option_none]
10892
  ]
10893 37:94944d00e43c chris
10894 1517:dffacf8a6908 Chris
  has_and_belongs_to_many :groups,
10895
                          :join_table   => "#{table_name_prefix}groups_users#{table_name_suffix}",
10896
                          :after_add    => Proc.new {|user, group| group.user_added(user)},
10897
                          :after_remove => Proc.new {|user, group| group.user_removed(user)}
10898 0:513646585e45 Chris
  has_many :changesets, :dependent => :nullify
10899
  has_one :preference, :dependent => :destroy, :class_name => 'UserPreference'
10900 128:07fa8a8b56a8 Chris
  has_one :rss_token, :class_name => 'Token', :conditions => "action='feeds'"
10901
  has_one :api_token, :class_name => 'Token', :conditions => "action='api'"
10902 0:513646585e45 Chris
  belongs_to :auth_source
10903 909:cbb26bc654de Chris
10904 1464:261b3d9a4903 Chris
  scope :logged, lambda { where("#{User.table_name}.status <> #{STATUS_ANONYMOUS}") }
10905
  scope :status, lambda {|arg| where(arg.blank? ? nil : {:status => arg.to_i}) }
10906 909:cbb26bc654de Chris
10907 55:bbb139d5ca95 luisf
  has_one :ssamr_user_detail, :dependent => :destroy, :class_name => 'SsamrUserDetail'
10908 65:69ee2e406f71 luisf
  accepts_nested_attributes_for :ssamr_user_detail
10909 403:b15397a5341c luis
10910
  has_one :author
10911 64:9d42bcda8cea luisf
10912 0:513646585e45 Chris
  acts_as_customizable
10913 909:cbb26bc654de Chris
10914 1464:261b3d9a4903 Chris
  attr_accessor :password, :password_confirmation, :generate_password
10915 0:513646585e45 Chris
  attr_accessor :last_before_login_on
10916
  # Prevents unauthorized assignments
10917 119:8661b858af72 Chris
  attr_protected :login, :admin, :password, :password_confirmation, :hashed_password
10918 1115:433d4f72a19b Chris
10919
  LOGIN_LENGTH_LIMIT = 60
10920
  MAIL_LENGTH_LIMIT = 60
10921
10922 0:513646585e45 Chris
  validates_presence_of :login, :firstname, :lastname, :mail, :if => Proc.new { |user| !user.is_a?(AnonymousUser) }
10923 1115:433d4f72a19b Chris
  validates_uniqueness_of :login, :if => Proc.new { |user| user.login_changed? && user.login.present? }, :case_sensitive => false
10924
  validates_uniqueness_of :mail, :if => Proc.new { |user| user.mail_changed? && user.mail.present? }, :case_sensitive => false
10925 1484:51364c0cd58f Chris
10926 1464:261b3d9a4903 Chris
  # Login must contain letters, numbers, underscores only
10927
  validates_format_of :login, :with => /\A[a-z0-9_\-@\.]*\z/i
10928 1115:433d4f72a19b Chris
  validates_length_of :login, :maximum => LOGIN_LENGTH_LIMIT
10929 0:513646585e45 Chris
  validates_length_of :firstname, :lastname, :maximum => 30
10930 1464:261b3d9a4903 Chris
  validates_format_of :mail, :with => /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\z/i, :allow_blank => true
10931 1115:433d4f72a19b Chris
  validates_length_of :mail, :maximum => MAIL_LENGTH_LIMIT, :allow_nil => true
10932 0:513646585e45 Chris
  validates_confirmation_of :password, :allow_nil => true
10933 119:8661b858af72 Chris
  validates_inclusion_of :mail_notification, :in => MAIL_NOTIFICATION_OPTIONS.collect(&:first), :allow_blank => true
10934 909:cbb26bc654de Chris
  validate :validate_password_length
10935 0:513646585e45 Chris
10936 909:cbb26bc654de Chris
  before_create :set_mail_notification
10937 1464:261b3d9a4903 Chris
  before_save   :generate_password_if_needed, :update_hashed_password
10938 128:07fa8a8b56a8 Chris
  before_destroy :remove_references_before_destroy
10939 1464:261b3d9a4903 Chris
  after_save :update_notified_project_ids
10940 909:cbb26bc654de Chris
10941 190:440c4f4bf2d6 luisf
  validates_acceptance_of :terms_and_conditions, :on => :create, :message => :must_accept_terms_and_conditions
10942
10943 1115:433d4f72a19b Chris
  scope :in_group, lambda {|group|
10944 441:cbce1fd3b1b7 Chris
    group_id = group.is_a?(Group) ? group.id : group.to_i
10945 1115:433d4f72a19b Chris
    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)
10946 441:cbce1fd3b1b7 Chris
  }
10947 1115:433d4f72a19b Chris
  scope :not_in_group, lambda {|group|
10948 441:cbce1fd3b1b7 Chris
    group_id = group.is_a?(Group) ? group.id : group.to_i
10949 1115:433d4f72a19b Chris
    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)
10950 441:cbce1fd3b1b7 Chris
  }
10951 1464:261b3d9a4903 Chris
  scope :sorted, lambda { order(*User.fields_for_order_statement)}
10952 909:cbb26bc654de Chris
10953
  def set_mail_notification
10954 37:94944d00e43c chris
    self.mail_notification = Setting.default_notification_option if self.mail_notification.blank?
10955 0:513646585e45 Chris
    true
10956
  end
10957 909:cbb26bc654de Chris
10958
  def update_hashed_password
10959 0:513646585e45 Chris
    # update hashed_password if password was set
10960 245:051f544170fe Chris
    if self.password && self.auth_source_id.blank?
10961
      salt_password(password)
10962
    end
10963 0:513646585e45 Chris
  end
10964 909:cbb26bc654de Chris
10965 1464:261b3d9a4903 Chris
  alias :base_reload :reload
10966 0:513646585e45 Chris
  def reload(*args)
10967
    @name = nil
10968 441:cbce1fd3b1b7 Chris
    @projects_by_role = nil
10969 1464:261b3d9a4903 Chris
    @membership_by_project_id = nil
10970
    @notified_projects_ids = nil
10971
    @notified_projects_ids_changed = false
10972
    @builtin_role = nil
10973
    base_reload(*args)
10974 0:513646585e45 Chris
  end
10975 909:cbb26bc654de Chris
10976 1:cca12e1c1fd4 Chris
  def mail=(arg)
10977
    write_attribute(:mail, arg.to_s.strip)
10978
  end
10979 909:cbb26bc654de Chris
10980 59:7ff14a13f48a luisf
  def description=(arg)
10981
    write_attribute(:description, arg.to_s.strip)
10982
  end
10983
10984 0:513646585e45 Chris
  def identity_url=(url)
10985
    if url.blank?
10986
      write_attribute(:identity_url, '')
10987
    else
10988
      begin
10989
        write_attribute(:identity_url, OpenIdAuthentication.normalize_identifier(url))
10990
      rescue OpenIdAuthentication::InvalidOpenId
10991 1464:261b3d9a4903 Chris
        # Invalid url, don't save
10992 0:513646585e45 Chris
      end
10993
    end
10994
    self.read_attribute(:identity_url)
10995
  end
10996 909:cbb26bc654de Chris
10997 0:513646585e45 Chris
  # Returns the user that matches provided login and password, or nil
10998 1464:261b3d9a4903 Chris
  def self.try_to_login(login, password, active_only=true)
10999 1115:433d4f72a19b Chris
    login = login.to_s
11000
    password = password.to_s
11001
11002 1464:261b3d9a4903 Chris
    # Make sure no one can sign in with an empty login or password
11003
    return nil if login.empty? || password.empty?
11004 0:513646585e45 Chris
    user = find_by_login(login)
11005
    if user
11006
      # user is already in local database
11007 1464:261b3d9a4903 Chris
      return nil unless user.check_password?(password)
11008
      return nil if !user.active? && active_only
11009 0:513646585e45 Chris
    else
11010
      # user is not yet registered, try to authenticate with available sources
11011
      attrs = AuthSource.authenticate(login, password)
11012
      if attrs
11013
        user = new(attrs)
11014
        user.login = login
11015
        user.language = Setting.default_language
11016
        if user.save
11017
          user.reload
11018
          logger.info("User '#{user.login}' created from external auth source: #{user.auth_source.type} - #{user.auth_source.name}") if logger && user.auth_source
11019
        end
11020
      end
11021 909:cbb26bc654de Chris
    end
11022 1464:261b3d9a4903 Chris
    user.update_column(:last_login_on, Time.now) if user && !user.new_record? && user.active?
11023 0:513646585e45 Chris
    user
11024
  rescue => text
11025
    raise text
11026
  end
11027 909:cbb26bc654de Chris
11028 0:513646585e45 Chris
  # Returns the user who matches the given autologin +key+ or nil
11029
  def self.try_to_autologin(key)
11030 1464:261b3d9a4903 Chris
    user = Token.find_active_user('autologin', key, Setting.autologin.to_i)
11031
    if user
11032
      user.update_column(:last_login_on, Time.now)
11033
      user
11034 0:513646585e45 Chris
    end
11035
  end
11036 909:cbb26bc654de Chris
11037
  def self.name_formatter(formatter = nil)
11038
    USER_FORMATS[formatter || Setting.user_format] || USER_FORMATS[:firstname_lastname]
11039
  end
11040
11041
  # Returns an array of fields names than can be used to make an order statement for users
11042
  # according to how user names are displayed
11043
  # Examples:
11044
  #
11045
  #   User.fields_for_order_statement              => ['users.login', 'users.id']
11046
  #   User.fields_for_order_statement('authors')   => ['authors.login', 'authors.id']
11047
  def self.fields_for_order_statement(table=nil)
11048
    table ||= table_name
11049
    name_formatter[:order].map {|field| "#{table}.#{field}"}
11050
  end
11051
11052 0:513646585e45 Chris
  # Return user's full name for display
11053
  def name(formatter = nil)
11054 909:cbb26bc654de Chris
    f = self.class.name_formatter(formatter)
11055 0:513646585e45 Chris
    if formatter
11056 909:cbb26bc654de Chris
      eval('"' + f[:string] + '"')
11057 0:513646585e45 Chris
    else
11058 909:cbb26bc654de Chris
      @name ||= eval('"' + f[:string] + '"')
11059 0:513646585e45 Chris
    end
11060
  end
11061 909:cbb26bc654de Chris
11062 0:513646585e45 Chris
  def active?
11063
    self.status == STATUS_ACTIVE
11064
  end
11065
11066
  def registered?
11067
    self.status == STATUS_REGISTERED
11068
  end
11069 909:cbb26bc654de Chris
11070 0:513646585e45 Chris
  def locked?
11071
    self.status == STATUS_LOCKED
11072
  end
11073
11074 14:1d32c0a0efbf Chris
  def activate
11075
    self.status = STATUS_ACTIVE
11076
  end
11077
11078
  def register
11079
    self.status = STATUS_REGISTERED
11080
  end
11081
11082
  def lock
11083
    self.status = STATUS_LOCKED
11084
  end
11085
11086
  def activate!
11087
    update_attribute(:status, STATUS_ACTIVE)
11088
  end
11089
11090
  def register!
11091
    update_attribute(:status, STATUS_REGISTERED)
11092
  end
11093
11094
  def lock!
11095
    update_attribute(:status, STATUS_LOCKED)
11096
  end
11097
11098 245:051f544170fe Chris
  # Returns true if +clear_password+ is the correct user's password, otherwise false
11099 0:513646585e45 Chris
  def check_password?(clear_password)
11100
    if auth_source_id.present?
11101
      auth_source.authenticate(self.login, clear_password)
11102
    else
11103 245:051f544170fe Chris
      User.hash_password("#{salt}#{User.hash_password clear_password}") == hashed_password
11104 0:513646585e45 Chris
    end
11105
  end
11106 909:cbb26bc654de Chris
11107 245:051f544170fe Chris
  # Generates a random salt and computes hashed_password for +clear_password+
11108
  # The hashed password is stored in the following form: SHA1(salt + SHA1(password))
11109
  def salt_password(clear_password)
11110
    self.salt = User.generate_salt
11111
    self.hashed_password = User.hash_password("#{salt}#{User.hash_password clear_password}")
11112
  end
11113 0:513646585e45 Chris
11114
  # Does the backend storage allow this user to change their password?
11115
  def change_password_allowed?
11116 1115:433d4f72a19b Chris
    return true if auth_source.nil?
11117 0:513646585e45 Chris
    return auth_source.allow_password_changes?
11118
  end
11119
11120 1464:261b3d9a4903 Chris
  def must_change_password?
11121
    must_change_passwd? && change_password_allowed?
11122
  end
11123
11124
  def generate_password?
11125
    generate_password == '1' || generate_password == true
11126
  end
11127
11128
  # Generate and set a random password on given length
11129
  def random_password(length=40)
11130 0:513646585e45 Chris
    chars = ("a".."z").to_a + ("A".."Z").to_a + ("0".."9").to_a
11131 1464:261b3d9a4903 Chris
    chars -= %w(0 O 1 l)
11132 0:513646585e45 Chris
    password = ''
11133 1464:261b3d9a4903 Chris
    length.times {|i| password << chars[SecureRandom.random_number(chars.size)] }
11134 0:513646585e45 Chris
    self.password = password
11135
    self.password_confirmation = password
11136
    self
11137
  end
11138 909:cbb26bc654de Chris
11139 0:513646585e45 Chris
  def pref
11140
    self.preference ||= UserPreference.new(:user => self)
11141
  end
11142 909:cbb26bc654de Chris
11143 0:513646585e45 Chris
  def time_zone
11144
    @time_zone ||= (self.pref.time_zone.blank? ? nil : ActiveSupport::TimeZone[self.pref.time_zone])
11145
  end
11146 909:cbb26bc654de Chris
11147 1517:dffacf8a6908 Chris
  def force_default_language?
11148
    Setting.force_default_language_for_loggedin?
11149
  end
11150
11151
  def language
11152
    if force_default_language?
11153
      Setting.default_language
11154
    else
11155
      super
11156
    end
11157
  end
11158
11159 0:513646585e45 Chris
  def wants_comments_in_reverse_order?
11160
    self.pref[:comments_sorting] == 'desc'
11161
  end
11162 909:cbb26bc654de Chris
11163 0:513646585e45 Chris
  # Return user's RSS key (a 40 chars long string), used to access feeds
11164
  def rss_key
11165 1115:433d4f72a19b Chris
    if rss_token.nil?
11166
      create_rss_token(:action => 'feeds')
11167
    end
11168
    rss_token.value
11169 0:513646585e45 Chris
  end
11170
11171
  # Return user's API key (a 40 chars long string), used to access the API
11172
  def api_key
11173 1115:433d4f72a19b Chris
    if api_token.nil?
11174
      create_api_token(:action => 'api')
11175
    end
11176
    api_token.value
11177 0:513646585e45 Chris
  end
11178 909:cbb26bc654de Chris
11179 0:513646585e45 Chris
  # Return an array of project ids for which the user has explicitly turned mail notifications on
11180
  def notified_projects_ids
11181
    @notified_projects_ids ||= memberships.select {|m| m.mail_notification?}.collect(&:project_id)
11182
  end
11183 909:cbb26bc654de Chris
11184 0:513646585e45 Chris
  def notified_project_ids=(ids)
11185 1464:261b3d9a4903 Chris
    @notified_projects_ids_changed = true
11186
    @notified_projects_ids = ids
11187 0:513646585e45 Chris
  end
11188
11189 1464:261b3d9a4903 Chris
  # Updates per project notifications (after_save callback)
11190
  def update_notified_project_ids
11191
    if @notified_projects_ids_changed
11192
      ids = (mail_notification == 'selected' ? Array.wrap(notified_projects_ids).reject(&:blank?) : [])
11193
      members.update_all(:mail_notification => false)
11194
      members.where(:project_id => ids).update_all(:mail_notification => true) if ids.any?
11195
    end
11196
  end
11197
  private :update_notified_project_ids
11198
11199 128:07fa8a8b56a8 Chris
  def valid_notification_options
11200
    self.class.valid_notification_options(self)
11201
  end
11202
11203 37:94944d00e43c chris
  # Only users that belong to more than 1 project can select projects for which they are notified
11204 128:07fa8a8b56a8 Chris
  def self.valid_notification_options(user=nil)
11205 37:94944d00e43c chris
    # Note that @user.membership.size would fail since AR ignores
11206
    # :include association option when doing a count
11207 128:07fa8a8b56a8 Chris
    if user.nil? || user.memberships.length < 1
11208
      MAIL_NOTIFICATION_OPTIONS.reject {|option| option.first == 'selected'}
11209 37:94944d00e43c chris
    else
11210
      MAIL_NOTIFICATION_OPTIONS
11211
    end
11212
  end
11213
11214 0:513646585e45 Chris
  # Find a user account by matching the exact login and then a case-insensitive
11215
  # version.  Exact matches will be given priority.
11216
  def self.find_by_login(login)
11217 1517:dffacf8a6908 Chris
    login = Redmine::CodesetUtil.replace_invalid_utf8(login.to_s)
11218 1464:261b3d9a4903 Chris
    if login.present?
11219
      # First look for an exact match
11220 1517:dffacf8a6908 Chris
      user = where(:login => login).detect {|u| u.login == login}
11221 1464:261b3d9a4903 Chris
      unless user
11222
        # Fail over to case-insensitive if none was found
11223
        user = where("LOWER(login) = ?", login.downcase).first
11224
      end
11225
      user
11226 1115:433d4f72a19b Chris
    end
11227 0:513646585e45 Chris
  end
11228
11229
  def self.find_by_rss_key(key)
11230 1464:261b3d9a4903 Chris
    Token.find_active_user('feeds', key)
11231 0:513646585e45 Chris
  end
11232 909:cbb26bc654de Chris
11233 0:513646585e45 Chris
  def self.find_by_api_key(key)
11234 1464:261b3d9a4903 Chris
    Token.find_active_user('api', key)
11235 0:513646585e45 Chris
  end
11236 909:cbb26bc654de Chris
11237 0:513646585e45 Chris
  # Makes find_by_mail case-insensitive
11238
  def self.find_by_mail(mail)
11239 1115:433d4f72a19b Chris
    where("LOWER(mail) = ?", mail.to_s.downcase).first
11240 0:513646585e45 Chris
  end
11241 909:cbb26bc654de Chris
11242 929:5f33065ddc4b Chris
  # Returns true if the default admin account can no longer be used
11243
  def self.default_admin_account_changed?
11244
    !User.active.find_by_login("admin").try(:check_password?, "admin")
11245
  end
11246
11247 0:513646585e45 Chris
  def to_s
11248
    name
11249
  end
11250 909:cbb26bc654de Chris
11251 1115:433d4f72a19b Chris
  CSS_CLASS_BY_STATUS = {
11252
    STATUS_ANONYMOUS  => 'anon',
11253
    STATUS_ACTIVE     => 'active',
11254
    STATUS_REGISTERED => 'registered',
11255
    STATUS_LOCKED     => 'locked'
11256
  }
11257
11258
  def css_classes
11259
    "user #{CSS_CLASS_BY_STATUS[status]}"
11260
  end
11261
11262 0:513646585e45 Chris
  # Returns the current day according to user's time zone
11263
  def today
11264
    if time_zone.nil?
11265
      Date.today
11266
    else
11267
      Time.now.in_time_zone(time_zone).to_date
11268
    end
11269
  end
11270 909:cbb26bc654de Chris
11271 1115:433d4f72a19b Chris
  # Returns the day of +time+ according to user's time zone
11272
  def time_to_date(time)
11273
    if time_zone.nil?
11274
      time.to_date
11275
    else
11276
      time.in_time_zone(time_zone).to_date
11277
    end
11278
  end
11279
11280 0:513646585e45 Chris
  def logged?
11281
    true
11282
  end
11283 909:cbb26bc654de Chris
11284 0:513646585e45 Chris
  def anonymous?
11285
    !logged?
11286
  end
11287 909:cbb26bc654de Chris
11288 1464:261b3d9a4903 Chris
  # Returns user's membership for the given project
11289
  # or nil if the user is not a member of project
11290
  def membership(project)
11291
    project_id = project.is_a?(Project) ? project.id : project
11292
11293
    @membership_by_project_id ||= Hash.new {|h, project_id|
11294
      h[project_id] = memberships.where(:project_id => project_id).first
11295
    }
11296
    @membership_by_project_id[project_id]
11297
  end
11298
11299
  # Returns the user's bult-in role
11300
  def builtin_role
11301
    @builtin_role ||= Role.non_member
11302
  end
11303
11304 0:513646585e45 Chris
  # Return user's roles for project
11305
  def roles_for_project(project)
11306
    roles = []
11307
    # No role on archived projects
11308 1115:433d4f72a19b Chris
    return roles if project.nil? || project.archived?
11309 1464:261b3d9a4903 Chris
    if membership = membership(project)
11310
      roles = membership.roles
11311 0:513646585e45 Chris
    else
11312 1464:261b3d9a4903 Chris
      roles << builtin_role
11313 0:513646585e45 Chris
    end
11314
    roles
11315
  end
11316 909:cbb26bc654de Chris
11317 0:513646585e45 Chris
  # Return true if the user is a member of project
11318
  def member_of?(project)
11319 1464:261b3d9a4903 Chris
    projects.to_a.include?(project)
11320 0:513646585e45 Chris
  end
11321 909:cbb26bc654de Chris
11322 441:cbce1fd3b1b7 Chris
  # Returns a hash of user's projects grouped by roles
11323
  def projects_by_role
11324
    return @projects_by_role if @projects_by_role
11325 909:cbb26bc654de Chris
11326 1115:433d4f72a19b Chris
    @projects_by_role = Hash.new([])
11327 441:cbce1fd3b1b7 Chris
    memberships.each do |membership|
11328 1115:433d4f72a19b Chris
      if membership.project
11329
        membership.roles.each do |role|
11330
          @projects_by_role[role] = [] unless @projects_by_role.key?(role)
11331
          @projects_by_role[role] << membership.project
11332
        end
11333 441:cbce1fd3b1b7 Chris
      end
11334
    end
11335
    @projects_by_role.each do |role, projects|
11336
      projects.uniq!
11337
    end
11338 909:cbb26bc654de Chris
11339 441:cbce1fd3b1b7 Chris
    @projects_by_role
11340
  end
11341 909:cbb26bc654de Chris
11342
  # Returns true if user is arg or belongs to arg
11343
  def is_or_belongs_to?(arg)
11344
    if arg.is_a?(User)
11345
      self == arg
11346
    elsif arg.is_a?(Group)
11347
      arg.users.include?(self)
11348
    else
11349
      false
11350
    end
11351
  end
11352
11353 37:94944d00e43c chris
  # Return true if the user is allowed to do the specified action on a specific context
11354
  # Action can be:
11355 0:513646585e45 Chris
  # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
11356
  # * a permission Symbol (eg. :edit_project)
11357 37:94944d00e43c chris
  # Context can be:
11358
  # * a project : returns true if user is allowed to do the specified action on this project
11359 441:cbce1fd3b1b7 Chris
  # * an array of projects : returns true if user is allowed on every project
11360 909:cbb26bc654de Chris
  # * nil with options[:global] set : check if user has at least one role allowed for this action,
11361 37:94944d00e43c chris
  #   or falls back to Non Member / Anonymous permissions depending if the user is logged
11362 441:cbce1fd3b1b7 Chris
  def allowed_to?(action, context, options={}, &block)
11363 37:94944d00e43c chris
    if context && context.is_a?(Project)
11364
      return false unless context.allows_to?(action)
11365 0:513646585e45 Chris
      # Admin users are authorized for anything else
11366
      return true if admin?
11367 909:cbb26bc654de Chris
11368 37:94944d00e43c chris
      roles = roles_for_project(context)
11369 0:513646585e45 Chris
      return false unless roles
11370 1115:433d4f72a19b Chris
      roles.any? {|role|
11371 441:cbce1fd3b1b7 Chris
        (context.is_public? || role.member?) &&
11372
        role.allowed_to?(action) &&
11373
        (block_given? ? yield(role, self) : true)
11374
      }
11375 37:94944d00e43c chris
    elsif context && context.is_a?(Array)
11376 1115:433d4f72a19b Chris
      if context.empty?
11377
        false
11378
      else
11379
        # Authorize if user is authorized on every element of the array
11380
        context.map {|project| allowed_to?(action, project, options, &block)}.reduce(:&)
11381 37:94944d00e43c chris
      end
11382 0:513646585e45 Chris
    elsif options[:global]
11383
      # Admin users are always authorized
11384
      return true if admin?
11385 909:cbb26bc654de Chris
11386 0:513646585e45 Chris
      # authorize if user has at least one role that has this permission
11387
      roles = memberships.collect {|m| m.roles}.flatten.uniq
11388 441:cbce1fd3b1b7 Chris
      roles << (self.logged? ? Role.non_member : Role.anonymous)
11389 1115:433d4f72a19b Chris
      roles.any? {|role|
11390 441:cbce1fd3b1b7 Chris
        role.allowed_to?(action) &&
11391
        (block_given? ? yield(role, self) : true)
11392
      }
11393 0:513646585e45 Chris
    else
11394
      false
11395
    end
11396
  end
11397 22:40f7cfd4df19 chris
11398
  # Is the user allowed to do the specified action on any project?
11399
  # See allowed_to? for the actions and valid options.
11400 441:cbce1fd3b1b7 Chris
  def allowed_to_globally?(action, options, &block)
11401
    allowed_to?(action, nil, options.reverse_merge(:global => true), &block)
11402 22:40f7cfd4df19 chris
  end
11403 119:8661b858af72 Chris
11404 1464:261b3d9a4903 Chris
  # Returns true if the user is allowed to delete the user's own account
11405 1115:433d4f72a19b Chris
  def own_account_deletable?
11406
    Setting.unsubscribe? &&
11407
      (!admin? || User.active.where("admin = ? AND id <> ?", true, id).exists?)
11408
  end
11409
11410 119:8661b858af72 Chris
  safe_attributes 'login',
11411
    'firstname',
11412
    'lastname',
11413
    'mail',
11414
    'mail_notification',
11415 1464:261b3d9a4903 Chris
    'notified_project_ids',
11416 119:8661b858af72 Chris
    'language',
11417
    'custom_field_values',
11418
    'custom_fields',
11419
    'identity_url'
11420 909:cbb26bc654de Chris
11421 119:8661b858af72 Chris
  safe_attributes 'status',
11422
    'auth_source_id',
11423 1464:261b3d9a4903 Chris
    'generate_password',
11424
    'must_change_passwd',
11425 119:8661b858af72 Chris
    :if => lambda {|user, current_user| current_user.admin?}
11426 909:cbb26bc654de Chris
11427 119:8661b858af72 Chris
  safe_attributes 'group_ids',
11428
    :if => lambda {|user, current_user| current_user.admin? && !user.new_record?}
11429 909:cbb26bc654de Chris
11430 37:94944d00e43c chris
  # Utility method to help check if a user should be notified about an
11431
  # event.
11432
  #
11433
  # TODO: only supports Issue events currently
11434
  def notify_about?(object)
11435 1464:261b3d9a4903 Chris
    if mail_notification == 'all'
11436 37:94944d00e43c chris
      true
11437 1464:261b3d9a4903 Chris
    elsif mail_notification.blank? || mail_notification == 'none'
11438
      false
11439
    else
11440
      case object
11441
      when Issue
11442
        case mail_notification
11443
        when 'selected', 'only_my_events'
11444
          # user receives notifications for created/assigned issues on unselected projects
11445
          object.author == self || is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.assigned_to_was)
11446
        when 'only_assigned'
11447
          is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.assigned_to_was)
11448
        when 'only_owner'
11449
          object.author == self
11450
        end
11451
      when News
11452
        # always send to project members except when mail_notification is set to 'none'
11453 210:0579821a129a Chris
        true
11454
      end
11455 37:94944d00e43c chris
    end
11456
  end
11457 909:cbb26bc654de Chris
11458 0:513646585e45 Chris
  def self.current=(user)
11459 1464:261b3d9a4903 Chris
    Thread.current[:current_user] = user
11460 0:513646585e45 Chris
  end
11461 909:cbb26bc654de Chris
11462 0:513646585e45 Chris
  def self.current
11463 1464:261b3d9a4903 Chris
    Thread.current[:current_user] ||= User.anonymous
11464 0:513646585e45 Chris
  end
11465 909:cbb26bc654de Chris
11466 0:513646585e45 Chris
  # Returns the anonymous user.  If the anonymous user does not exist, it is created.  There can be only
11467
  # one anonymous user per database.
11468
  def self.anonymous
11469 1115:433d4f72a19b Chris
    anonymous_user = AnonymousUser.first
11470 0:513646585e45 Chris
    if anonymous_user.nil?
11471
      anonymous_user = AnonymousUser.create(:lastname => 'Anonymous', :firstname => '', :mail => '', :login => '', :status => 0)
11472
      raise 'Unable to create the anonymous user.' if anonymous_user.new_record?
11473
    end
11474
    anonymous_user
11475
  end
11476 245:051f544170fe Chris
11477
  # Salts all existing unsalted passwords
11478
  # It changes password storage scheme from SHA1(password) to SHA1(salt + SHA1(password))
11479
  # This method is used in the SaltPasswords migration and is to be kept as is
11480
  def self.salt_unsalted_passwords!
11481
    transaction do
11482 1115:433d4f72a19b Chris
      User.where("salt IS NULL OR salt = ''").find_each do |user|
11483 245:051f544170fe Chris
        next if user.hashed_password.blank?
11484
        salt = User.generate_salt
11485
        hashed_password = User.hash_password("#{salt}#{user.hashed_password}")
11486 1115:433d4f72a19b Chris
        User.where(:id => user.id).update_all(:salt => salt, :hashed_password => hashed_password)
11487 245:051f544170fe Chris
      end
11488
    end
11489
  end
11490 909:cbb26bc654de Chris
11491 0:513646585e45 Chris
  protected
11492 909:cbb26bc654de Chris
11493
  def validate_password_length
11494 1464:261b3d9a4903 Chris
    return if password.blank? && generate_password?
11495 0:513646585e45 Chris
    # Password length validation based on setting
11496
    if !password.nil? && password.size < Setting.password_min_length.to_i
11497
      errors.add(:password, :too_short, :count => Setting.password_min_length.to_i)
11498
    end
11499
  end
11500 909:cbb26bc654de Chris
11501 0:513646585e45 Chris
  private
11502 909:cbb26bc654de Chris
11503 1464:261b3d9a4903 Chris
  def generate_password_if_needed
11504
    if generate_password? && auth_source.nil?
11505
      length = [Setting.password_min_length.to_i + 2, 10].max
11506
      random_password(length)
11507
    end
11508
  end
11509
11510 128:07fa8a8b56a8 Chris
  # Removes references that are not handled by associations
11511
  # Things that are not deleted are reassociated with the anonymous user
11512
  def remove_references_before_destroy
11513
    return if self.id.nil?
11514 909:cbb26bc654de Chris
11515 128:07fa8a8b56a8 Chris
    substitute = User.anonymous
11516 1517:dffacf8a6908 Chris
    Attachment.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
11517
    Comment.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
11518
    Issue.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
11519
    Issue.where(['assigned_to_id = ?', id]).update_all('assigned_to_id = NULL')
11520
    Journal.where(['user_id = ?', id]).update_all(['user_id = ?', substitute.id])
11521
    JournalDetail.
11522
      where(["property = 'attr' AND prop_key = 'assigned_to_id' AND old_value = ?", id.to_s]).
11523
      update_all(['old_value = ?', substitute.id.to_s])
11524
    JournalDetail.
11525
      where(["property = 'attr' AND prop_key = 'assigned_to_id' AND value = ?", id.to_s]).
11526
      update_all(['value = ?', substitute.id.to_s])
11527
    Message.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
11528
    News.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
11529 128:07fa8a8b56a8 Chris
    # Remove private queries and keep public ones
11530 1464:261b3d9a4903 Chris
    ::Query.delete_all ['user_id = ? AND visibility = ?', id, ::Query::VISIBILITY_PRIVATE]
11531 1517:dffacf8a6908 Chris
    ::Query.where(['user_id = ?', id]).update_all(['user_id = ?', substitute.id])
11532
    TimeEntry.where(['user_id = ?', id]).update_all(['user_id = ?', substitute.id])
11533 128:07fa8a8b56a8 Chris
    Token.delete_all ['user_id = ?', id]
11534
    Watcher.delete_all ['user_id = ?', id]
11535 1517:dffacf8a6908 Chris
    WikiContent.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
11536
    WikiContent::Version.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
11537 128:07fa8a8b56a8 Chris
  end
11538 909:cbb26bc654de Chris
11539 0:513646585e45 Chris
  # Return password digest
11540
  def self.hash_password(clear_password)
11541
    Digest::SHA1.hexdigest(clear_password || "")
11542
  end
11543 909:cbb26bc654de Chris
11544 245:051f544170fe Chris
  # Returns a 128bits random salt as a hex string (32 chars long)
11545
  def self.generate_salt
11546 1115:433d4f72a19b Chris
    Redmine::Utils.random_hex(16)
11547 245:051f544170fe Chris
  end
11548 909:cbb26bc654de Chris
11549 0:513646585e45 Chris
end
11550
11551
class AnonymousUser < User
11552 1115:433d4f72a19b Chris
  validate :validate_anonymous_uniqueness, :on => :create
11553 909:cbb26bc654de Chris
11554 1115:433d4f72a19b Chris
  def validate_anonymous_uniqueness
11555 0:513646585e45 Chris
    # There should be only one AnonymousUser in the database
11556 1464:261b3d9a4903 Chris
    errors.add :base, 'An anonymous user already exists.' if AnonymousUser.exists?
11557 0:513646585e45 Chris
  end
11558 909:cbb26bc654de Chris
11559 0:513646585e45 Chris
  def available_custom_fields
11560
    []
11561
  end
11562 909:cbb26bc654de Chris
11563 0:513646585e45 Chris
  # Overrides a few properties
11564
  def logged?; false end
11565
  def admin; false end
11566
  def name(*args); I18n.t(:label_user_anonymous) end
11567
  def mail; nil end
11568
  def time_zone; nil end
11569
  def rss_key; nil end
11570 909:cbb26bc654de Chris
11571 1115:433d4f72a19b Chris
  def pref
11572
    UserPreference.new(:user => self)
11573
  end
11574
11575 1464:261b3d9a4903 Chris
  # Returns the user's bult-in role
11576
  def builtin_role
11577
    @builtin_role ||= Role.anonymous
11578
  end
11579
11580
  def membership(*args)
11581
    nil
11582
  end
11583
11584
  def member_of?(*args)
11585
    false
11586
  end
11587
11588 128:07fa8a8b56a8 Chris
  # Anonymous user can not be destroyed
11589
  def destroy
11590
    false
11591
  end
11592 0:513646585e45 Chris
end
11593 909:cbb26bc654de Chris
# Redmine - project management software
11594 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
11595 0:513646585e45 Chris
#
11596
# This program is free software; you can redistribute it and/or
11597
# modify it under the terms of the GNU General Public License
11598
# as published by the Free Software Foundation; either version 2
11599
# of the License, or (at your option) any later version.
11600 909:cbb26bc654de Chris
#
11601 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
11602
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11603
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11604
# GNU General Public License for more details.
11605 909:cbb26bc654de Chris
#
11606 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
11607
# along with this program; if not, write to the Free Software
11608
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
11609
11610
class UserCustomField < CustomField
11611
  def type_name
11612
    :label_user_plural
11613
  end
11614
end
11615
11616 909:cbb26bc654de Chris
# Redmine - project management software
11617 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
11618 0:513646585e45 Chris
#
11619
# This program is free software; you can redistribute it and/or
11620
# modify it under the terms of the GNU General Public License
11621
# as published by the Free Software Foundation; either version 2
11622
# of the License, or (at your option) any later version.
11623 909:cbb26bc654de Chris
#
11624 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
11625
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11626
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11627
# GNU General Public License for more details.
11628 909:cbb26bc654de Chris
#
11629 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
11630
# along with this program; if not, write to the Free Software
11631
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
11632
11633
class UserPreference < ActiveRecord::Base
11634
  belongs_to :user
11635
  serialize :others
11636 909:cbb26bc654de Chris
11637 929:5f33065ddc4b Chris
  attr_protected :others, :user_id
11638 909:cbb26bc654de Chris
11639 1115:433d4f72a19b Chris
  before_save :set_others_hash
11640 1464:261b3d9a4903 Chris
11641 1115:433d4f72a19b Chris
  def initialize(attributes=nil, *args)
11642 0:513646585e45 Chris
    super
11643
    self.others ||= {}
11644
  end
11645 909:cbb26bc654de Chris
11646 1115:433d4f72a19b Chris
  def set_others_hash
11647 0:513646585e45 Chris
    self.others ||= {}
11648
  end
11649 909:cbb26bc654de Chris
11650 0:513646585e45 Chris
  def [](attr_name)
11651 1464:261b3d9a4903 Chris
    if has_attribute? attr_name
11652 0:513646585e45 Chris
      super
11653
    else
11654
      others ? others[attr_name] : nil
11655
    end
11656
  end
11657 909:cbb26bc654de Chris
11658 0:513646585e45 Chris
  def []=(attr_name, value)
11659 1464:261b3d9a4903 Chris
    if has_attribute? attr_name
11660 0:513646585e45 Chris
      super
11661
    else
11662 1115:433d4f72a19b Chris
      h = (read_attribute(:others) || {}).dup
11663 0:513646585e45 Chris
      h.update(attr_name => value)
11664
      write_attribute(:others, h)
11665
      value
11666
    end
11667
  end
11668 909:cbb26bc654de Chris
11669 0:513646585e45 Chris
  def comments_sorting; self[:comments_sorting] end
11670
  def comments_sorting=(order); self[:comments_sorting]=order end
11671 909:cbb26bc654de Chris
11672 245:051f544170fe Chris
  def warn_on_leaving_unsaved; self[:warn_on_leaving_unsaved] || '1'; end
11673
  def warn_on_leaving_unsaved=(value); self[:warn_on_leaving_unsaved]=value; end
11674 1464:261b3d9a4903 Chris
11675
  def no_self_notified; (self[:no_self_notified] == true || self[:no_self_notified] == '1'); end
11676
  def no_self_notified=(value); self[:no_self_notified]=value; end
11677 0:513646585e45 Chris
end
11678
# Redmine - project management software
11679 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
11680 0:513646585e45 Chris
#
11681
# This program is free software; you can redistribute it and/or
11682
# modify it under the terms of the GNU General Public License
11683
# as published by the Free Software Foundation; either version 2
11684
# of the License, or (at your option) any later version.
11685 909:cbb26bc654de Chris
#
11686 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
11687
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11688
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11689
# GNU General Public License for more details.
11690 909:cbb26bc654de Chris
#
11691 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
11692
# along with this program; if not, write to the Free Software
11693
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
11694
11695
class Version < ActiveRecord::Base
11696 929:5f33065ddc4b Chris
  include Redmine::SafeAttributes
11697 0:513646585e45 Chris
  after_update :update_issues_from_sharing_change
11698
  belongs_to :project
11699
  has_many :fixed_issues, :class_name => 'Issue', :foreign_key => 'fixed_version_id', :dependent => :nullify
11700
  acts_as_customizable
11701
  acts_as_attachable :view_permission => :view_files,
11702
                     :delete_permission => :manage_files
11703
11704
  VERSION_STATUSES = %w(open locked closed)
11705
  VERSION_SHARINGS = %w(none descendants hierarchy tree system)
11706 909:cbb26bc654de Chris
11707 0:513646585e45 Chris
  validates_presence_of :name
11708
  validates_uniqueness_of :name, :scope => [:project_id]
11709
  validates_length_of :name, :maximum => 60
11710 1464:261b3d9a4903 Chris
  validates :effective_date, :date => true
11711 0:513646585e45 Chris
  validates_inclusion_of :status, :in => VERSION_STATUSES
11712
  validates_inclusion_of :sharing, :in => VERSION_SHARINGS
11713
11714 1115:433d4f72a19b Chris
  scope :named, lambda {|arg| where("LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip)}
11715 1464:261b3d9a4903 Chris
  scope :open, lambda { where(:status => 'open') }
11716 1115:433d4f72a19b Chris
  scope :visible, lambda {|*args|
11717
    includes(:project).where(Project.allowed_to_condition(args.first || User.current, :view_issues))
11718
  }
11719 0:513646585e45 Chris
11720 1464:261b3d9a4903 Chris
  safe_attributes 'name',
11721 929:5f33065ddc4b Chris
    'description',
11722
    'effective_date',
11723
    'due_date',
11724
    'wiki_page_title',
11725
    'status',
11726
    'sharing',
11727 1464:261b3d9a4903 Chris
    'custom_field_values',
11728
    'custom_fields'
11729 929:5f33065ddc4b Chris
11730 0:513646585e45 Chris
  # Returns true if +user+ or current user is allowed to view the version
11731
  def visible?(user=User.current)
11732
    user.allowed_to?(:view_issues, self.project)
11733
  end
11734 909:cbb26bc654de Chris
11735
  # Version files have same visibility as project files
11736
  def attachments_visible?(*args)
11737
    project.present? && project.attachments_visible?(*args)
11738
  end
11739
11740 0:513646585e45 Chris
  def start_date
11741 119:8661b858af72 Chris
    @start_date ||= fixed_issues.minimum('start_date')
11742 0:513646585e45 Chris
  end
11743 909:cbb26bc654de Chris
11744 0:513646585e45 Chris
  def due_date
11745
    effective_date
11746
  end
11747 909:cbb26bc654de Chris
11748
  def due_date=(arg)
11749
    self.effective_date=(arg)
11750
  end
11751
11752 0:513646585e45 Chris
  # Returns the total estimated time for this version
11753
  # (sum of leaves estimated_hours)
11754
  def estimated_hours
11755
    @estimated_hours ||= fixed_issues.leaves.sum(:estimated_hours).to_f
11756
  end
11757 909:cbb26bc654de Chris
11758 0:513646585e45 Chris
  # Returns the total reported time for this version
11759
  def spent_hours
11760 1115:433d4f72a19b Chris
    @spent_hours ||= TimeEntry.joins(:issue).where("#{Issue.table_name}.fixed_version_id = ?", id).sum(:hours).to_f
11761 0:513646585e45 Chris
  end
11762 909:cbb26bc654de Chris
11763 0:513646585e45 Chris
  def closed?
11764
    status == 'closed'
11765
  end
11766
11767
  def open?
11768
    status == 'open'
11769
  end
11770 909:cbb26bc654de Chris
11771 0:513646585e45 Chris
  # Returns true if the version is completed: due date reached and no open issues
11772
  def completed?
11773 1115:433d4f72a19b Chris
    effective_date && (effective_date < Date.today) && (open_issues_count == 0)
11774 0:513646585e45 Chris
  end
11775 22:40f7cfd4df19 chris
11776
  def behind_schedule?
11777 1464:261b3d9a4903 Chris
    if completed_percent == 100
11778 22:40f7cfd4df19 chris
      return false
11779 119:8661b858af72 Chris
    elsif due_date && start_date
11780 1464:261b3d9a4903 Chris
      done_date = start_date + ((due_date - start_date+1)* completed_percent/100).floor
11781 22:40f7cfd4df19 chris
      return done_date <= Date.today
11782
    else
11783
      false # No issues so it's not late
11784
    end
11785
  end
11786 909:cbb26bc654de Chris
11787 0:513646585e45 Chris
  # Returns the completion percentage of this version based on the amount of open/closed issues
11788
  # and the time spent on the open issues.
11789 1464:261b3d9a4903 Chris
  def completed_percent
11790 0:513646585e45 Chris
    if issues_count == 0
11791
      0
11792
    elsif open_issues_count == 0
11793
      100
11794
    else
11795
      issues_progress(false) + issues_progress(true)
11796
    end
11797
  end
11798 909:cbb26bc654de Chris
11799 1464:261b3d9a4903 Chris
  # TODO: remove in Redmine 3.0
11800
  def completed_pourcent
11801
    ActiveSupport::Deprecation.warn "Version#completed_pourcent is deprecated and will be removed in Redmine 3.0. Please use #completed_percent instead."
11802
    completed_percent
11803
  end
11804
11805 0:513646585e45 Chris
  # Returns the percentage of issues that have been marked as 'closed'.
11806 1464:261b3d9a4903 Chris
  def closed_percent
11807 0:513646585e45 Chris
    if issues_count == 0
11808
      0
11809
    else
11810
      issues_progress(false)
11811
    end
11812
  end
11813 909:cbb26bc654de Chris
11814 1464:261b3d9a4903 Chris
  # TODO: remove in Redmine 3.0
11815
  def closed_pourcent
11816
    ActiveSupport::Deprecation.warn "Version#closed_pourcent is deprecated and will be removed in Redmine 3.0. Please use #closed_percent instead."
11817
    closed_percent
11818
  end
11819
11820 0:513646585e45 Chris
  # Returns true if the version is overdue: due date reached and some open issues
11821
  def overdue?
11822
    effective_date && (effective_date < Date.today) && (open_issues_count > 0)
11823
  end
11824 909:cbb26bc654de Chris
11825 0:513646585e45 Chris
  # Returns assigned issues count
11826
  def issues_count
11827 1115:433d4f72a19b Chris
    load_issue_counts
11828
    @issue_count
11829 0:513646585e45 Chris
  end
11830 909:cbb26bc654de Chris
11831 0:513646585e45 Chris
  # Returns the total amount of open issues for this version.
11832
  def open_issues_count
11833 1115:433d4f72a19b Chris
    load_issue_counts
11834
    @open_issues_count
11835 0:513646585e45 Chris
  end
11836
11837
  # Returns the total amount of closed issues for this version.
11838
  def closed_issues_count
11839 1115:433d4f72a19b Chris
    load_issue_counts
11840
    @closed_issues_count
11841 0:513646585e45 Chris
  end
11842 909:cbb26bc654de Chris
11843 0:513646585e45 Chris
  def wiki_page
11844
    if project.wiki && !wiki_page_title.blank?
11845
      @wiki_page ||= project.wiki.find_page(wiki_page_title)
11846
    end
11847
    @wiki_page
11848
  end
11849 909:cbb26bc654de Chris
11850 0:513646585e45 Chris
  def to_s; name end
11851 22:40f7cfd4df19 chris
11852
  def to_s_with_project
11853
    "#{project} - #{name}"
11854
  end
11855 909:cbb26bc654de Chris
11856 1115:433d4f72a19b Chris
  # Versions are sorted by effective_date and name
11857
  # Those with no effective_date are at the end, sorted by name
11858 0:513646585e45 Chris
  def <=>(version)
11859
    if self.effective_date
11860
      if version.effective_date
11861
        if self.effective_date == version.effective_date
11862 1115:433d4f72a19b Chris
          name == version.name ? id <=> version.id : name <=> version.name
11863 0:513646585e45 Chris
        else
11864
          self.effective_date <=> version.effective_date
11865
        end
11866
      else
11867
        -1
11868
      end
11869
    else
11870
      if version.effective_date
11871
        1
11872
      else
11873 1115:433d4f72a19b Chris
        name == version.name ? id <=> version.id : name <=> version.name
11874 0:513646585e45 Chris
      end
11875
    end
11876
  end
11877 909:cbb26bc654de Chris
11878 1115:433d4f72a19b Chris
  def self.fields_for_order_statement(table=nil)
11879
    table ||= table_name
11880
    ["(CASE WHEN #{table}.effective_date IS NULL THEN 1 ELSE 0 END)", "#{table}.effective_date", "#{table}.name", "#{table}.id"]
11881
  end
11882
11883 1517:dffacf8a6908 Chris
  scope :sorted, lambda { order(fields_for_order_statement) }
11884 1115:433d4f72a19b Chris
11885 0:513646585e45 Chris
  # Returns the sharings that +user+ can set the version to
11886
  def allowed_sharings(user = User.current)
11887
    VERSION_SHARINGS.select do |s|
11888
      if sharing == s
11889
        true
11890
      else
11891
        case s
11892
        when 'system'
11893
          # Only admin users can set a systemwide sharing
11894
          user.admin?
11895
        when 'hierarchy', 'tree'
11896
          # Only users allowed to manage versions of the root project can
11897
          # set sharing to hierarchy or tree
11898
          project.nil? || user.allowed_to?(:manage_versions, project.root)
11899
        else
11900
          true
11901
        end
11902
      end
11903
    end
11904
  end
11905 909:cbb26bc654de Chris
11906 0:513646585e45 Chris
  private
11907
11908 1115:433d4f72a19b Chris
  def load_issue_counts
11909
    unless @issue_count
11910
      @open_issues_count = 0
11911
      @closed_issues_count = 0
11912 1517:dffacf8a6908 Chris
      fixed_issues.group(:status).count.each do |status, count|
11913 1115:433d4f72a19b Chris
        if status.is_closed?
11914
          @closed_issues_count += count
11915
        else
11916
          @open_issues_count += count
11917
        end
11918
      end
11919
      @issue_count = @open_issues_count + @closed_issues_count
11920
    end
11921
  end
11922
11923 0:513646585e45 Chris
  # Update the issue's fixed versions. Used if a version's sharing changes.
11924
  def update_issues_from_sharing_change
11925
    if sharing_changed?
11926
      if VERSION_SHARINGS.index(sharing_was).nil? ||
11927
          VERSION_SHARINGS.index(sharing).nil? ||
11928
          VERSION_SHARINGS.index(sharing_was) > VERSION_SHARINGS.index(sharing)
11929
        Issue.update_versions_from_sharing_change self
11930
      end
11931
    end
11932
  end
11933 909:cbb26bc654de Chris
11934 0:513646585e45 Chris
  # Returns the average estimated time of assigned issues
11935
  # or 1 if no issue has an estimated time
11936
  # Used to weigth unestimated issues in progress calculation
11937
  def estimated_average
11938
    if @estimated_average.nil?
11939
      average = fixed_issues.average(:estimated_hours).to_f
11940
      if average == 0
11941
        average = 1
11942
      end
11943
      @estimated_average = average
11944
    end
11945
    @estimated_average
11946
  end
11947 909:cbb26bc654de Chris
11948 0:513646585e45 Chris
  # Returns the total progress of open or closed issues.  The returned percentage takes into account
11949
  # the amount of estimated time set for this version.
11950
  #
11951
  # Examples:
11952
  # issues_progress(true)   => returns the progress percentage for open issues.
11953
  # issues_progress(false)  => returns the progress percentage for closed issues.
11954
  def issues_progress(open)
11955
    @issues_progress ||= {}
11956
    @issues_progress[open] ||= begin
11957
      progress = 0
11958
      if issues_count > 0
11959
        ratio = open ? 'done_ratio' : 100
11960 909:cbb26bc654de Chris
11961 1115:433d4f72a19b Chris
        done = fixed_issues.open(open).sum("COALESCE(estimated_hours, #{estimated_average}) * #{ratio}").to_f
11962 0:513646585e45 Chris
        progress = done / (estimated_average * issues_count)
11963
      end
11964
      progress
11965
    end
11966
  end
11967
end
11968
# Redmine - project management software
11969 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
11970 0:513646585e45 Chris
#
11971
# This program is free software; you can redistribute it and/or
11972
# modify it under the terms of the GNU General Public License
11973
# as published by the Free Software Foundation; either version 2
11974
# of the License, or (at your option) any later version.
11975 909:cbb26bc654de Chris
#
11976 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
11977
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11978
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11979
# GNU General Public License for more details.
11980 909:cbb26bc654de Chris
#
11981 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
11982
# along with this program; if not, write to the Free Software
11983
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
11984
11985
class VersionCustomField < CustomField
11986
  def type_name
11987
    :label_version_plural
11988
  end
11989
end
11990 909:cbb26bc654de Chris
# Redmine - project management software
11991 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
11992 0:513646585e45 Chris
#
11993
# This program is free software; you can redistribute it and/or
11994
# modify it under the terms of the GNU General Public License
11995
# as published by the Free Software Foundation; either version 2
11996
# of the License, or (at your option) any later version.
11997 909:cbb26bc654de Chris
#
11998 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
11999
# but WITHOUT ANY WARRANTY; without even the implied warranty of
12000
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12001
# GNU General Public License for more details.
12002 909:cbb26bc654de Chris
#
12003 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
12004
# along with this program; if not, write to the Free Software
12005
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
12006
12007
class Watcher < ActiveRecord::Base
12008
  belongs_to :watchable, :polymorphic => true
12009
  belongs_to :user
12010 909:cbb26bc654de Chris
12011 0:513646585e45 Chris
  validates_presence_of :user
12012
  validates_uniqueness_of :user_id, :scope => [:watchable_type, :watchable_id]
12013 1115:433d4f72a19b Chris
  validate :validate_user
12014 0:513646585e45 Chris
12015 1464:261b3d9a4903 Chris
  # Returns true if at least one object among objects is watched by user
12016
  def self.any_watched?(objects, user)
12017
    objects = objects.reject(&:new_record?)
12018
    if objects.any?
12019
      objects.group_by {|object| object.class.base_class}.each do |base_class, objects|
12020
        if Watcher.where(:watchable_type => base_class.name, :watchable_id => objects.map(&:id), :user_id => user.id).exists?
12021
          return true
12022
        end
12023
      end
12024
    end
12025
    false
12026
  end
12027
12028 0:513646585e45 Chris
  # Unwatch things that users are no longer allowed to view
12029
  def self.prune(options={})
12030
    if options.has_key?(:user)
12031
      prune_single_user(options[:user], options)
12032
    else
12033
      pruned = 0
12034 1517:dffacf8a6908 Chris
      User.where("id IN (SELECT DISTINCT user_id FROM #{table_name})").each do |user|
12035 0:513646585e45 Chris
        pruned += prune_single_user(user, options)
12036
      end
12037
      pruned
12038
    end
12039
  end
12040 909:cbb26bc654de Chris
12041 0:513646585e45 Chris
  protected
12042 909:cbb26bc654de Chris
12043 1115:433d4f72a19b Chris
  def validate_user
12044 0:513646585e45 Chris
    errors.add :user_id, :invalid unless user.nil? || user.active?
12045
  end
12046 909:cbb26bc654de Chris
12047 0:513646585e45 Chris
  private
12048 909:cbb26bc654de Chris
12049 0:513646585e45 Chris
  def self.prune_single_user(user, options={})
12050
    return unless user.is_a?(User)
12051
    pruned = 0
12052 1517:dffacf8a6908 Chris
    where(:user_id => user.id).each do |watcher|
12053 0:513646585e45 Chris
      next if watcher.watchable.nil?
12054
      if options.has_key?(:project)
12055 1517:dffacf8a6908 Chris
        unless watcher.watchable.respond_to?(:project) &&
12056
                 watcher.watchable.project == options[:project]
12057
          next
12058
        end
12059 0:513646585e45 Chris
      end
12060
      if watcher.watchable.respond_to?(:visible?)
12061
        unless watcher.watchable.visible?(user)
12062
          watcher.destroy
12063
          pruned += 1
12064
        end
12065
      end
12066
    end
12067
    pruned
12068
  end
12069
end
12070
# Redmine - project management software
12071 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
12072 0:513646585e45 Chris
#
12073
# This program is free software; you can redistribute it and/or
12074
# modify it under the terms of the GNU General Public License
12075
# as published by the Free Software Foundation; either version 2
12076
# of the License, or (at your option) any later version.
12077 909:cbb26bc654de Chris
#
12078 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
12079
# but WITHOUT ANY WARRANTY; without even the implied warranty of
12080
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12081
# GNU General Public License for more details.
12082 909:cbb26bc654de Chris
#
12083 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
12084
# along with this program; if not, write to the Free Software
12085
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
12086
12087
class Wiki < ActiveRecord::Base
12088 929:5f33065ddc4b Chris
  include Redmine::SafeAttributes
12089 0:513646585e45 Chris
  belongs_to :project
12090
  has_many :pages, :class_name => 'WikiPage', :dependent => :destroy, :order => 'title'
12091
  has_many :redirects, :class_name => 'WikiRedirect', :dependent => :delete_all
12092 909:cbb26bc654de Chris
12093 0:513646585e45 Chris
  acts_as_watchable
12094 909:cbb26bc654de Chris
12095 0:513646585e45 Chris
  validates_presence_of :start_page
12096 1464:261b3d9a4903 Chris
  validates_format_of :start_page, :with => /\A[^,\.\/\?\;\|\:]*\z/
12097 909:cbb26bc654de Chris
12098 929:5f33065ddc4b Chris
  safe_attributes 'start_page'
12099
12100 0:513646585e45 Chris
  def visible?(user=User.current)
12101
    !user.nil? && user.allowed_to?(:view_wiki_pages, project)
12102
  end
12103 909:cbb26bc654de Chris
12104 0:513646585e45 Chris
  # Returns the wiki page that acts as the sidebar content
12105
  # or nil if no such page exists
12106
  def sidebar
12107
    @sidebar ||= find_page('Sidebar', :with_redirect => false)
12108
  end
12109 909:cbb26bc654de Chris
12110 0:513646585e45 Chris
  # find the page with the given title
12111
  # if page doesn't exist, return a new page
12112
  def find_or_new_page(title)
12113
    title = start_page if title.blank?
12114
    find_page(title) || WikiPage.new(:wiki => self, :title => Wiki.titleize(title))
12115
  end
12116 909:cbb26bc654de Chris
12117 0:513646585e45 Chris
  # find the page with the given title
12118
  def find_page(title, options = {})
12119 441:cbce1fd3b1b7 Chris
    @page_found_with_redirect = false
12120 0:513646585e45 Chris
    title = start_page if title.blank?
12121
    title = Wiki.titleize(title)
12122 1464:261b3d9a4903 Chris
    page = pages.where("LOWER(title) = LOWER(?)", title).first
12123 0:513646585e45 Chris
    if !page && !(options[:with_redirect] == false)
12124
      # search for a redirect
12125 1464:261b3d9a4903 Chris
      redirect = redirects.where("LOWER(title) = LOWER(?)", title).first
12126 441:cbce1fd3b1b7 Chris
      if redirect
12127
        page = find_page(redirect.redirects_to, :with_redirect => false)
12128
        @page_found_with_redirect = true
12129
      end
12130 0:513646585e45 Chris
    end
12131
    page
12132
  end
12133 909:cbb26bc654de Chris
12134 441:cbce1fd3b1b7 Chris
  # Returns true if the last page was found with a redirect
12135
  def page_found_with_redirect?
12136
    @page_found_with_redirect
12137
  end
12138
12139 0:513646585e45 Chris
  # Finds a page by title
12140
  # The given string can be of one of the forms: "title" or "project:title"
12141
  # Examples:
12142
  #   Wiki.find_page("bar", project => foo)
12143
  #   Wiki.find_page("foo:bar")
12144
  def self.find_page(title, options = {})
12145
    project = options[:project]
12146
    if title.to_s =~ %r{^([^\:]+)\:(.*)$}
12147
      project_identifier, title = $1, $2
12148
      project = Project.find_by_identifier(project_identifier) || Project.find_by_name(project_identifier)
12149
    end
12150
    if project && project.wiki
12151
      page = project.wiki.find_page(title)
12152
      if page && page.content
12153
        page
12154
      end
12155
    end
12156
  end
12157 909:cbb26bc654de Chris
12158 0:513646585e45 Chris
  # turn a string into a valid page title
12159
  def self.titleize(title)
12160
    # replace spaces with _ and remove unwanted caracters
12161
    title = title.gsub(/\s+/, '_').delete(',./?;|:') if title
12162
    # upcase the first letter
12163
    title = (title.slice(0..0).upcase + (title.slice(1..-1) || '')) if title
12164
    title
12165 909:cbb26bc654de Chris
  end
12166 0:513646585e45 Chris
end
12167 1115:433d4f72a19b Chris
# Redmine - project management software
12168 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
12169 0:513646585e45 Chris
#
12170
# This program is free software; you can redistribute it and/or
12171
# modify it under the terms of the GNU General Public License
12172
# as published by the Free Software Foundation; either version 2
12173
# of the License, or (at your option) any later version.
12174 441:cbce1fd3b1b7 Chris
#
12175 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
12176
# but WITHOUT ANY WARRANTY; without even the implied warranty of
12177
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12178
# GNU General Public License for more details.
12179 441:cbce1fd3b1b7 Chris
#
12180 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
12181
# along with this program; if not, write to the Free Software
12182
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
12183
12184
require 'zlib'
12185
12186
class WikiContent < ActiveRecord::Base
12187 1115:433d4f72a19b Chris
  self.locking_column = 'version'
12188 0:513646585e45 Chris
  belongs_to :page, :class_name => 'WikiPage', :foreign_key => 'page_id'
12189
  belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
12190
  validates_presence_of :text
12191
  validates_length_of :comments, :maximum => 255, :allow_nil => true
12192 441:cbce1fd3b1b7 Chris
12193 0:513646585e45 Chris
  acts_as_versioned
12194 441:cbce1fd3b1b7 Chris
12195 1464:261b3d9a4903 Chris
  after_save :send_notification
12196
12197 0:513646585e45 Chris
  def visible?(user=User.current)
12198
    page.visible?(user)
12199
  end
12200 441:cbce1fd3b1b7 Chris
12201 0:513646585e45 Chris
  def project
12202
    page.project
12203
  end
12204 441:cbce1fd3b1b7 Chris
12205 0:513646585e45 Chris
  def attachments
12206
    page.nil? ? [] : page.attachments
12207
  end
12208 441:cbce1fd3b1b7 Chris
12209 0:513646585e45 Chris
  # Returns the mail adresses of users that should be notified
12210
  def recipients
12211
    notified = project.notified_users
12212
    notified.reject! {|user| !visible?(user)}
12213
    notified.collect(&:mail)
12214
  end
12215 441:cbce1fd3b1b7 Chris
12216 909:cbb26bc654de Chris
  # Return true if the content is the current page content
12217
  def current_version?
12218
    true
12219
  end
12220
12221 0:513646585e45 Chris
  class Version
12222
    belongs_to :page, :class_name => '::WikiPage', :foreign_key => 'page_id'
12223
    belongs_to :author, :class_name => '::User', :foreign_key => 'author_id'
12224
    attr_protected :data
12225
12226
    acts_as_event :title => Proc.new {|o| "#{l(:label_wiki_edit)}: #{o.page.title} (##{o.version})"},
12227
                  :description => :comments,
12228
                  :datetime => :updated_on,
12229
                  :type => 'wiki-page',
12230 1464:261b3d9a4903 Chris
                  :group => :page,
12231 37:94944d00e43c chris
                  :url => Proc.new {|o| {:controller => 'wiki', :action => 'show', :project_id => o.page.wiki.project, :id => o.page.title, :version => o.version}}
12232 0:513646585e45 Chris
12233
    acts_as_activity_provider :type => 'wiki_edits',
12234
                              :timestamp => "#{WikiContent.versioned_table_name}.updated_on",
12235
                              :author_key => "#{WikiContent.versioned_table_name}.author_id",
12236
                              :permission => :view_wiki_edits,
12237
                              :find_options => {:select => "#{WikiContent.versioned_table_name}.updated_on, #{WikiContent.versioned_table_name}.comments, " +
12238
                                                           "#{WikiContent.versioned_table_name}.#{WikiContent.version_column}, #{WikiPage.table_name}.title, " +
12239
                                                           "#{WikiContent.versioned_table_name}.page_id, #{WikiContent.versioned_table_name}.author_id, " +
12240
                                                           "#{WikiContent.versioned_table_name}.id",
12241
                                                :joins => "LEFT JOIN #{WikiPage.table_name} ON #{WikiPage.table_name}.id = #{WikiContent.versioned_table_name}.page_id " +
12242
                                                          "LEFT JOIN #{Wiki.table_name} ON #{Wiki.table_name}.id = #{WikiPage.table_name}.wiki_id " +
12243
                                                          "LEFT JOIN #{Project.table_name} ON #{Project.table_name}.id = #{Wiki.table_name}.project_id"}
12244
12245 1115:433d4f72a19b Chris
    after_destroy :page_update_after_destroy
12246
12247 0:513646585e45 Chris
    def text=(plain)
12248
      case Setting.wiki_compression
12249
      when 'gzip'
12250
      begin
12251
        self.data = Zlib::Deflate.deflate(plain, Zlib::BEST_COMPRESSION)
12252
        self.compression = 'gzip'
12253
      rescue
12254
        self.data = plain
12255
        self.compression = ''
12256
      end
12257
      else
12258
        self.data = plain
12259
        self.compression = ''
12260
      end
12261
      plain
12262
    end
12263 441:cbce1fd3b1b7 Chris
12264 0:513646585e45 Chris
    def text
12265 1115:433d4f72a19b Chris
      @text ||= begin
12266
        str = case compression
12267
              when 'gzip'
12268
                Zlib::Inflate.inflate(data)
12269
              else
12270
                # uncompressed data
12271
                data
12272
              end
12273 909:cbb26bc654de Chris
        str.force_encoding("UTF-8") if str.respond_to?(:force_encoding)
12274
        str
12275 441:cbce1fd3b1b7 Chris
      end
12276 0:513646585e45 Chris
    end
12277 441:cbce1fd3b1b7 Chris
12278 0:513646585e45 Chris
    def project
12279
      page.project
12280
    end
12281 441:cbce1fd3b1b7 Chris
12282 909:cbb26bc654de Chris
    # Return true if the content is the current page content
12283
    def current_version?
12284
      page.content.version == self.version
12285
    end
12286
12287 0:513646585e45 Chris
    # Returns the previous version or nil
12288
    def previous
12289 1115:433d4f72a19b Chris
      @previous ||= WikiContent::Version.
12290
        reorder('version DESC').
12291
        includes(:author).
12292
        where("wiki_content_id = ? AND version < ?", wiki_content_id, version).first
12293
    end
12294
12295
    # Returns the next version or nil
12296
    def next
12297
      @next ||= WikiContent::Version.
12298
        reorder('version ASC').
12299
        includes(:author).
12300
        where("wiki_content_id = ? AND version > ?", wiki_content_id, version).first
12301
    end
12302
12303
    private
12304
12305
    # Updates page's content if the latest version is removed
12306
    # or destroys the page if it was the only version
12307
    def page_update_after_destroy
12308
      latest = page.content.versions.reorder("#{self.class.table_name}.version DESC").first
12309
      if latest && page.content.version != latest.version
12310
        raise ActiveRecord::Rollback unless page.content.revert_to!(latest)
12311
      elsif latest.nil?
12312
        raise ActiveRecord::Rollback unless page.destroy
12313
      end
12314 0:513646585e45 Chris
    end
12315
  end
12316 1464:261b3d9a4903 Chris
12317
  private
12318
12319
  def send_notification
12320
    # new_record? returns false in after_save callbacks
12321
    if id_changed?
12322
      if Setting.notified_events.include?('wiki_content_added')
12323
        Mailer.wiki_content_added(self).deliver
12324
      end
12325
    elsif text_changed?
12326
      if Setting.notified_events.include?('wiki_content_updated')
12327
        Mailer.wiki_content_updated(self).deliver
12328
      end
12329
    end
12330
  end
12331 0:513646585e45 Chris
end
12332
# Redmine - project management software
12333 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
12334 0:513646585e45 Chris
#
12335
# This program is free software; you can redistribute it and/or
12336
# modify it under the terms of the GNU General Public License
12337
# as published by the Free Software Foundation; either version 2
12338
# of the License, or (at your option) any later version.
12339 909:cbb26bc654de Chris
#
12340 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
12341
# but WITHOUT ANY WARRANTY; without even the implied warranty of
12342
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12343
# GNU General Public License for more details.
12344 909:cbb26bc654de Chris
#
12345 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
12346
# along with this program; if not, write to the Free Software
12347
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
12348
12349
require 'diff'
12350
require 'enumerator'
12351
12352
class WikiPage < ActiveRecord::Base
12353 1115:433d4f72a19b Chris
  include Redmine::SafeAttributes
12354
12355 0:513646585e45 Chris
  belongs_to :wiki
12356
  has_one :content, :class_name => 'WikiContent', :foreign_key => 'page_id', :dependent => :destroy
12357
  acts_as_attachable :delete_permission => :delete_wiki_pages_attachments
12358
  acts_as_tree :dependent => :nullify, :order => 'title'
12359
12360
  acts_as_watchable
12361
  acts_as_event :title => Proc.new {|o| "#{l(:label_wiki)}: #{o.title}"},
12362
                :description => :text,
12363
                :datetime => :created_on,
12364 37:94944d00e43c chris
                :url => Proc.new {|o| {:controller => 'wiki', :action => 'show', :project_id => o.wiki.project, :id => o.title}}
12365 0:513646585e45 Chris
12366 909:cbb26bc654de Chris
  acts_as_searchable :columns => ['title', "#{WikiContent.table_name}.text"],
12367 0:513646585e45 Chris
                     :include => [{:wiki => :project}, :content],
12368 441:cbce1fd3b1b7 Chris
                     :permission => :view_wiki_pages,
12369 0:513646585e45 Chris
                     :project_key => "#{Wiki.table_name}.project_id"
12370
12371
  attr_accessor :redirect_existing_links
12372 909:cbb26bc654de Chris
12373 0:513646585e45 Chris
  validates_presence_of :title
12374 1464:261b3d9a4903 Chris
  validates_format_of :title, :with => /\A[^,\.\/\?\;\|\s]*\z/
12375 0:513646585e45 Chris
  validates_uniqueness_of :title, :scope => :wiki_id, :case_sensitive => false
12376
  validates_associated :content
12377 909:cbb26bc654de Chris
12378
  validate :validate_parent_title
12379
  before_destroy :remove_redirects
12380
  before_save    :handle_redirects
12381
12382 441:cbce1fd3b1b7 Chris
  # eager load information about last updates, without loading text
12383 1464:261b3d9a4903 Chris
  scope :with_updated_on, lambda {
12384
    select("#{WikiPage.table_name}.*, #{WikiContent.table_name}.updated_on, #{WikiContent.table_name}.version").
12385
      joins("LEFT JOIN #{WikiContent.table_name} ON #{WikiContent.table_name}.page_id = #{WikiPage.table_name}.id")
12386 441:cbce1fd3b1b7 Chris
  }
12387 909:cbb26bc654de Chris
12388 0:513646585e45 Chris
  # Wiki pages that are protected by default
12389
  DEFAULT_PROTECTED_PAGES = %w(sidebar)
12390 909:cbb26bc654de Chris
12391 1115:433d4f72a19b Chris
  safe_attributes 'parent_id', 'parent_title',
12392
    :if => lambda {|page, user| page.new_record? || user.allowed_to?(:rename_wiki_pages, page.project)}
12393
12394
  def initialize(attributes=nil, *args)
12395
    super
12396 0:513646585e45 Chris
    if new_record? && DEFAULT_PROTECTED_PAGES.include?(title.to_s.downcase)
12397
      self.protected = true
12398
    end
12399
  end
12400 909:cbb26bc654de Chris
12401 0:513646585e45 Chris
  def visible?(user=User.current)
12402
    !user.nil? && user.allowed_to?(:view_wiki_pages, project)
12403
  end
12404
12405
  def title=(value)
12406
    value = Wiki.titleize(value)
12407
    @previous_title = read_attribute(:title) if @previous_title.blank?
12408
    write_attribute(:title, value)
12409
  end
12410
12411 909:cbb26bc654de Chris
  def handle_redirects
12412
    self.title = Wiki.titleize(title)
12413 0:513646585e45 Chris
    # Manage redirects if the title has changed
12414
    if !@previous_title.blank? && (@previous_title != title) && !new_record?
12415
      # Update redirects that point to the old title
12416 1517:dffacf8a6908 Chris
      wiki.redirects.where(:redirects_to => @previous_title).each do |r|
12417 0:513646585e45 Chris
        r.redirects_to = title
12418
        r.title == r.redirects_to ? r.destroy : r.save
12419
      end
12420
      # Remove redirects for the new title
12421 1517:dffacf8a6908 Chris
      wiki.redirects.where(:title => title).each(&:destroy)
12422 0:513646585e45 Chris
      # Create a redirect to the new title
12423
      wiki.redirects << WikiRedirect.new(:title => @previous_title, :redirects_to => title) unless redirect_existing_links == "0"
12424
      @previous_title = nil
12425
    end
12426
  end
12427 909:cbb26bc654de Chris
12428
  def remove_redirects
12429 0:513646585e45 Chris
    # Remove redirects to this page
12430 1517:dffacf8a6908 Chris
    wiki.redirects.where(:redirects_to => title).each(&:destroy)
12431 0:513646585e45 Chris
  end
12432 909:cbb26bc654de Chris
12433 0:513646585e45 Chris
  def pretty_title
12434
    WikiPage.pretty_title(title)
12435
  end
12436 909:cbb26bc654de Chris
12437 0:513646585e45 Chris
  def content_for_version(version=nil)
12438 1517:dffacf8a6908 Chris
    if content
12439
      result = content.versions.find_by_version(version.to_i) if version
12440
      result ||= content
12441
      result
12442
    end
12443 0:513646585e45 Chris
  end
12444 909:cbb26bc654de Chris
12445 0:513646585e45 Chris
  def diff(version_to=nil, version_from=nil)
12446
    version_to = version_to ? version_to.to_i : self.content.version
12447 1115:433d4f72a19b Chris
    content_to = content.versions.find_by_version(version_to)
12448
    content_from = version_from ? content.versions.find_by_version(version_from.to_i) : content_to.try(:previous)
12449
    return nil unless content_to && content_from
12450 909:cbb26bc654de Chris
12451 1115:433d4f72a19b Chris
    if content_from.version > content_to.version
12452
      content_to, content_from = content_from, content_to
12453
    end
12454 909:cbb26bc654de Chris
12455 0:513646585e45 Chris
    (content_to && content_from) ? WikiDiff.new(content_to, content_from) : nil
12456
  end
12457 909:cbb26bc654de Chris
12458 0:513646585e45 Chris
  def annotate(version=nil)
12459
    version = version ? version.to_i : self.content.version
12460
    c = content.versions.find_by_version(version)
12461
    c ? WikiAnnotate.new(c) : nil
12462
  end
12463 909:cbb26bc654de Chris
12464 0:513646585e45 Chris
  def self.pretty_title(str)
12465
    (str && str.is_a?(String)) ? str.tr('_', ' ') : str
12466
  end
12467 909:cbb26bc654de Chris
12468 0:513646585e45 Chris
  def project
12469
    wiki.project
12470
  end
12471 909:cbb26bc654de Chris
12472 0:513646585e45 Chris
  def text
12473
    content.text if content
12474
  end
12475 909:cbb26bc654de Chris
12476 441:cbce1fd3b1b7 Chris
  def updated_on
12477
    unless @updated_on
12478
      if time = read_attribute(:updated_on)
12479
        # content updated_on was eager loaded with the page
12480 1115:433d4f72a19b Chris
        begin
12481
          @updated_on = (self.class.default_timezone == :utc ? Time.parse(time.to_s).utc : Time.parse(time.to_s).localtime)
12482
        rescue
12483
        end
12484 441:cbce1fd3b1b7 Chris
      else
12485
        @updated_on = content && content.updated_on
12486
      end
12487
    end
12488
    @updated_on
12489
  end
12490 909:cbb26bc654de Chris
12491 0:513646585e45 Chris
  # Returns true if usr is allowed to edit the page, otherwise false
12492
  def editable_by?(usr)
12493
    !protected? || usr.allowed_to?(:protect_wiki_pages, wiki.project)
12494
  end
12495 909:cbb26bc654de Chris
12496 0:513646585e45 Chris
  def attachments_deletable?(usr=User.current)
12497
    editable_by?(usr) && super(usr)
12498
  end
12499 909:cbb26bc654de Chris
12500 0:513646585e45 Chris
  def parent_title
12501
    @parent_title || (self.parent && self.parent.pretty_title)
12502
  end
12503 909:cbb26bc654de Chris
12504 0:513646585e45 Chris
  def parent_title=(t)
12505
    @parent_title = t
12506
    parent_page = t.blank? ? nil : self.wiki.find_page(t)
12507
    self.parent = parent_page
12508
  end
12509 37:94944d00e43c chris
12510 1115:433d4f72a19b Chris
  # Saves the page and its content if text was changed
12511 1464:261b3d9a4903 Chris
  def save_with_content(content)
12512 1115:433d4f72a19b Chris
    ret = nil
12513
    transaction do
12514 1464:261b3d9a4903 Chris
      self.content = content
12515 1115:433d4f72a19b Chris
      if new_record?
12516
        # Rails automatically saves associated content
12517
        ret = save
12518
      else
12519
        ret = save && (content.text_changed? ? content.save : true)
12520
      end
12521
      raise ActiveRecord::Rollback unless ret
12522
    end
12523
    ret
12524
  end
12525
12526 0:513646585e45 Chris
  protected
12527 909:cbb26bc654de Chris
12528
  def validate_parent_title
12529 0:513646585e45 Chris
    errors.add(:parent_title, :invalid) if !@parent_title.blank? && parent.nil?
12530
    errors.add(:parent_title, :circular_dependency) if parent && (parent == self || parent.ancestors.include?(self))
12531
    errors.add(:parent_title, :not_same_project) if parent && (parent.wiki_id != wiki_id)
12532
  end
12533
end
12534
12535 245:051f544170fe Chris
class WikiDiff < Redmine::Helpers::Diff
12536
  attr_reader :content_to, :content_from
12537 909:cbb26bc654de Chris
12538 0:513646585e45 Chris
  def initialize(content_to, content_from)
12539
    @content_to = content_to
12540
    @content_from = content_from
12541 245:051f544170fe Chris
    super(content_to.text, content_from.text)
12542 0:513646585e45 Chris
  end
12543
end
12544
12545
class WikiAnnotate
12546
  attr_reader :lines, :content
12547 909:cbb26bc654de Chris
12548 0:513646585e45 Chris
  def initialize(content)
12549
    @content = content
12550
    current = content
12551
    current_lines = current.text.split(/\r?\n/)
12552
    @lines = current_lines.collect {|t| [nil, nil, t]}
12553
    positions = []
12554
    current_lines.size.times {|i| positions << i}
12555
    while (current.previous)
12556
      d = current.previous.text.split(/\r?\n/).diff(current.text.split(/\r?\n/)).diffs.flatten
12557
      d.each_slice(3) do |s|
12558
        sign, line = s[0], s[1]
12559
        if sign == '+' && positions[line] && positions[line] != -1
12560
          if @lines[positions[line]][0].nil?
12561
            @lines[positions[line]][0] = current.version
12562
            @lines[positions[line]][1] = current.author
12563
          end
12564
        end
12565
      end
12566
      d.each_slice(3) do |s|
12567
        sign, line = s[0], s[1]
12568
        if sign == '-'
12569
          positions.insert(line, -1)
12570
        else
12571
          positions[line] = nil
12572
        end
12573
      end
12574
      positions.compact!
12575
      # Stop if every line is annotated
12576
      break unless @lines.detect { |line| line[0].nil? }
12577
      current = current.previous
12578
    end
12579 909:cbb26bc654de Chris
    @lines.each { |line|
12580 507:0c939c159af4 Chris
      line[0] ||= current.version
12581
      # if the last known version is > 1 (eg. history was cleared), we don't know the author
12582
      line[1] ||= current.author if current.version == 1
12583
    }
12584 0:513646585e45 Chris
  end
12585
end
12586 909:cbb26bc654de Chris
# Redmine - project management software
12587 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
12588 0:513646585e45 Chris
#
12589
# This program is free software; you can redistribute it and/or
12590
# modify it under the terms of the GNU General Public License
12591
# as published by the Free Software Foundation; either version 2
12592
# of the License, or (at your option) any later version.
12593 909:cbb26bc654de Chris
#
12594 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
12595
# but WITHOUT ANY WARRANTY; without even the implied warranty of
12596
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12597
# GNU General Public License for more details.
12598 909:cbb26bc654de Chris
#
12599 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
12600
# along with this program; if not, write to the Free Software
12601
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
12602
12603
class WikiRedirect < ActiveRecord::Base
12604
  belongs_to :wiki
12605 909:cbb26bc654de Chris
12606 0:513646585e45 Chris
  validates_presence_of :title, :redirects_to
12607
  validates_length_of :title, :redirects_to, :maximum => 255
12608
end
12609 1115:433d4f72a19b Chris
# Redmine - project management software
12610 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
12611 1115:433d4f72a19b Chris
#
12612
# This program is free software; you can redistribute it and/or
12613
# modify it under the terms of the GNU General Public License
12614
# as published by the Free Software Foundation; either version 2
12615
# of the License, or (at your option) any later version.
12616
#
12617
# This program is distributed in the hope that it will be useful,
12618
# but WITHOUT ANY WARRANTY; without even the implied warranty of
12619
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12620
# GNU General Public License for more details.
12621
#
12622
# You should have received a copy of the GNU General Public License
12623
# along with this program; if not, write to the Free Software
12624
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
12625
12626
class WorkflowPermission < WorkflowRule
12627
  validates_inclusion_of :rule, :in => %w(readonly required)
12628
  validate :validate_field_name
12629
12630
  # Replaces the workflow permissions for the given tracker and role
12631
  #
12632
  # Example:
12633
  #   WorkflowPermission.replace_permissions role, tracker, {'due_date' => {'1' => 'readonly', '2' => 'required'}}
12634
  def self.replace_permissions(tracker, role, permissions)
12635
    destroy_all(:tracker_id => tracker.id, :role_id => role.id)
12636
12637
    permissions.each { |field, rule_by_status_id|
12638
      rule_by_status_id.each { |status_id, rule|
12639
        if rule.present?
12640
          WorkflowPermission.create(:role_id => role.id, :tracker_id => tracker.id, :old_status_id => status_id, :field_name => field, :rule => rule)
12641
        end
12642
      }
12643
    }
12644
  end
12645
12646
  protected
12647
12648
  def validate_field_name
12649
    unless Tracker::CORE_FIELDS_ALL.include?(field_name) || field_name.to_s.match(/^\d+$/)
12650
      errors.add :field_name, :invalid
12651
    end
12652
  end
12653
end
12654
# Redmine - project management software
12655 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
12656 1115:433d4f72a19b Chris
#
12657
# This program is free software; you can redistribute it and/or
12658
# modify it under the terms of the GNU General Public License
12659
# as published by the Free Software Foundation; either version 2
12660
# of the License, or (at your option) any later version.
12661
#
12662
# This program is distributed in the hope that it will be useful,
12663
# but WITHOUT ANY WARRANTY; without even the implied warranty of
12664
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12665
# GNU General Public License for more details.
12666
#
12667
# You should have received a copy of the GNU General Public License
12668
# along with this program; if not, write to the Free Software
12669
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
12670
12671
class WorkflowRule < ActiveRecord::Base
12672
  self.table_name = "#{table_name_prefix}workflows#{table_name_suffix}"
12673
12674
  belongs_to :role
12675
  belongs_to :tracker
12676
  belongs_to :old_status, :class_name => 'IssueStatus', :foreign_key => 'old_status_id'
12677
  belongs_to :new_status, :class_name => 'IssueStatus', :foreign_key => 'new_status_id'
12678
12679
  validates_presence_of :role, :tracker, :old_status
12680
12681
  # Copies workflows from source to targets
12682
  def self.copy(source_tracker, source_role, target_trackers, target_roles)
12683
    unless source_tracker.is_a?(Tracker) || source_role.is_a?(Role)
12684
      raise ArgumentError.new("source_tracker or source_role must be specified")
12685
    end
12686
12687
    target_trackers = [target_trackers].flatten.compact
12688
    target_roles = [target_roles].flatten.compact
12689
12690
    target_trackers = Tracker.sorted.all if target_trackers.empty?
12691
    target_roles = Role.all if target_roles.empty?
12692
12693
    target_trackers.each do |target_tracker|
12694
      target_roles.each do |target_role|
12695
        copy_one(source_tracker || target_tracker,
12696
                   source_role || target_role,
12697
                   target_tracker,
12698
                   target_role)
12699
      end
12700
    end
12701
  end
12702
12703
  # Copies a single set of workflows from source to target
12704
  def self.copy_one(source_tracker, source_role, target_tracker, target_role)
12705
    unless source_tracker.is_a?(Tracker) && !source_tracker.new_record? &&
12706
      source_role.is_a?(Role) && !source_role.new_record? &&
12707
      target_tracker.is_a?(Tracker) && !target_tracker.new_record? &&
12708
      target_role.is_a?(Role) && !target_role.new_record?
12709
12710
      raise ArgumentError.new("arguments can not be nil or unsaved objects")
12711
    end
12712
12713
    if source_tracker == target_tracker && source_role == target_role
12714
      false
12715
    else
12716
      transaction do
12717
        delete_all :tracker_id => target_tracker.id, :role_id => target_role.id
12718 1464:261b3d9a4903 Chris
        connection.insert "INSERT INTO #{WorkflowRule.table_name} (tracker_id, role_id, old_status_id, new_status_id, author, assignee, field_name, #{connection.quote_column_name 'rule'}, type)" +
12719
                          " SELECT #{target_tracker.id}, #{target_role.id}, old_status_id, new_status_id, author, assignee, field_name, #{connection.quote_column_name 'rule'}, type" +
12720 1115:433d4f72a19b Chris
                          " FROM #{WorkflowRule.table_name}" +
12721
                          " WHERE tracker_id = #{source_tracker.id} AND role_id = #{source_role.id}"
12722
      end
12723
      true
12724
    end
12725
  end
12726
end
12727
# Redmine - project management software
12728 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
12729 1115:433d4f72a19b Chris
#
12730
# This program is free software; you can redistribute it and/or
12731
# modify it under the terms of the GNU General Public License
12732
# as published by the Free Software Foundation; either version 2
12733
# of the License, or (at your option) any later version.
12734
#
12735
# This program is distributed in the hope that it will be useful,
12736
# but WITHOUT ANY WARRANTY; without even the implied warranty of
12737
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12738
# GNU General Public License for more details.
12739
#
12740
# You should have received a copy of the GNU General Public License
12741
# along with this program; if not, write to the Free Software
12742
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
12743
12744
class WorkflowTransition < WorkflowRule
12745
  validates_presence_of :new_status
12746
12747
  # Returns workflow transitions count by tracker and role
12748
  def self.count_by_tracker_and_role
12749
    counts = connection.select_all("SELECT role_id, tracker_id, count(id) AS c FROM #{table_name} WHERE type = 'WorkflowTransition' GROUP BY role_id, tracker_id")
12750
    roles = Role.sorted.all
12751
    trackers = Tracker.sorted.all
12752
12753
    result = []
12754
    trackers.each do |tracker|
12755
      t = []
12756
      roles.each do |role|
12757
        row = counts.detect {|c| c['role_id'].to_s == role.id.to_s && c['tracker_id'].to_s == tracker.id.to_s}
12758
        t << [role, (row.nil? ? 0 : row['c'].to_i)]
12759
      end
12760
      result << [tracker, t]
12761
    end
12762
12763
    result
12764
  end
12765
end