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 @ 1541:2696466256ff

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 1541:2696466256ff Chris
              if uu.empty?
8570
                logger.warn "find_committer_user: found no user with name matching #{username}, ignoring"
8571
              elsif uu.length == 1
8572 1533:59e13100ea95 Chris
                u = uu.first
8573
              else
8574
                logger.warn "find_committer_user: found more than one (#{uu.length}) results for user named #{username}, ignoring"
8575
              end
8576 1532:a0460a3d154f Chris
            end
8577
          else
8578
            u = User.find_by_mail(email)
8579
          end
8580
        end
8581 0:513646585e45 Chris
        user = u
8582
      end
8583
      @found_committer_users[committer] = user
8584
      user
8585
    end
8586
  end
8587 245:051f544170fe Chris
8588
  def repo_log_encoding
8589
    encoding = log_encoding.to_s.strip
8590
    encoding.blank? ? 'UTF-8' : encoding
8591
  end
8592
8593 0:513646585e45 Chris
  # Fetches new changesets for all repositories of active projects
8594
  # Can be called periodically by an external script
8595
  # eg. ruby script/runner "Repository.fetch_changesets"
8596
  def self.fetch_changesets
8597 1115:433d4f72a19b Chris
    Project.active.has_module(:repository).all.each do |project|
8598
      project.repositories.each do |repository|
8599 245:051f544170fe Chris
        begin
8600 1115:433d4f72a19b Chris
          repository.fetch_changesets
8601 245:051f544170fe Chris
        rescue Redmine::Scm::Adapters::CommandFailed => e
8602
          logger.error "scm: error during fetching changesets: #{e.message}"
8603
        end
8604 0:513646585e45 Chris
      end
8605
    end
8606
  end
8607 245:051f544170fe Chris
8608 0:513646585e45 Chris
  # scan changeset comments to find related and fixed issues for all repositories
8609
  def self.scan_changesets_for_issue_ids
8610 1464:261b3d9a4903 Chris
    all.each(&:scan_changesets_for_issue_ids)
8611 0:513646585e45 Chris
  end
8612
8613
  def self.scm_name
8614
    'Abstract'
8615
  end
8616 441:cbce1fd3b1b7 Chris
8617 0:513646585e45 Chris
  def self.available_scm
8618
    subclasses.collect {|klass| [klass.scm_name, klass.name]}
8619
  end
8620 245:051f544170fe Chris
8621 0:513646585e45 Chris
  def self.factory(klass_name, *args)
8622
    klass = "Repository::#{klass_name}".constantize
8623
    klass.new(*args)
8624
  rescue
8625
    nil
8626
  end
8627 245:051f544170fe Chris
8628 437:102056ec2de9 chris
  def clear_cache
8629
    clear_changesets
8630
  end
8631 443:350acce374a2 Chris
8632 245:051f544170fe Chris
  def self.scm_adapter_class
8633
    nil
8634
  end
8635
8636
  def self.scm_command
8637
    ret = ""
8638
    begin
8639
      ret = self.scm_adapter_class.client_command if self.scm_adapter_class
8640 441:cbce1fd3b1b7 Chris
    rescue Exception => e
8641 245:051f544170fe Chris
      logger.error "scm: error during get command: #{e.message}"
8642
    end
8643
    ret
8644
  end
8645
8646
  def self.scm_version_string
8647
    ret = ""
8648
    begin
8649
      ret = self.scm_adapter_class.client_version_string if self.scm_adapter_class
8650 441:cbce1fd3b1b7 Chris
    rescue Exception => e
8651 245:051f544170fe Chris
      logger.error "scm: error during get version string: #{e.message}"
8652
    end
8653
    ret
8654
  end
8655
8656
  def self.scm_available
8657
    ret = false
8658
    begin
8659 441:cbce1fd3b1b7 Chris
      ret = self.scm_adapter_class.client_available if self.scm_adapter_class
8660
    rescue Exception => e
8661 245:051f544170fe Chris
      logger.error "scm: error during get scm available: #{e.message}"
8662
    end
8663
    ret
8664
  end
8665
8666 1115:433d4f72a19b Chris
  def set_as_default?
8667 1464:261b3d9a4903 Chris
    new_record? && project && Repository.where(:project_id => project.id).empty?
8668 1115:433d4f72a19b Chris
  end
8669
8670
  protected
8671
8672
  def check_default
8673
    if !is_default? && set_as_default?
8674
      self.is_default = true
8675
    end
8676
    if is_default? && is_default_changed?
8677 1517:dffacf8a6908 Chris
      Repository.where(["project_id = ?", project_id]).update_all(["is_default = ?", false])
8678 1115:433d4f72a19b Chris
    end
8679
  end
8680
8681
  def load_entries_changesets(entries)
8682
    if entries
8683
      entries.each do |entry|
8684
        if entry.lastrev && entry.lastrev.identifier
8685
          entry.changeset = find_changeset_by_name(entry.lastrev.identifier)
8686
        end
8687
      end
8688
    end
8689
  end
8690
8691 0:513646585e45 Chris
  private
8692 245:051f544170fe Chris
8693 1115:433d4f72a19b Chris
  # Deletes repository data
8694 0:513646585e45 Chris
  def clear_changesets
8695 1115:433d4f72a19b Chris
    cs = Changeset.table_name
8696
    ch = Change.table_name
8697
    ci = "#{table_name_prefix}changesets_issues#{table_name_suffix}"
8698
    cp = "#{table_name_prefix}changeset_parents#{table_name_suffix}"
8699
8700 0:513646585e45 Chris
    connection.delete("DELETE FROM #{ch} WHERE #{ch}.changeset_id IN (SELECT #{cs}.id FROM #{cs} WHERE #{cs}.repository_id = #{id})")
8701
    connection.delete("DELETE FROM #{ci} WHERE #{ci}.changeset_id IN (SELECT #{cs}.id FROM #{cs} WHERE #{cs}.repository_id = #{id})")
8702 1115:433d4f72a19b Chris
    connection.delete("DELETE FROM #{cp} WHERE #{cp}.changeset_id IN (SELECT #{cs}.id FROM #{cs} WHERE #{cs}.repository_id = #{id})")
8703 0:513646585e45 Chris
    connection.delete("DELETE FROM #{cs} WHERE #{cs}.repository_id = #{id}")
8704 1115:433d4f72a19b Chris
    clear_extra_info_of_changesets
8705
  end
8706
8707
  def clear_extra_info_of_changesets
8708 0:513646585e45 Chris
  end
8709
end
8710 441:cbce1fd3b1b7 Chris
# Redmine - project management software
8711 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
8712 0:513646585e45 Chris
#
8713
# This program is free software; you can redistribute it and/or
8714
# modify it under the terms of the GNU General Public License
8715
# as published by the Free Software Foundation; either version 2
8716
# of the License, or (at your option) any later version.
8717 441:cbce1fd3b1b7 Chris
#
8718 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
8719
# but WITHOUT ANY WARRANTY; without even the implied warranty of
8720
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
8721
# GNU General Public License for more details.
8722 441:cbce1fd3b1b7 Chris
#
8723 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
8724
# along with this program; if not, write to the Free Software
8725
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
8726
8727 1136:51d7f3e06556 chris
require_dependency 'redmine/scm/adapters/bazaar_adapter'
8728 0:513646585e45 Chris
8729
class Repository::Bazaar < Repository
8730
  attr_protected :root_url
8731 245:051f544170fe Chris
  validates_presence_of :url, :log_encoding
8732 0:513646585e45 Chris
8733 1115:433d4f72a19b Chris
  def self.human_attribute_name(attribute_key_name, *args)
8734
    attr_name = attribute_key_name.to_s
8735 441:cbce1fd3b1b7 Chris
    if attr_name == "url"
8736
      attr_name = "path_to_repository"
8737
    end
8738 1115:433d4f72a19b Chris
    super(attr_name, *args)
8739 245:051f544170fe Chris
  end
8740
8741
  def self.scm_adapter_class
8742 0:513646585e45 Chris
    Redmine::Scm::Adapters::BazaarAdapter
8743
  end
8744 245:051f544170fe Chris
8745 0:513646585e45 Chris
  def self.scm_name
8746
    'Bazaar'
8747
  end
8748 245:051f544170fe Chris
8749 1115:433d4f72a19b Chris
  def entry(path=nil, identifier=nil)
8750
    scm.bzr_path_encodig = log_encoding
8751
    scm.entry(path, identifier)
8752
  end
8753
8754
  def cat(path, identifier=nil)
8755
    scm.bzr_path_encodig = log_encoding
8756
    scm.cat(path, identifier)
8757
  end
8758
8759
  def annotate(path, identifier=nil)
8760
    scm.bzr_path_encodig = log_encoding
8761
    scm.annotate(path, identifier)
8762
  end
8763
8764
  def diff(path, rev, rev_to)
8765
    scm.bzr_path_encodig = log_encoding
8766
    scm.diff(path, rev, rev_to)
8767
  end
8768
8769 1517:dffacf8a6908 Chris
  def scm_entries(path=nil, identifier=nil)
8770 1115:433d4f72a19b Chris
    scm.bzr_path_encodig = log_encoding
8771 0:513646585e45 Chris
    entries = scm.entries(path, identifier)
8772
    if entries
8773
      entries.each do |e|
8774
        next if e.lastrev.revision.blank?
8775
        # Set the filesize unless browsing a specific revision
8776
        if identifier.nil? && e.is_file?
8777
          full_path = File.join(root_url, e.path)
8778
          e.size = File.stat(full_path).size if File.file?(full_path)
8779
        end
8780 1464:261b3d9a4903 Chris
        c = Change.
8781
              includes(:changeset).
8782
              where("#{Change.table_name}.revision = ? and #{Changeset.table_name}.repository_id = ?", e.lastrev.revision, id).
8783
              order("#{Changeset.table_name}.revision DESC").
8784
              first
8785 0:513646585e45 Chris
        if c
8786
          e.lastrev.identifier = c.changeset.revision
8787 441:cbce1fd3b1b7 Chris
          e.lastrev.name       = c.changeset.revision
8788
          e.lastrev.author     = c.changeset.committer
8789 0:513646585e45 Chris
        end
8790
      end
8791
    end
8792 1115:433d4f72a19b Chris
    entries
8793 0:513646585e45 Chris
  end
8794 1517:dffacf8a6908 Chris
  protected :scm_entries
8795 441:cbce1fd3b1b7 Chris
8796 0:513646585e45 Chris
  def fetch_changesets
8797 1115:433d4f72a19b Chris
    scm.bzr_path_encodig = log_encoding
8798 0:513646585e45 Chris
    scm_info = scm.info
8799
    if scm_info
8800
      # latest revision found in database
8801
      db_revision = latest_changeset ? latest_changeset.revision.to_i : 0
8802
      # latest revision in the repository
8803
      scm_revision = scm_info.lastrev.identifier.to_i
8804
      if db_revision < scm_revision
8805
        logger.debug "Fetching changesets for repository #{url}" if logger && logger.debug?
8806
        identifier_from = db_revision + 1
8807
        while (identifier_from <= scm_revision)
8808
          # loads changesets by batches of 200
8809
          identifier_to = [identifier_from + 199, scm_revision].min
8810 1115:433d4f72a19b Chris
          revisions = scm.revisions('', identifier_to, identifier_from)
8811 0:513646585e45 Chris
          transaction do
8812
            revisions.reverse_each do |revision|
8813 441:cbce1fd3b1b7 Chris
              changeset = Changeset.create(:repository   => self,
8814
                                           :revision     => revision.identifier,
8815
                                           :committer    => revision.author,
8816 0:513646585e45 Chris
                                           :committed_on => revision.time,
8817 441:cbce1fd3b1b7 Chris
                                           :scmid        => revision.scmid,
8818
                                           :comments     => revision.message)
8819
8820 0:513646585e45 Chris
              revision.paths.each do |change|
8821
                Change.create(:changeset => changeset,
8822 441:cbce1fd3b1b7 Chris
                              :action    => change[:action],
8823
                              :path      => change[:path],
8824
                              :revision  => change[:revision])
8825 0:513646585e45 Chris
              end
8826
            end
8827
          end unless revisions.nil?
8828
          identifier_from = identifier_to + 1
8829
        end
8830
      end
8831
    end
8832
  end
8833
end
8834 441:cbce1fd3b1b7 Chris
# Redmine - project management software
8835 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
8836 0:513646585e45 Chris
#
8837
# This program is free software; you can redistribute it and/or
8838
# modify it under the terms of the GNU General Public License
8839
# as published by the Free Software Foundation; either version 2
8840
# of the License, or (at your option) any later version.
8841 441:cbce1fd3b1b7 Chris
#
8842 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
8843
# but WITHOUT ANY WARRANTY; without even the implied warranty of
8844
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
8845
# GNU General Public License for more details.
8846 441:cbce1fd3b1b7 Chris
#
8847 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
8848
# along with this program; if not, write to the Free Software
8849
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
8850
8851 1136:51d7f3e06556 chris
require_dependency 'redmine/scm/adapters/cvs_adapter'
8852 0:513646585e45 Chris
require 'digest/sha1'
8853
8854
class Repository::Cvs < Repository
8855 245:051f544170fe Chris
  validates_presence_of :url, :root_url, :log_encoding
8856 0:513646585e45 Chris
8857 1115:433d4f72a19b Chris
  safe_attributes 'root_url',
8858
    :if => lambda {|repository, user| repository.new_record?}
8859
8860
  def self.human_attribute_name(attribute_key_name, *args)
8861
    attr_name = attribute_key_name.to_s
8862 441:cbce1fd3b1b7 Chris
    if attr_name == "root_url"
8863
      attr_name = "cvsroot"
8864
    elsif attr_name == "url"
8865
      attr_name = "cvs_module"
8866
    end
8867 1115:433d4f72a19b Chris
    super(attr_name, *args)
8868 245:051f544170fe Chris
  end
8869
8870
  def self.scm_adapter_class
8871 0:513646585e45 Chris
    Redmine::Scm::Adapters::CvsAdapter
8872
  end
8873 245:051f544170fe Chris
8874 0:513646585e45 Chris
  def self.scm_name
8875
    'CVS'
8876
  end
8877 245:051f544170fe Chris
8878 0:513646585e45 Chris
  def entry(path=nil, identifier=nil)
8879
    rev = identifier.nil? ? nil : changesets.find_by_revision(identifier)
8880
    scm.entry(path, rev.nil? ? nil : rev.committed_on)
8881
  end
8882 441:cbce1fd3b1b7 Chris
8883 1517:dffacf8a6908 Chris
  def scm_entries(path=nil, identifier=nil)
8884 441:cbce1fd3b1b7 Chris
    rev = nil
8885
    if ! identifier.nil?
8886
      rev = changesets.find_by_revision(identifier)
8887
      return nil if rev.nil?
8888
    end
8889 0:513646585e45 Chris
    entries = scm.entries(path, rev.nil? ? nil : rev.committed_on)
8890
    if entries
8891
      entries.each() do |entry|
8892 441:cbce1fd3b1b7 Chris
        if ( ! entry.lastrev.nil? ) && ( ! entry.lastrev.revision.nil? )
8893 1115:433d4f72a19b Chris
          change = filechanges.find_by_revision_and_path(
8894 441:cbce1fd3b1b7 Chris
                     entry.lastrev.revision,
8895
                     scm.with_leading_slash(entry.path) )
8896 0:513646585e45 Chris
          if change
8897 441:cbce1fd3b1b7 Chris
            entry.lastrev.identifier = change.changeset.revision
8898
            entry.lastrev.revision   = change.changeset.revision
8899
            entry.lastrev.author     = change.changeset.committer
8900
            # entry.lastrev.branch     = change.branch
8901 0:513646585e45 Chris
          end
8902
        end
8903
      end
8904
    end
8905
    entries
8906
  end
8907 1517:dffacf8a6908 Chris
  protected :scm_entries
8908 441:cbce1fd3b1b7 Chris
8909 0:513646585e45 Chris
  def cat(path, identifier=nil)
8910 441:cbce1fd3b1b7 Chris
    rev = nil
8911
    if ! identifier.nil?
8912
      rev = changesets.find_by_revision(identifier)
8913
      return nil if rev.nil?
8914
    end
8915 0:513646585e45 Chris
    scm.cat(path, rev.nil? ? nil : rev.committed_on)
8916
  end
8917 441:cbce1fd3b1b7 Chris
8918
  def annotate(path, identifier=nil)
8919
    rev = nil
8920
    if ! identifier.nil?
8921
      rev = changesets.find_by_revision(identifier)
8922
      return nil if rev.nil?
8923
    end
8924
    scm.annotate(path, rev.nil? ? nil : rev.committed_on)
8925
  end
8926
8927 0:513646585e45 Chris
  def diff(path, rev, rev_to)
8928 441:cbce1fd3b1b7 Chris
    # convert rev to revision. CVS can't handle changesets here
8929 0:513646585e45 Chris
    diff=[]
8930 441:cbce1fd3b1b7 Chris
    changeset_from = changesets.find_by_revision(rev)
8931
    if rev_to.to_i > 0
8932
      changeset_to = changesets.find_by_revision(rev_to)
8933 0:513646585e45 Chris
    end
8934 1115:433d4f72a19b Chris
    changeset_from.filechanges.each() do |change_from|
8935 441:cbce1fd3b1b7 Chris
      revision_from = nil
8936
      revision_to   = nil
8937
      if path.nil? || (change_from.path.starts_with? scm.with_leading_slash(path))
8938
        revision_from = change_from.revision
8939
      end
8940 0:513646585e45 Chris
      if revision_from
8941
        if changeset_to
8942 1115:433d4f72a19b Chris
          changeset_to.filechanges.each() do |change_to|
8943 441:cbce1fd3b1b7 Chris
            revision_to = change_to.revision if change_to.path == change_from.path
8944 0:513646585e45 Chris
          end
8945
        end
8946
        unless revision_to
8947 441:cbce1fd3b1b7 Chris
          revision_to = scm.get_previous_revision(revision_from)
8948 0:513646585e45 Chris
        end
8949
        file_diff = scm.diff(change_from.path, revision_from, revision_to)
8950
        diff = diff + file_diff unless file_diff.nil?
8951
      end
8952
    end
8953
    return diff
8954
  end
8955 441:cbce1fd3b1b7 Chris
8956 0:513646585e45 Chris
  def fetch_changesets
8957
    # some nifty bits to introduce a commit-id with cvs
8958 441:cbce1fd3b1b7 Chris
    # natively cvs doesn't provide any kind of changesets,
8959
    # there is only a revision per file.
8960 0:513646585e45 Chris
    # we now take a guess using the author, the commitlog and the commit-date.
8961 441:cbce1fd3b1b7 Chris
8962
    # last one is the next step to take. the commit-date is not equal for all
8963 0:513646585e45 Chris
    # commits in one changeset. cvs update the commit-date when the *,v file was touched. so
8964
    # we use a small delta here, to merge all changes belonging to _one_ changeset
8965 441:cbce1fd3b1b7 Chris
    time_delta  = 10.seconds
8966 0:513646585e45 Chris
    fetch_since = latest_changeset ? latest_changeset.committed_on : nil
8967
    transaction do
8968
      tmp_rev_num = 1
8969 441:cbce1fd3b1b7 Chris
      scm.revisions('', fetch_since, nil, :log_encoding => repo_log_encoding) do |revision|
8970 0:513646585e45 Chris
        # only add the change to the database, if it doen't exists. the cvs log
8971 441:cbce1fd3b1b7 Chris
        # is not exclusive at all.
8972 210:0579821a129a Chris
        tmp_time = revision.time.clone
8973 1115:433d4f72a19b Chris
        unless filechanges.find_by_path_and_revision(
8974 441:cbce1fd3b1b7 Chris
	                         scm.with_leading_slash(revision.paths[0][:path]),
8975
	                         revision.paths[0][:revision]
8976
	                           )
8977 245:051f544170fe Chris
          cmt = Changeset.normalize_comments(revision.message, repo_log_encoding)
8978 441:cbce1fd3b1b7 Chris
          author_utf8 = Changeset.to_utf8(revision.author, repo_log_encoding)
8979 1464:261b3d9a4903 Chris
          cs  = changesets.where(
8980
                  :committed_on => tmp_time - time_delta .. tmp_time + time_delta,
8981
                  :committer    => author_utf8,
8982
                  :comments     => cmt
8983
                ).first
8984 441:cbce1fd3b1b7 Chris
          # create a new changeset....
8985 0:513646585e45 Chris
          unless cs
8986
            # we use a temporaray revision number here (just for inserting)
8987
            # later on, we calculate a continous positive number
8988 210:0579821a129a Chris
            tmp_time2 = tmp_time.clone.gmtime
8989 441:cbce1fd3b1b7 Chris
            branch    = revision.paths[0][:branch]
8990
            scmid     = branch + "-" + tmp_time2.strftime("%Y%m%d-%H%M%S")
8991
            cs = Changeset.create(:repository   => self,
8992
                                  :revision     => "tmp#{tmp_rev_num}",
8993
                                  :scmid        => scmid,
8994
                                  :committer    => revision.author,
8995 210:0579821a129a Chris
                                  :committed_on => tmp_time,
8996 441:cbce1fd3b1b7 Chris
                                  :comments     => revision.message)
8997 0:513646585e45 Chris
            tmp_rev_num += 1
8998
          end
8999 441:cbce1fd3b1b7 Chris
          # convert CVS-File-States to internal Action-abbrevations
9000
          # default action is (M)odified
9001
          action = "M"
9002
          if revision.paths[0][:action] == "Exp" && revision.paths[0][:revision] == "1.1"
9003
            action = "A" # add-action always at first revision (= 1.1)
9004
          elsif revision.paths[0][:action] == "dead"
9005
            action = "D" # dead-state is similar to Delete
9006 0:513646585e45 Chris
          end
9007 441:cbce1fd3b1b7 Chris
          Change.create(
9008
             :changeset => cs,
9009
             :action    => action,
9010
             :path      => scm.with_leading_slash(revision.paths[0][:path]),
9011
             :revision  => revision.paths[0][:revision],
9012
             :branch    => revision.paths[0][:branch]
9013
              )
9014 0:513646585e45 Chris
        end
9015
      end
9016 441:cbce1fd3b1b7 Chris
9017 0:513646585e45 Chris
      # Renumber new changesets in chronological order
9018 1464:261b3d9a4903 Chris
      Changeset.
9019
        order('committed_on ASC, id ASC').
9020
        where("repository_id = ? AND revision LIKE 'tmp%'", id).
9021
        each do |changeset|
9022 1517:dffacf8a6908 Chris
          changeset.update_attribute :revision, next_revision_number
9023 0:513646585e45 Chris
      end
9024
    end # transaction
9025 210:0579821a129a Chris
    @current_revision_number = nil
9026 0:513646585e45 Chris
  end
9027 441:cbce1fd3b1b7 Chris
9028 0:513646585e45 Chris
  private
9029 441:cbce1fd3b1b7 Chris
9030 0:513646585e45 Chris
  # Returns the next revision number to assign to a CVS changeset
9031
  def next_revision_number
9032
    # Need to retrieve existing revision numbers to sort them as integers
9033 210:0579821a129a Chris
    sql = "SELECT revision FROM #{Changeset.table_name} "
9034
    sql << "WHERE repository_id = #{id} AND revision NOT LIKE 'tmp%'"
9035
    @current_revision_number ||= (connection.select_values(sql).collect(&:to_i).max || 0)
9036 0:513646585e45 Chris
    @current_revision_number += 1
9037
  end
9038
end
9039 441:cbce1fd3b1b7 Chris
# Redmine - project management software
9040 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
9041 0:513646585e45 Chris
#
9042
# This program is free software; you can redistribute it and/or
9043
# modify it under the terms of the GNU General Public License
9044
# as published by the Free Software Foundation; either version 2
9045
# of the License, or (at your option) any later version.
9046 441:cbce1fd3b1b7 Chris
#
9047 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
9048
# but WITHOUT ANY WARRANTY; without even the implied warranty of
9049
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
9050
# GNU General Public License for more details.
9051 441:cbce1fd3b1b7 Chris
#
9052 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
9053
# along with this program; if not, write to the Free Software
9054
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
9055
9056 1136:51d7f3e06556 chris
require_dependency 'redmine/scm/adapters/darcs_adapter'
9057 0:513646585e45 Chris
9058
class Repository::Darcs < Repository
9059 245:051f544170fe Chris
  validates_presence_of :url, :log_encoding
9060 0:513646585e45 Chris
9061 1115:433d4f72a19b Chris
  def self.human_attribute_name(attribute_key_name, *args)
9062
    attr_name = attribute_key_name.to_s
9063 441:cbce1fd3b1b7 Chris
    if attr_name == "url"
9064
      attr_name = "path_to_repository"
9065
    end
9066 1115:433d4f72a19b Chris
    super(attr_name, *args)
9067 245:051f544170fe Chris
  end
9068
9069
  def self.scm_adapter_class
9070 0:513646585e45 Chris
    Redmine::Scm::Adapters::DarcsAdapter
9071
  end
9072 245:051f544170fe Chris
9073 0:513646585e45 Chris
  def self.scm_name
9074
    'Darcs'
9075
  end
9076 245:051f544170fe Chris
9077 441:cbce1fd3b1b7 Chris
  def supports_directory_revisions?
9078
    true
9079
  end
9080
9081 0:513646585e45 Chris
  def entry(path=nil, identifier=nil)
9082
    patch = identifier.nil? ? nil : changesets.find_by_revision(identifier)
9083
    scm.entry(path, patch.nil? ? nil : patch.scmid)
9084
  end
9085 441:cbce1fd3b1b7 Chris
9086 1517:dffacf8a6908 Chris
  def scm_entries(path=nil, identifier=nil)
9087 441:cbce1fd3b1b7 Chris
    patch = nil
9088
    if ! identifier.nil?
9089
      patch = changesets.find_by_revision(identifier)
9090
      return nil if patch.nil?
9091
    end
9092 0:513646585e45 Chris
    entries = scm.entries(path, patch.nil? ? nil : patch.scmid)
9093
    if entries
9094
      entries.each do |entry|
9095
        # Search the DB for the entry's last change
9096 441:cbce1fd3b1b7 Chris
        if entry.lastrev && !entry.lastrev.scmid.blank?
9097
          changeset = changesets.find_by_scmid(entry.lastrev.scmid)
9098
        end
9099 0:513646585e45 Chris
        if changeset
9100
          entry.lastrev.identifier = changeset.revision
9101 441:cbce1fd3b1b7 Chris
          entry.lastrev.name       = changeset.revision
9102
          entry.lastrev.time       = changeset.committed_on
9103
          entry.lastrev.author     = changeset.committer
9104 0:513646585e45 Chris
        end
9105
      end
9106
    end
9107
    entries
9108
  end
9109 1517:dffacf8a6908 Chris
  protected :scm_entries
9110 441:cbce1fd3b1b7 Chris
9111 0:513646585e45 Chris
  def cat(path, identifier=nil)
9112
    patch = identifier.nil? ? nil : changesets.find_by_revision(identifier.to_s)
9113
    scm.cat(path, patch.nil? ? nil : patch.scmid)
9114
  end
9115 441:cbce1fd3b1b7 Chris
9116 0:513646585e45 Chris
  def diff(path, rev, rev_to)
9117
    patch_from = changesets.find_by_revision(rev)
9118
    return nil if patch_from.nil?
9119
    patch_to = changesets.find_by_revision(rev_to) if rev_to
9120
    if path.blank?
9121 1115:433d4f72a19b Chris
      path = patch_from.filechanges.collect{|change| change.path}.join(' ')
9122 0:513646585e45 Chris
    end
9123
    patch_from ? scm.diff(path, patch_from.scmid, patch_to ? patch_to.scmid : nil) : nil
9124
  end
9125 441:cbce1fd3b1b7 Chris
9126 0:513646585e45 Chris
  def fetch_changesets
9127
    scm_info = scm.info
9128
    if scm_info
9129
      db_last_id = latest_changeset ? latest_changeset.scmid : nil
9130 441:cbce1fd3b1b7 Chris
      next_rev   = latest_changeset ? latest_changeset.revision.to_i + 1 : 1
9131 0:513646585e45 Chris
      # latest revision in the repository
9132 441:cbce1fd3b1b7 Chris
      scm_revision = scm_info.lastrev.scmid
9133 0:513646585e45 Chris
      unless changesets.find_by_scmid(scm_revision)
9134
        revisions = scm.revisions('', db_last_id, nil, :with_path => true)
9135
        transaction do
9136
          revisions.reverse_each do |revision|
9137 441:cbce1fd3b1b7 Chris
            changeset = Changeset.create(:repository   => self,
9138
                                         :revision     => next_rev,
9139
                                         :scmid        => revision.scmid,
9140
                                         :committer    => revision.author,
9141 0:513646585e45 Chris
                                         :committed_on => revision.time,
9142 441:cbce1fd3b1b7 Chris
                                         :comments     => revision.message)
9143 0:513646585e45 Chris
            revision.paths.each do |change|
9144
              changeset.create_change(change)
9145
            end
9146
            next_rev += 1
9147
          end if revisions
9148
        end
9149
      end
9150
    end
9151
  end
9152
end
9153 441:cbce1fd3b1b7 Chris
# Redmine - project management software
9154 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
9155 0:513646585e45 Chris
#
9156
# FileSystem adapter
9157
# File written by Paul Rivier, at Demotera.
9158
#
9159
# This program is free software; you can redistribute it and/or
9160
# modify it under the terms of the GNU General Public License
9161
# as published by the Free Software Foundation; either version 2
9162
# of the License, or (at your option) any later version.
9163 441:cbce1fd3b1b7 Chris
#
9164 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
9165
# but WITHOUT ANY WARRANTY; without even the implied warranty of
9166
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
9167
# GNU General Public License for more details.
9168 441:cbce1fd3b1b7 Chris
#
9169 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
9170
# along with this program; if not, write to the Free Software
9171
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
9172
9173 1136:51d7f3e06556 chris
require_dependency 'redmine/scm/adapters/filesystem_adapter'
9174 0:513646585e45 Chris
9175
class Repository::Filesystem < Repository
9176
  attr_protected :root_url
9177
  validates_presence_of :url
9178
9179 1115:433d4f72a19b Chris
  def self.human_attribute_name(attribute_key_name, *args)
9180
    attr_name = attribute_key_name.to_s
9181 441:cbce1fd3b1b7 Chris
    if attr_name == "url"
9182
      attr_name = "root_directory"
9183
    end
9184 1115:433d4f72a19b Chris
    super(attr_name, *args)
9185 245:051f544170fe Chris
  end
9186
9187
  def self.scm_adapter_class
9188 0:513646585e45 Chris
    Redmine::Scm::Adapters::FilesystemAdapter
9189
  end
9190 245:051f544170fe Chris
9191 0:513646585e45 Chris
  def self.scm_name
9192
    'Filesystem'
9193
  end
9194 245:051f544170fe Chris
9195 441:cbce1fd3b1b7 Chris
  def supports_all_revisions?
9196
    false
9197
  end
9198
9199 0:513646585e45 Chris
  def fetch_changesets
9200
    nil
9201
  end
9202
end
9203 441:cbce1fd3b1b7 Chris
# Redmine - project management software
9204 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
9205 0:513646585e45 Chris
# Copyright (C) 2007  Patrick Aljord patcito@ŋmail.com
9206 441:cbce1fd3b1b7 Chris
#
9207 0:513646585e45 Chris
# This program is free software; you can redistribute it and/or
9208
# modify it under the terms of the GNU General Public License
9209
# as published by the Free Software Foundation; either version 2
9210
# of the License, or (at your option) any later version.
9211 441:cbce1fd3b1b7 Chris
#
9212 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
9213
# but WITHOUT ANY WARRANTY; without even the implied warranty of
9214
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
9215
# GNU General Public License for more details.
9216 441:cbce1fd3b1b7 Chris
#
9217 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
9218
# along with this program; if not, write to the Free Software
9219
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
9220
9221 1136:51d7f3e06556 chris
require_dependency 'redmine/scm/adapters/git_adapter'
9222 0:513646585e45 Chris
9223
class Repository::Git < Repository
9224
  attr_protected :root_url
9225
  validates_presence_of :url
9226
9227 1115:433d4f72a19b Chris
  def self.human_attribute_name(attribute_key_name, *args)
9228
    attr_name = attribute_key_name.to_s
9229 441:cbce1fd3b1b7 Chris
    if attr_name == "url"
9230
      attr_name = "path_to_repository"
9231
    end
9232 1115:433d4f72a19b Chris
    super(attr_name, *args)
9233 245:051f544170fe Chris
  end
9234
9235
  def self.scm_adapter_class
9236 0:513646585e45 Chris
    Redmine::Scm::Adapters::GitAdapter
9237
  end
9238 245:051f544170fe Chris
9239 0:513646585e45 Chris
  def self.scm_name
9240
    'Git'
9241
  end
9242
9243 441:cbce1fd3b1b7 Chris
  def report_last_commit
9244
    extra_report_last_commit
9245
  end
9246
9247
  def extra_report_last_commit
9248
    return false if extra_info.nil?
9249
    v = extra_info["extra_report_last_commit"]
9250
    return false if v.nil?
9251
    v.to_s != '0'
9252
  end
9253
9254
  def supports_directory_revisions?
9255
    true
9256
  end
9257
9258 909:cbb26bc654de Chris
  def supports_revision_graph?
9259
    true
9260
  end
9261
9262 245:051f544170fe Chris
  def repo_log_encoding
9263
    'UTF-8'
9264
  end
9265
9266 117:af80e5618e9b Chris
  # Returns the identifier for the given git changeset
9267
  def self.changeset_identifier(changeset)
9268
    changeset.scmid
9269
  end
9270
9271
  # Returns the readable identifier for the given git changeset
9272
  def self.format_changeset_identifier(changeset)
9273
    changeset.revision[0, 8]
9274
  end
9275
9276 0:513646585e45 Chris
  def branches
9277
    scm.branches
9278
  end
9279
9280
  def tags
9281
    scm.tags
9282
  end
9283
9284 507:0c939c159af4 Chris
  def default_branch
9285
    scm.default_branch
9286 909:cbb26bc654de Chris
  rescue Exception => e
9287
    logger.error "git: error during get default branch: #{e.message}"
9288
    nil
9289 507:0c939c159af4 Chris
  end
9290
9291 245:051f544170fe Chris
  def find_changeset_by_name(name)
9292 1115:433d4f72a19b Chris
    if name.present?
9293
      changesets.where(:revision => name.to_s).first ||
9294
        changesets.where('scmid LIKE ?', "#{name}%").first
9295
    end
9296 245:051f544170fe Chris
  end
9297
9298 1517:dffacf8a6908 Chris
  def scm_entries(path=nil, identifier=nil)
9299
    scm.entries(path, identifier, :report_last_commit => extra_report_last_commit)
9300 441:cbce1fd3b1b7 Chris
  end
9301 1517:dffacf8a6908 Chris
  protected :scm_entries
9302 441:cbce1fd3b1b7 Chris
9303 909:cbb26bc654de Chris
  # With SCMs that have a sequential commit numbering,
9304
  # such as Subversion and Mercurial,
9305
  # Redmine is able to be clever and only fetch changesets
9306
  # going forward from the most recent one it knows about.
9307 1115:433d4f72a19b Chris
  #
9308 909:cbb26bc654de Chris
  # However, Git does not have a sequential commit numbering.
9309
  #
9310
  # In order to fetch only new adding revisions,
9311 1115:433d4f72a19b Chris
  # Redmine needs to save "heads".
9312 909:cbb26bc654de Chris
  #
9313 441:cbce1fd3b1b7 Chris
  # In Git and Mercurial, revisions are not in date order.
9314
  # Redmine Mercurial fixed issues.
9315
  #    * Redmine Takes Too Long On Large Mercurial Repository
9316
  #      http://www.redmine.org/issues/3449
9317
  #    * Sorting for changesets might go wrong on Mercurial repos
9318
  #      http://www.redmine.org/issues/3567
9319
  #
9320
  # Database revision column is text, so Redmine can not sort by revision.
9321
  # Mercurial has revision number, and revision number guarantees revision order.
9322
  # Redmine Mercurial model stored revisions ordered by database id to database.
9323
  # So, Redmine Mercurial model can use correct ordering revisions.
9324
  #
9325
  # Redmine Mercurial adapter uses "hg log -r 0:tip --limit 10"
9326
  # to get limited revisions from old to new.
9327
  # But, Git 1.7.3.4 does not support --reverse with -n or --skip.
9328
  #
9329 0:513646585e45 Chris
  # The repository can still be fully reloaded by calling #clear_changesets
9330
  # before fetching changesets (eg. for offline resync)
9331
  def fetch_changesets
9332 441:cbce1fd3b1b7 Chris
    scm_brs = branches
9333
    return if scm_brs.nil? || scm_brs.empty?
9334 1115:433d4f72a19b Chris
9335 441:cbce1fd3b1b7 Chris
    h1 = extra_info || {}
9336
    h  = h1.dup
9337 1115:433d4f72a19b Chris
    repo_heads = scm_brs.map{ |br| br.scmid }
9338
    h["heads"] ||= []
9339
    prev_db_heads = h["heads"].dup
9340
    if prev_db_heads.empty?
9341
      prev_db_heads += heads_from_branches_hash
9342
    end
9343
    return if prev_db_heads.sort == repo_heads.sort
9344
9345 441:cbce1fd3b1b7 Chris
    h["db_consistent"]  ||= {}
9346
    if changesets.count == 0
9347
      h["db_consistent"]["ordering"] = 1
9348
      merge_extra_info(h)
9349
      self.save
9350
    elsif ! h["db_consistent"].has_key?("ordering")
9351
      h["db_consistent"]["ordering"] = 0
9352
      merge_extra_info(h)
9353
      self.save
9354
    end
9355 1115:433d4f72a19b Chris
    save_revisions(prev_db_heads, repo_heads)
9356
  end
9357
9358
  def save_revisions(prev_db_heads, repo_heads)
9359
    h = {}
9360
    opts = {}
9361
    opts[:reverse]  = true
9362
    opts[:excludes] = prev_db_heads
9363
    opts[:includes] = repo_heads
9364
9365
    revisions = scm.revisions('', nil, nil, opts)
9366
    return if revisions.blank?
9367
9368
    # Make the search for existing revisions in the database in a more sufficient manner
9369
    #
9370
    # Git branch is the reference to the specific revision.
9371
    # Git can *delete* remote branch and *re-push* branch.
9372
    #
9373
    #  $ git push remote :branch
9374
    #  $ git push remote branch
9375
    #
9376
    # After deleting branch, revisions remain in repository until "git gc".
9377
    # On git 1.7.2.3, default pruning date is 2 weeks.
9378
    # So, "git log --not deleted_branch_head_revision" return code is 0.
9379
    #
9380
    # After re-pushing branch, "git log" returns revisions which are saved in database.
9381
    # So, Redmine needs to scan revisions and database every time.
9382
    #
9383
    # This is replacing the one-after-one queries.
9384 1517:dffacf8a6908 Chris
    # Find all revisions, that are in the database, and then remove them
9385
    # from the revision array.
9386 1115:433d4f72a19b Chris
    # Then later we won't need any conditions for db existence.
9387 1517:dffacf8a6908 Chris
    # Query for several revisions at once, and remove them
9388
    # from the revisions array, if they are there.
9389
    # Do this in chunks, to avoid eventual memory problems
9390
    # (in case of tens of thousands of commits).
9391 1115:433d4f72a19b Chris
    # If there are no revisions (because the original code's algorithm filtered them),
9392
    # then this part will be stepped over.
9393
    # We make queries, just if there is any revision.
9394
    limit = 100
9395
    offset = 0
9396
    revisions_copy = revisions.clone # revisions will change
9397
    while offset < revisions_copy.size
9398 1464:261b3d9a4903 Chris
      scmids = revisions_copy.slice(offset, limit).map{|x| x.scmid}
9399 1517:dffacf8a6908 Chris
      recent_changesets_slice = changesets.where(:scmid => scmids)
9400 1115:433d4f72a19b Chris
      # Subtract revisions that redmine already knows about
9401
      recent_revisions = recent_changesets_slice.map{|c| c.scmid}
9402
      revisions.reject!{|r| recent_revisions.include?(r.scmid)}
9403
      offset += limit
9404
    end
9405
    revisions.each do |rev|
9406
      transaction do
9407
        # There is no search in the db for this revision, because above we ensured,
9408
        # that it's not in the db.
9409
        save_revision(rev)
9410 245:051f544170fe Chris
      end
9411
    end
9412 1115:433d4f72a19b Chris
    h["heads"] = repo_heads.dup
9413
    merge_extra_info(h)
9414
    self.save
9415 0:513646585e45 Chris
  end
9416 1115:433d4f72a19b Chris
  private :save_revisions
9417 0:513646585e45 Chris
9418 441:cbce1fd3b1b7 Chris
  def save_revision(rev)
9419 1115:433d4f72a19b Chris
    parents = (rev.parents || []).collect{|rp| find_changeset_by_name(rp)}.compact
9420
    changeset = Changeset.create(
9421 441:cbce1fd3b1b7 Chris
              :repository   => self,
9422
              :revision     => rev.identifier,
9423
              :scmid        => rev.scmid,
9424
              :committer    => rev.author,
9425
              :committed_on => rev.time,
9426 1115:433d4f72a19b Chris
              :comments     => rev.message,
9427
              :parents      => parents
9428 441:cbce1fd3b1b7 Chris
              )
9429 1115:433d4f72a19b Chris
    unless changeset.new_record?
9430
      rev.paths.each { |change| changeset.create_change(change) }
9431 441:cbce1fd3b1b7 Chris
    end
9432 909:cbb26bc654de Chris
    changeset
9433 441:cbce1fd3b1b7 Chris
  end
9434
  private :save_revision
9435
9436 1115:433d4f72a19b Chris
  def heads_from_branches_hash
9437
    h1 = extra_info || {}
9438
    h  = h1.dup
9439
    h["branches"] ||= {}
9440
    h['branches'].map{|br, hs| hs['last_scmid']}
9441
  end
9442
9443 0:513646585e45 Chris
  def latest_changesets(path,rev,limit=10)
9444
    revisions = scm.revisions(path, nil, rev, :limit => limit, :all => false)
9445
    return [] if revisions.nil? || revisions.empty?
9446 1464:261b3d9a4903 Chris
    changesets.where(:scmid => revisions.map {|c| c.scmid}).all
9447 0:513646585e45 Chris
  end
9448 1115:433d4f72a19b Chris
9449
  def clear_extra_info_of_changesets
9450
    return if extra_info.nil?
9451
    v = extra_info["extra_report_last_commit"]
9452
    write_attribute(:extra_info, nil)
9453
    h = {}
9454
    h["extra_report_last_commit"] = v
9455
    merge_extra_info(h)
9456
    self.save
9457
  end
9458
  private :clear_extra_info_of_changesets
9459 0:513646585e45 Chris
end
9460 441:cbce1fd3b1b7 Chris
# Redmine - project management software
9461 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
9462 0:513646585e45 Chris
#
9463
# This program is free software; you can redistribute it and/or
9464
# modify it under the terms of the GNU General Public License
9465
# as published by the Free Software Foundation; either version 2
9466
# of the License, or (at your option) any later version.
9467 441:cbce1fd3b1b7 Chris
#
9468 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
9469
# but WITHOUT ANY WARRANTY; without even the implied warranty of
9470
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
9471
# GNU General Public License for more details.
9472 441:cbce1fd3b1b7 Chris
#
9473 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
9474
# along with this program; if not, write to the Free Software
9475
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
9476
9477 1136:51d7f3e06556 chris
require_dependency 'redmine/scm/adapters/mercurial_adapter'
9478 0:513646585e45 Chris
9479
class Repository::Mercurial < Repository
9480 119:8661b858af72 Chris
  # sort changesets by revision number
9481 909:cbb26bc654de Chris
  has_many :changesets,
9482
           :order       => "#{Changeset.table_name}.id DESC",
9483
           :foreign_key => 'repository_id'
9484 119:8661b858af72 Chris
9485 909:cbb26bc654de Chris
  attr_protected        :root_url
9486 225:6056b3c5f8f2 luis
  # validates_presence_of :url
9487 0:513646585e45 Chris
9488 909:cbb26bc654de Chris
  # number of changesets to fetch at once
9489
  FETCH_AT_ONCE = 100
9490 245:051f544170fe Chris
9491 1115:433d4f72a19b Chris
  def self.human_attribute_name(attribute_key_name, *args)
9492
    attr_name = attribute_key_name.to_s
9493 441:cbce1fd3b1b7 Chris
    if attr_name == "url"
9494
      attr_name = "path_to_repository"
9495
    end
9496 1115:433d4f72a19b Chris
    super(attr_name, *args)
9497 245:051f544170fe Chris
  end
9498
9499
  def self.scm_adapter_class
9500 0:513646585e45 Chris
    Redmine::Scm::Adapters::MercurialAdapter
9501
  end
9502 119:8661b858af72 Chris
9503 0:513646585e45 Chris
  def self.scm_name
9504
    'Mercurial'
9505
  end
9506 119:8661b858af72 Chris
9507 441:cbce1fd3b1b7 Chris
  def supports_directory_revisions?
9508
    true
9509
  end
9510
9511 909:cbb26bc654de Chris
  def supports_revision_graph?
9512
    true
9513
  end
9514
9515 245:051f544170fe Chris
  def repo_log_encoding
9516
    'UTF-8'
9517
  end
9518
9519 119:8661b858af72 Chris
  # Returns the readable identifier for the given mercurial changeset
9520
  def self.format_changeset_identifier(changeset)
9521 1517:dffacf8a6908 Chris
    "#{changeset.revision}:#{changeset.scmid[0, 12]}"
9522 119:8661b858af72 Chris
  end
9523
9524
  # Returns the identifier for the given Mercurial changeset
9525
  def self.changeset_identifier(changeset)
9526
    changeset.scmid
9527
  end
9528
9529
  def diff_format_revisions(cs, cs_to, sep=':')
9530
    super(cs, cs_to, ' ')
9531
  end
9532
9533 1517:dffacf8a6908 Chris
  def modify_entry_lastrev_identifier(entry)
9534
    if entry.lastrev && entry.lastrev.identifier
9535
      entry.lastrev.identifier = scmid_for_inserting_db(entry.lastrev.identifier)
9536
    end
9537
  end
9538
  private :modify_entry_lastrev_identifier
9539
9540
  def entry(path=nil, identifier=nil)
9541
    entry = scm.entry(path, identifier)
9542
    return nil if entry.nil?
9543
    modify_entry_lastrev_identifier(entry)
9544
    entry
9545
  end
9546
9547
  def scm_entries(path=nil, identifier=nil)
9548
    entries = scm.entries(path, identifier)
9549
    return nil if entries.nil?
9550
    entries.each {|entry| modify_entry_lastrev_identifier(entry)}
9551
    entries
9552
  end
9553
  protected :scm_entries
9554
9555 119:8661b858af72 Chris
  # Finds and returns a revision with a number or the beginning of a hash
9556
  def find_changeset_by_name(name)
9557 1115:433d4f72a19b Chris
    return nil if name.blank?
9558
    s = name.to_s
9559
    if /[^\d]/ =~ s or s.size > 8
9560
      cs = changesets.where(:scmid => s).first
9561 119:8661b858af72 Chris
    else
9562 1115:433d4f72a19b Chris
      cs = changesets.where(:revision => s).first
9563 119:8661b858af72 Chris
    end
9564 1115:433d4f72a19b Chris
    return cs if cs
9565
    changesets.where('scmid LIKE ?', "#{s}%").first
9566 119:8661b858af72 Chris
  end
9567
9568
  # Returns the latest changesets for +path+; sorted by revision number
9569 441:cbce1fd3b1b7 Chris
  #
9570
  # Because :order => 'id DESC' is defined at 'has_many',
9571
  # there is no need to set 'order'.
9572
  # But, MySQL test fails.
9573
  # Sqlite3 and PostgreSQL pass.
9574
  # Is this MySQL bug?
9575 119:8661b858af72 Chris
  def latest_changesets(path, rev, limit=10)
9576 1464:261b3d9a4903 Chris
    changesets.
9577
      includes(:user).
9578
      where(latest_changesets_cond(path, rev, limit)).
9579
      limit(limit).
9580
      order("#{Changeset.table_name}.id DESC").
9581
      all
9582 441:cbce1fd3b1b7 Chris
  end
9583
9584 1517:dffacf8a6908 Chris
  def is_short_id_in_db?
9585
    return @is_short_id_in_db unless @is_short_id_in_db.nil?
9586
    cs = changesets.first
9587
    @is_short_id_in_db = (!cs.nil? && cs.scmid.length != 40)
9588
  end
9589
  private :is_short_id_in_db?
9590
9591
  def scmid_for_inserting_db(scmid)
9592
    is_short_id_in_db? ? scmid[0, 12] : scmid
9593
  end
9594
9595
  def nodes_in_branch(rev, branch_limit)
9596
    scm.nodes_in_branch(rev, :limit => branch_limit).collect do |b|
9597
      scmid_for_inserting_db(b)
9598
    end
9599
  end
9600
9601
  def tag_scmid(rev)
9602
    scmid = scm.tagmap[rev]
9603
    scmid.nil? ? nil : scmid_for_inserting_db(scmid)
9604
  end
9605
9606 441:cbce1fd3b1b7 Chris
  def latest_changesets_cond(path, rev, limit)
9607
    cond, args = [], []
9608
    if scm.branchmap.member? rev
9609
      # Mercurial named branch is *stable* in each revision.
9610
      # So, named branch can be stored in database.
9611
      # Mercurial provides *bookmark* which is equivalent with git branch.
9612
      # But, bookmark is not implemented.
9613
      cond << "#{Changeset.table_name}.scmid IN (?)"
9614
      # Revisions in root directory and sub directory are not equal.
9615
      # So, in order to get correct limit, we need to get all revisions.
9616
      # But, it is very heavy.
9617
      # Mercurial does not treat direcotry.
9618
      # So, "hg log DIR" is very heavy.
9619
      branch_limit = path.blank? ? limit : ( limit * 5 )
9620 1517:dffacf8a6908 Chris
      args << nodes_in_branch(rev, branch_limit)
9621
    elsif last = rev ? find_changeset_by_name(tag_scmid(rev) || rev) : nil
9622 441:cbce1fd3b1b7 Chris
      cond << "#{Changeset.table_name}.id <= ?"
9623
      args << last.id
9624 119:8661b858af72 Chris
    end
9625 441:cbce1fd3b1b7 Chris
    unless path.blank?
9626
      cond << "EXISTS (SELECT * FROM #{Change.table_name}
9627
                 WHERE #{Change.table_name}.changeset_id = #{Changeset.table_name}.id
9628
                 AND (#{Change.table_name}.path = ?
9629
                       OR #{Change.table_name}.path LIKE ? ESCAPE ?))"
9630
      args << path.with_leading_slash
9631 909:cbb26bc654de Chris
      args << "#{path.with_leading_slash.gsub(%r{[%_\\]}) { |s| "\\#{s}" }}/%" << '\\'
9632 441:cbce1fd3b1b7 Chris
    end
9633
    [cond.join(' AND '), *args] unless cond.empty?
9634 119:8661b858af72 Chris
  end
9635 441:cbce1fd3b1b7 Chris
  private :latest_changesets_cond
9636 119:8661b858af72 Chris
9637 0:513646585e45 Chris
  def fetch_changesets
9638 507:0c939c159af4 Chris
    return if scm.info.nil?
9639 245:051f544170fe Chris
    scm_rev = scm.info.lastrev.revision.to_i
9640 909:cbb26bc654de Chris
    db_rev  = latest_changeset ? latest_changeset.revision.to_i : -1
9641 245:051f544170fe Chris
    return unless db_rev < scm_rev  # already up-to-date
9642
9643
    logger.debug "Fetching changesets for repository #{url}" if logger
9644
    (db_rev + 1).step(scm_rev, FETCH_AT_ONCE) do |i|
9645 1115:433d4f72a19b Chris
      scm.each_revision('', i, [i + FETCH_AT_ONCE - 1, scm_rev].min) do |re|
9646
        transaction do
9647 1517:dffacf8a6908 Chris
          parents = (re.parents || []).collect do |rp|
9648
            find_changeset_by_name(scmid_for_inserting_db(rp))
9649
          end.compact
9650 909:cbb26bc654de Chris
          cs = Changeset.create(:repository   => self,
9651
                                :revision     => re.revision,
9652 1517:dffacf8a6908 Chris
                                :scmid        => scmid_for_inserting_db(re.scmid),
9653 909:cbb26bc654de Chris
                                :committer    => re.author,
9654 245:051f544170fe Chris
                                :committed_on => re.time,
9655 1115:433d4f72a19b Chris
                                :comments     => re.message,
9656
                                :parents      => parents)
9657
          unless cs.new_record?
9658 1517:dffacf8a6908 Chris
            re.paths.each do |e|
9659
              if from_revision = e[:from_revision]
9660
                e[:from_revision] = scmid_for_inserting_db(from_revision)
9661
              end
9662
              cs.create_change(e)
9663
            end
9664 909:cbb26bc654de Chris
          end
9665 0:513646585e45 Chris
        end
9666
      end
9667
    end
9668
  end
9669
end
9670 441:cbce1fd3b1b7 Chris
# Redmine - project management software
9671 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
9672 0:513646585e45 Chris
#
9673
# This program is free software; you can redistribute it and/or
9674
# modify it under the terms of the GNU General Public License
9675
# as published by the Free Software Foundation; either version 2
9676
# of the License, or (at your option) any later version.
9677 441:cbce1fd3b1b7 Chris
#
9678 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
9679
# but WITHOUT ANY WARRANTY; without even the implied warranty of
9680
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
9681
# GNU General Public License for more details.
9682 441:cbce1fd3b1b7 Chris
#
9683 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
9684
# along with this program; if not, write to the Free Software
9685
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
9686
9687 1136:51d7f3e06556 chris
require_dependency 'redmine/scm/adapters/subversion_adapter'
9688 0:513646585e45 Chris
9689
class Repository::Subversion < Repository
9690
  attr_protected :root_url
9691
  validates_presence_of :url
9692 1464:261b3d9a4903 Chris
  validates_format_of :url, :with => %r{\A(http|https|svn(\+[^\s:\/\\]+)?|file):\/\/.+}i
9693 0:513646585e45 Chris
9694 245:051f544170fe Chris
  def self.scm_adapter_class
9695 0:513646585e45 Chris
    Redmine::Scm::Adapters::SubversionAdapter
9696
  end
9697 245:051f544170fe Chris
9698 0:513646585e45 Chris
  def self.scm_name
9699
    'Subversion'
9700
  end
9701
9702 441:cbce1fd3b1b7 Chris
  def supports_directory_revisions?
9703
    true
9704
  end
9705
9706 245:051f544170fe Chris
  def repo_log_encoding
9707
    'UTF-8'
9708
  end
9709
9710 0:513646585e45 Chris
  def latest_changesets(path, rev, limit=10)
9711
    revisions = scm.revisions(path, rev, nil, :limit => limit)
9712 1115:433d4f72a19b Chris
    if revisions
9713
      identifiers = revisions.collect(&:identifier).compact
9714
      changesets.where(:revision => identifiers).reorder("committed_on DESC").includes(:repository, :user).all
9715
    else
9716
      []
9717
    end
9718 0:513646585e45 Chris
  end
9719 441:cbce1fd3b1b7 Chris
9720 0:513646585e45 Chris
  # Returns a path relative to the url of the repository
9721
  def relative_path(path)
9722
    path.gsub(Regexp.new("^\/?#{Regexp.escape(relative_url)}"), '')
9723
  end
9724 441:cbce1fd3b1b7 Chris
9725 0:513646585e45 Chris
  def fetch_changesets
9726
    scm_info = scm.info
9727
    if scm_info
9728
      # latest revision found in database
9729
      db_revision = latest_changeset ? latest_changeset.revision.to_i : 0
9730
      # latest revision in the repository
9731
      scm_revision = scm_info.lastrev.identifier.to_i
9732
      if db_revision < scm_revision
9733
        logger.debug "Fetching changesets for repository #{url}" if logger && logger.debug?
9734
        identifier_from = db_revision + 1
9735
        while (identifier_from <= scm_revision)
9736
          # loads changesets by batches of 200
9737
          identifier_to = [identifier_from + 199, scm_revision].min
9738
          revisions = scm.revisions('', identifier_to, identifier_from, :with_paths => true)
9739
          revisions.reverse_each do |revision|
9740
            transaction do
9741 441:cbce1fd3b1b7 Chris
              changeset = Changeset.create(:repository   => self,
9742
                                           :revision     => revision.identifier,
9743
                                           :committer    => revision.author,
9744 0:513646585e45 Chris
                                           :committed_on => revision.time,
9745 441:cbce1fd3b1b7 Chris
                                           :comments     => revision.message)
9746
9747 0:513646585e45 Chris
              revision.paths.each do |change|
9748
                changeset.create_change(change)
9749
              end unless changeset.new_record?
9750
            end
9751
          end unless revisions.nil?
9752
          identifier_from = identifier_to + 1
9753
        end
9754
      end
9755
    end
9756
  end
9757 441:cbce1fd3b1b7 Chris
9758 1115:433d4f72a19b Chris
  protected
9759
9760
  def load_entries_changesets(entries)
9761
    return unless entries
9762 1517:dffacf8a6908 Chris
    entries_with_identifier =
9763
      entries.select {|entry| entry.lastrev && entry.lastrev.identifier.present?}
9764 1115:433d4f72a19b Chris
    identifiers = entries_with_identifier.map {|entry| entry.lastrev.identifier}.compact.uniq
9765
    if identifiers.any?
9766 1517:dffacf8a6908 Chris
      changesets_by_identifier =
9767
        changesets.where(:revision => identifiers).
9768
          includes(:user, :repository).group_by(&:revision)
9769 1115:433d4f72a19b Chris
      entries_with_identifier.each do |entry|
9770
        if m = changesets_by_identifier[entry.lastrev.identifier]
9771
          entry.changeset = m.first
9772
        end
9773
      end
9774
    end
9775
  end
9776
9777 0:513646585e45 Chris
  private
9778 441:cbce1fd3b1b7 Chris
9779 0:513646585e45 Chris
  # Returns the relative url of the repository
9780
  # Eg: root_url = file:///var/svn/foo
9781
  #     url      = file:///var/svn/foo/bar
9782
  #     => returns /bar
9783
  def relative_url
9784
    @relative_url ||= url.gsub(Regexp.new("^#{Regexp.escape(root_url || scm.root_url)}", Regexp::IGNORECASE), '')
9785
  end
9786
end
9787 441:cbce1fd3b1b7 Chris
# Redmine - project management software
9788 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
9789 0:513646585e45 Chris
#
9790
# This program is free software; you can redistribute it and/or
9791
# modify it under the terms of the GNU General Public License
9792
# as published by the Free Software Foundation; either version 2
9793
# of the License, or (at your option) any later version.
9794 909:cbb26bc654de Chris
#
9795 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
9796
# but WITHOUT ANY WARRANTY; without even the implied warranty of
9797
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
9798
# GNU General Public License for more details.
9799 909:cbb26bc654de Chris
#
9800 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
9801
# along with this program; if not, write to the Free Software
9802
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
9803
9804
class Role < ActiveRecord::Base
9805 1115:433d4f72a19b Chris
  # Custom coder for the permissions attribute that should be an
9806
  # array of symbols. Rails 3 uses Psych which can be *unbelievably*
9807
  # slow on some platforms (eg. mingw32).
9808
  class PermissionsAttributeCoder
9809
    def self.load(str)
9810
      str.to_s.scan(/:([a-z0-9_]+)/).flatten.map(&:to_sym)
9811
    end
9812
9813
    def self.dump(value)
9814
      YAML.dump(value)
9815
    end
9816
  end
9817
9818 0:513646585e45 Chris
  # Built-in roles
9819
  BUILTIN_NON_MEMBER = 1
9820
  BUILTIN_ANONYMOUS  = 2
9821 909:cbb26bc654de Chris
9822 441:cbce1fd3b1b7 Chris
  ISSUES_VISIBILITY_OPTIONS = [
9823
    ['all', :label_issues_visibility_all],
9824
    ['default', :label_issues_visibility_public],
9825
    ['own', :label_issues_visibility_own]
9826
  ]
9827 0:513646585e45 Chris
9828 1464:261b3d9a4903 Chris
  scope :sorted, lambda { order("#{table_name}.builtin ASC, #{table_name}.position ASC") }
9829
  scope :givable, lambda { order("#{table_name}.position ASC").where(:builtin => 0) }
9830 1115:433d4f72a19b Chris
  scope :builtin, lambda { |*args|
9831
    compare = (args.first == true ? 'not' : '')
9832
    where("#{compare} builtin = 0")
9833 0:513646585e45 Chris
  }
9834 909:cbb26bc654de Chris
9835 0:513646585e45 Chris
  before_destroy :check_deletable
9836 1115:433d4f72a19b Chris
  has_many :workflow_rules, :dependent => :delete_all do
9837 0:513646585e45 Chris
    def copy(source_role)
9838 1115:433d4f72a19b Chris
      WorkflowRule.copy(nil, source_role, nil, proxy_association.owner)
9839 0:513646585e45 Chris
    end
9840
  end
9841 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"
9842 909:cbb26bc654de Chris
9843 0:513646585e45 Chris
  has_many :member_roles, :dependent => :destroy
9844
  has_many :members, :through => :member_roles
9845
  acts_as_list
9846 909:cbb26bc654de Chris
9847 1115:433d4f72a19b Chris
  serialize :permissions, ::Role::PermissionsAttributeCoder
9848 0:513646585e45 Chris
  attr_protected :builtin
9849
9850
  validates_presence_of :name
9851
  validates_uniqueness_of :name
9852
  validates_length_of :name, :maximum => 30
9853 441:cbce1fd3b1b7 Chris
  validates_inclusion_of :issues_visibility,
9854
    :in => ISSUES_VISIBILITY_OPTIONS.collect(&:first),
9855
    :if => lambda {|role| role.respond_to?(:issues_visibility)}
9856 909:cbb26bc654de Chris
9857 1115:433d4f72a19b Chris
  # Copies attributes from another role, arg can be an id or a Role
9858
  def copy_from(arg, options={})
9859
    return unless arg.present?
9860
    role = arg.is_a?(Role) ? arg : Role.find_by_id(arg.to_s)
9861
    self.attributes = role.attributes.dup.except("id", "name", "position", "builtin", "permissions")
9862
    self.permissions = role.permissions.dup
9863
    self
9864 0:513646585e45 Chris
  end
9865 909:cbb26bc654de Chris
9866 0:513646585e45 Chris
  def permissions=(perms)
9867
    perms = perms.collect {|p| p.to_sym unless p.blank? }.compact.uniq if perms
9868
    write_attribute(:permissions, perms)
9869
  end
9870
9871
  def add_permission!(*perms)
9872
    self.permissions = [] unless permissions.is_a?(Array)
9873
9874
    permissions_will_change!
9875
    perms.each do |p|
9876
      p = p.to_sym
9877
      permissions << p unless permissions.include?(p)
9878
    end
9879
    save!
9880
  end
9881
9882
  def remove_permission!(*perms)
9883
    return unless permissions.is_a?(Array)
9884
    permissions_will_change!
9885
    perms.each { |p| permissions.delete(p.to_sym) }
9886
    save!
9887
  end
9888 909:cbb26bc654de Chris
9889 0:513646585e45 Chris
  # Returns true if the role has the given permission
9890
  def has_permission?(perm)
9891
    !permissions.nil? && permissions.include?(perm.to_sym)
9892
  end
9893 909:cbb26bc654de Chris
9894 0:513646585e45 Chris
  def <=>(role)
9895 1115:433d4f72a19b Chris
    if role
9896
      if builtin == role.builtin
9897
        position <=> role.position
9898
      else
9899
        builtin <=> role.builtin
9900
      end
9901
    else
9902
      -1
9903
    end
9904 0:513646585e45 Chris
  end
9905 909:cbb26bc654de Chris
9906 0:513646585e45 Chris
  def to_s
9907
    name
9908
  end
9909 909:cbb26bc654de Chris
9910 441:cbce1fd3b1b7 Chris
  def name
9911
    case builtin
9912
    when 1; l(:label_role_non_member, :default => read_attribute(:name))
9913
    when 2; l(:label_role_anonymous,  :default => read_attribute(:name))
9914
    else; read_attribute(:name)
9915
    end
9916
  end
9917 909:cbb26bc654de Chris
9918 0:513646585e45 Chris
  # Return true if the role is a builtin role
9919
  def builtin?
9920
    self.builtin != 0
9921
  end
9922 909:cbb26bc654de Chris
9923 1115:433d4f72a19b Chris
  # Return true if the role is the anonymous role
9924
  def anonymous?
9925
    builtin == 2
9926
  end
9927 1464:261b3d9a4903 Chris
9928 0:513646585e45 Chris
  # Return true if the role is a project member role
9929
  def member?
9930
    !self.builtin?
9931
  end
9932 909:cbb26bc654de Chris
9933 0:513646585e45 Chris
  # Return true if role is allowed to do the specified action
9934
  # action can be:
9935
  # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
9936
  # * a permission Symbol (eg. :edit_project)
9937
  def allowed_to?(action)
9938
    if action.is_a? Hash
9939
      allowed_actions.include? "#{action[:controller]}/#{action[:action]}"
9940
    else
9941
      allowed_permissions.include? action
9942
    end
9943
  end
9944 909:cbb26bc654de Chris
9945 0:513646585e45 Chris
  # Return all the permissions that can be given to the role
9946
  def setable_permissions
9947
    setable_permissions = Redmine::AccessControl.permissions - Redmine::AccessControl.public_permissions
9948
    setable_permissions -= Redmine::AccessControl.members_only_permissions if self.builtin == BUILTIN_NON_MEMBER
9949
    setable_permissions -= Redmine::AccessControl.loggedin_only_permissions if self.builtin == BUILTIN_ANONYMOUS
9950
    setable_permissions
9951
  end
9952
9953
  # Find all the roles that can be given to a project member
9954
  def self.find_all_givable
9955 1115:433d4f72a19b Chris
    Role.givable.all
9956 0:513646585e45 Chris
  end
9957
9958
  # Return the builtin 'non member' role.  If the role doesn't exist,
9959
  # it will be created on the fly.
9960
  def self.non_member
9961 909:cbb26bc654de Chris
    find_or_create_system_role(BUILTIN_NON_MEMBER, 'Non member')
9962 0:513646585e45 Chris
  end
9963
9964
  # Return the builtin 'anonymous' role.  If the role doesn't exist,
9965
  # it will be created on the fly.
9966
  def self.anonymous
9967 909:cbb26bc654de Chris
    find_or_create_system_role(BUILTIN_ANONYMOUS, 'Anonymous')
9968 0:513646585e45 Chris
  end
9969
9970
private
9971 909:cbb26bc654de Chris
9972 0:513646585e45 Chris
  def allowed_permissions
9973
    @allowed_permissions ||= permissions + Redmine::AccessControl.public_permissions.collect {|p| p.name}
9974
  end
9975
9976
  def allowed_actions
9977
    @actions_allowed ||= allowed_permissions.inject([]) { |actions, permission| actions += Redmine::AccessControl.allowed_actions(permission) }.flatten
9978
  end
9979 909:cbb26bc654de Chris
9980 0:513646585e45 Chris
  def check_deletable
9981
    raise "Can't delete role" if members.any?
9982
    raise "Can't delete builtin role" if builtin?
9983
  end
9984 909:cbb26bc654de Chris
9985
  def self.find_or_create_system_role(builtin, name)
9986 1115:433d4f72a19b Chris
    role = where(:builtin => builtin).first
9987 909:cbb26bc654de Chris
    if role.nil?
9988
      role = create(:name => name, :position => 0) do |r|
9989
        r.builtin = builtin
9990
      end
9991
      raise "Unable to create the #{name} role." if role.new_record?
9992
    end
9993
    role
9994
  end
9995 0:513646585e45 Chris
end
9996 441:cbce1fd3b1b7 Chris
# Redmine - project management software
9997 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
9998 0:513646585e45 Chris
#
9999
# This program is free software; you can redistribute it and/or
10000
# modify it under the terms of the GNU General Public License
10001
# as published by the Free Software Foundation; either version 2
10002
# of the License, or (at your option) any later version.
10003 441:cbce1fd3b1b7 Chris
#
10004 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
10005
# but WITHOUT ANY WARRANTY; without even the implied warranty of
10006
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
10007
# GNU General Public License for more details.
10008 441:cbce1fd3b1b7 Chris
#
10009 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
10010
# along with this program; if not, write to the Free Software
10011
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
10012
10013
class Setting < ActiveRecord::Base
10014
10015
  DATE_FORMATS = [
10016 1464:261b3d9a4903 Chris
        '%Y-%m-%d',
10017
        '%d/%m/%Y',
10018
        '%d.%m.%Y',
10019
        '%d-%m-%Y',
10020
        '%m/%d/%Y',
10021
        '%d %b %Y',
10022
        '%d %B %Y',
10023
        '%b %d, %Y',
10024
        '%B %d, %Y'
10025 0:513646585e45 Chris
    ]
10026 441:cbce1fd3b1b7 Chris
10027 0:513646585e45 Chris
  TIME_FORMATS = [
10028
    '%H:%M',
10029
    '%I:%M %p'
10030
    ]
10031 441:cbce1fd3b1b7 Chris
10032 0:513646585e45 Chris
  ENCODINGS = %w(US-ASCII
10033
                  windows-1250
10034
                  windows-1251
10035
                  windows-1252
10036
                  windows-1253
10037
                  windows-1254
10038
                  windows-1255
10039
                  windows-1256
10040
                  windows-1257
10041
                  windows-1258
10042
                  windows-31j
10043
                  ISO-2022-JP
10044
                  ISO-2022-KR
10045
                  ISO-8859-1
10046
                  ISO-8859-2
10047
                  ISO-8859-3
10048
                  ISO-8859-4
10049
                  ISO-8859-5
10050
                  ISO-8859-6
10051
                  ISO-8859-7
10052
                  ISO-8859-8
10053
                  ISO-8859-9
10054
                  ISO-8859-13
10055
                  ISO-8859-15
10056
                  KOI8-R
10057
                  UTF-8
10058
                  UTF-16
10059
                  UTF-16BE
10060
                  UTF-16LE
10061
                  EUC-JP
10062
                  Shift_JIS
10063 245:051f544170fe Chris
                  CP932
10064 0:513646585e45 Chris
                  GB18030
10065
                  GBK
10066
                  ISCII91
10067
                  EUC-KR
10068
                  Big5
10069
                  Big5-HKSCS
10070
                  TIS-620)
10071 441:cbce1fd3b1b7 Chris
10072 0:513646585e45 Chris
  cattr_accessor :available_settings
10073 909:cbb26bc654de Chris
  @@available_settings = YAML::load(File.open("#{Rails.root}/config/settings.yml"))
10074 0:513646585e45 Chris
  Redmine::Plugin.all.each do |plugin|
10075
    next unless plugin.settings
10076 441:cbce1fd3b1b7 Chris
    @@available_settings["plugin_#{plugin.id}"] = {'default' => plugin.settings[:default], 'serialized' => true}
10077 0:513646585e45 Chris
  end
10078 441:cbce1fd3b1b7 Chris
10079 0:513646585e45 Chris
  validates_uniqueness_of :name
10080
  validates_inclusion_of :name, :in => @@available_settings.keys
10081 1517:dffacf8a6908 Chris
  validates_numericality_of :value, :only_integer => true, :if => Proc.new { |setting|
10082
    (s = @@available_settings[setting.name]) && s['format'] == 'int'
10083
  }
10084 0:513646585e45 Chris
10085
  # Hash used to cache setting values
10086
  @cached_settings = {}
10087
  @cached_cleared_on = Time.now
10088 441:cbce1fd3b1b7 Chris
10089 0:513646585e45 Chris
  def value
10090
    v = read_attribute(:value)
10091
    # Unserialize serialized settings
10092
    v = YAML::load(v) if @@available_settings[name]['serialized'] && v.is_a?(String)
10093
    v = v.to_sym if @@available_settings[name]['format'] == 'symbol' && !v.blank?
10094
    v
10095
  end
10096 441:cbce1fd3b1b7 Chris
10097 0:513646585e45 Chris
  def value=(v)
10098
    v = v.to_yaml if v && @@available_settings[name] && @@available_settings[name]['serialized']
10099
    write_attribute(:value, v.to_s)
10100
  end
10101 441:cbce1fd3b1b7 Chris
10102 0:513646585e45 Chris
  # Returns the value of the setting named name
10103
  def self.[](name)
10104
    v = @cached_settings[name]
10105
    v ? v : (@cached_settings[name] = find_or_default(name).value)
10106
  end
10107 441:cbce1fd3b1b7 Chris
10108 0:513646585e45 Chris
  def self.[]=(name, v)
10109
    setting = find_or_default(name)
10110
    setting.value = (v ? v : "")
10111
    @cached_settings[name] = nil
10112
    setting.save
10113
    setting.value
10114
  end
10115 441:cbce1fd3b1b7 Chris
10116 0:513646585e45 Chris
  # Defines getter and setter for each setting
10117
  # Then setting values can be read using: Setting.some_setting_name
10118
  # or set using Setting.some_setting_name = "some value"
10119
  @@available_settings.each do |name, params|
10120
    src = <<-END_SRC
10121
    def self.#{name}
10122
      self[:#{name}]
10123
    end
10124
10125
    def self.#{name}?
10126
      self[:#{name}].to_i > 0
10127
    end
10128
10129
    def self.#{name}=(value)
10130
      self[:#{name}] = value
10131
    end
10132 1464:261b3d9a4903 Chris
END_SRC
10133 0:513646585e45 Chris
    class_eval src, __FILE__, __LINE__
10134
  end
10135 441:cbce1fd3b1b7 Chris
10136 1464:261b3d9a4903 Chris
  # Sets a setting value from params
10137
  def self.set_from_params(name, params)
10138
    params = params.dup
10139
    params.delete_if {|v| v.blank? } if params.is_a?(Array)
10140
10141
    m = "#{name}_from_params"
10142
    if respond_to? m
10143
      self[name.to_sym] = send m, params
10144
    else
10145
      self[name.to_sym] = params
10146
    end
10147
  end
10148
10149
  # Returns a hash suitable for commit_update_keywords setting
10150
  #
10151
  # Example:
10152
  # params = {:keywords => ['fixes', 'closes'], :status_id => ["3", "5"], :done_ratio => ["", "100"]}
10153
  # Setting.commit_update_keywords_from_params(params)
10154
  # # => [{'keywords => 'fixes', 'status_id' => "3"}, {'keywords => 'closes', 'status_id' => "5", 'done_ratio' => "100"}]
10155
  def self.commit_update_keywords_from_params(params)
10156
    s = []
10157
    if params.is_a?(Hash) && params.key?(:keywords) && params.values.all? {|v| v.is_a? Array}
10158
      attributes = params.except(:keywords).keys
10159
      params[:keywords].each_with_index do |keywords, i|
10160
        next if keywords.blank?
10161
        s << attributes.inject({}) {|h, a|
10162
          value = params[a][i].to_s
10163
          h[a.to_s] = value if value.present?
10164
          h
10165
        }.merge('keywords' => keywords)
10166
      end
10167
    end
10168
    s
10169
  end
10170
10171 0:513646585e45 Chris
  # Helper that returns an array based on per_page_options setting
10172
  def self.per_page_options_array
10173
    per_page_options.split(%r{[\s,]}).collect(&:to_i).select {|n| n > 0}.sort
10174
  end
10175 441:cbce1fd3b1b7 Chris
10176 1464:261b3d9a4903 Chris
  # Helper that returns a Hash with single update keywords as keys
10177
  def self.commit_update_keywords_array
10178
    a = []
10179
    if commit_update_keywords.is_a?(Array)
10180
      commit_update_keywords.each do |rule|
10181
        next unless rule.is_a?(Hash)
10182
        rule = rule.dup
10183
        rule.delete_if {|k, v| v.blank?}
10184
        keywords = rule['keywords'].to_s.downcase.split(",").map(&:strip).reject(&:blank?)
10185
        next if keywords.empty?
10186
        a << rule.merge('keywords' => keywords)
10187
      end
10188
    end
10189
    a
10190
  end
10191
10192
  def self.commit_fix_keywords
10193
    ActiveSupport::Deprecation.warn "Setting.commit_fix_keywords is deprecated and will be removed in Redmine 3"
10194
    if commit_update_keywords.is_a?(Array)
10195
      commit_update_keywords.first && commit_update_keywords.first['keywords']
10196
    end
10197
  end
10198
10199
  def self.commit_fix_status_id
10200
    ActiveSupport::Deprecation.warn "Setting.commit_fix_status_id is deprecated and will be removed in Redmine 3"
10201
    if commit_update_keywords.is_a?(Array)
10202
      commit_update_keywords.first && commit_update_keywords.first['status_id']
10203
    end
10204
  end
10205
10206
  def self.commit_fix_done_ratio
10207
    ActiveSupport::Deprecation.warn "Setting.commit_fix_done_ratio is deprecated and will be removed in Redmine 3"
10208
    if commit_update_keywords.is_a?(Array)
10209
      commit_update_keywords.first && commit_update_keywords.first['done_ratio']
10210
    end
10211
  end
10212
10213 0:513646585e45 Chris
  def self.openid?
10214
    Object.const_defined?(:OpenID) && self[:openid].to_i > 0
10215
  end
10216 441:cbce1fd3b1b7 Chris
10217 0:513646585e45 Chris
  # Checks if settings have changed since the values were read
10218
  # and clears the cache hash if it's the case
10219
  # Called once per request
10220
  def self.check_cache
10221
    settings_updated_on = Setting.maximum(:updated_on)
10222
    if settings_updated_on && @cached_cleared_on <= settings_updated_on
10223 909:cbb26bc654de Chris
      clear_cache
10224 0:513646585e45 Chris
    end
10225
  end
10226 1464:261b3d9a4903 Chris
10227 909:cbb26bc654de Chris
  # Clears the settings cache
10228
  def self.clear_cache
10229
    @cached_settings.clear
10230
    @cached_cleared_on = Time.now
10231
    logger.info "Settings cache cleared." if logger
10232
  end
10233 441:cbce1fd3b1b7 Chris
10234 0:513646585e45 Chris
private
10235
  # Returns the Setting instance for the setting named name
10236
  # (record found in database or new record with default value)
10237
  def self.find_or_default(name)
10238
    name = name.to_s
10239 441:cbce1fd3b1b7 Chris
    raise "There's no setting named #{name}" unless @@available_settings.has_key?(name)
10240 1517:dffacf8a6908 Chris
    setting = where(:name => name).first
10241 909:cbb26bc654de Chris
    unless setting
10242 1517:dffacf8a6908 Chris
      setting = new
10243
      setting.name = name
10244 909:cbb26bc654de Chris
      setting.value = @@available_settings[name]['default']
10245
    end
10246
    setting
10247 0:513646585e45 Chris
  end
10248
end
10249 56:1d072f771b4d luisf
class SsamrUserDetail < ActiveRecord::Base
10250 60:cf39b52d24b4 luisf
  belongs_to :user
10251 64:9d42bcda8cea luisf
10252
  validates_presence_of :description
10253 163:9a5a265e77f0 luisf
10254
  validate :check_institution
10255
10256
  def check_institution()
10257
    errors.add(:institution_id, "Please insert an institution") if
10258
      institution_id.blank? and other_institution.blank?
10259
  end
10260
10261 525:8a26a0e291cf luis
  def institution_name()
10262
    if not self.institution_type.nil?
10263
      if self.institution_type
10264
        Institution.find(self.institution_id).name
10265
      else
10266
        self.other_institution
10267
      end
10268
    else
10269
      ""
10270
    end
10271
  end
10272 56:1d072f771b4d luisf
end
10273 441:cbce1fd3b1b7 Chris
# Redmine - project management software
10274 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
10275 0:513646585e45 Chris
#
10276
# This program is free software; you can redistribute it and/or
10277
# modify it under the terms of the GNU General Public License
10278
# as published by the Free Software Foundation; either version 2
10279
# of the License, or (at your option) any later version.
10280 441:cbce1fd3b1b7 Chris
#
10281 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
10282
# but WITHOUT ANY WARRANTY; without even the implied warranty of
10283
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
10284
# GNU General Public License for more details.
10285 441:cbce1fd3b1b7 Chris
#
10286 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
10287
# along with this program; if not, write to the Free Software
10288
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
10289
10290
class TimeEntry < ActiveRecord::Base
10291 929:5f33065ddc4b Chris
  include Redmine::SafeAttributes
10292 0:513646585e45 Chris
  # could have used polymorphic association
10293
  # project association here allows easy loading of time entries at project level with one database trip
10294
  belongs_to :project
10295
  belongs_to :issue
10296
  belongs_to :user
10297
  belongs_to :activity, :class_name => 'TimeEntryActivity', :foreign_key => 'activity_id'
10298 441:cbce1fd3b1b7 Chris
10299 0:513646585e45 Chris
  attr_protected :project_id, :user_id, :tyear, :tmonth, :tweek
10300
10301
  acts_as_customizable
10302
  acts_as_event :title => Proc.new {|o| "#{l_hours(o.hours)} (#{(o.issue || o.project).event_title})"},
10303 37:94944d00e43c chris
                :url => Proc.new {|o| {:controller => 'timelog', :action => 'index', :project_id => o.project, :issue_id => o.issue}},
10304 0:513646585e45 Chris
                :author => :user,
10305 1464:261b3d9a4903 Chris
                :group => :issue,
10306 0:513646585e45 Chris
                :description => :comments
10307
10308
  acts_as_activity_provider :timestamp => "#{table_name}.created_on",
10309
                            :author_key => :user_id,
10310 441:cbce1fd3b1b7 Chris
                            :find_options => {:include => :project}
10311 0:513646585e45 Chris
10312
  validates_presence_of :user_id, :activity_id, :project_id, :hours, :spent_on
10313
  validates_numericality_of :hours, :allow_nil => true, :message => :invalid
10314
  validates_length_of :comments, :maximum => 255, :allow_nil => true
10315 1464:261b3d9a4903 Chris
  validates :spent_on, :date => true
10316 909:cbb26bc654de Chris
  before_validation :set_project_if_nil
10317
  validate :validate_time_entry
10318 0:513646585e45 Chris
10319 1464:261b3d9a4903 Chris
  scope :visible, lambda {|*args|
10320
    includes(:project).where(Project.allowed_to_condition(args.shift || User.current, :view_time_entries, *args))
10321
  }
10322
  scope :on_issue, lambda {|issue|
10323
    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}")
10324
  }
10325
  scope :on_project, lambda {|project, include_subprojects|
10326
    includes(:project).where(project.project_condition(include_subprojects))
10327
  }
10328 1115:433d4f72a19b Chris
  scope :spent_between, lambda {|from, to|
10329
    if from && to
10330 1464:261b3d9a4903 Chris
     where("#{TimeEntry.table_name}.spent_on BETWEEN ? AND ?", from, to)
10331 1115:433d4f72a19b Chris
    elsif from
10332 1464:261b3d9a4903 Chris
     where("#{TimeEntry.table_name}.spent_on >= ?", from)
10333 1115:433d4f72a19b Chris
    elsif to
10334 1464:261b3d9a4903 Chris
     where("#{TimeEntry.table_name}.spent_on <= ?", to)
10335 1115:433d4f72a19b Chris
    else
10336 1464:261b3d9a4903 Chris
     where(nil)
10337 1115:433d4f72a19b Chris
    end
10338
  }
10339 441:cbce1fd3b1b7 Chris
10340 1115:433d4f72a19b Chris
  safe_attributes 'hours', 'comments', 'issue_id', 'activity_id', 'spent_on', 'custom_field_values', 'custom_fields'
10341 929:5f33065ddc4b Chris
10342 1115:433d4f72a19b Chris
  def initialize(attributes=nil, *args)
10343
    super
10344 0:513646585e45 Chris
    if new_record? && self.activity.nil?
10345
      if default_activity = TimeEntryActivity.default
10346
        self.activity_id = default_activity.id
10347
      end
10348
      self.hours = nil if hours == 0
10349
    end
10350
  end
10351 441:cbce1fd3b1b7 Chris
10352 1517:dffacf8a6908 Chris
  def safe_attributes=(attrs, user=User.current)
10353
    attrs = super
10354
    if !new_record? && issue && issue.project_id != project_id
10355
      if user.allowed_to?(:log_time, issue.project)
10356
        self.project_id = issue.project_id
10357
      end
10358
    end
10359
    attrs
10360
  end
10361
10362 909:cbb26bc654de Chris
  def set_project_if_nil
10363 0:513646585e45 Chris
    self.project = issue.project if issue && project.nil?
10364
  end
10365 441:cbce1fd3b1b7 Chris
10366 909:cbb26bc654de Chris
  def validate_time_entry
10367 0:513646585e45 Chris
    errors.add :hours, :invalid if hours && (hours < 0 || hours >= 1000)
10368
    errors.add :project_id, :invalid if project.nil?
10369
    errors.add :issue_id, :invalid if (issue_id && !issue) || (issue && project!=issue.project)
10370
  end
10371 441:cbce1fd3b1b7 Chris
10372 0:513646585e45 Chris
  def hours=(h)
10373
    write_attribute :hours, (h.is_a?(String) ? (h.to_hours || h) : h)
10374
  end
10375 441:cbce1fd3b1b7 Chris
10376 1115:433d4f72a19b Chris
  def hours
10377
    h = read_attribute(:hours)
10378
    if h.is_a?(Float)
10379
      h.round(2)
10380
    else
10381
      h
10382
    end
10383
  end
10384
10385 0:513646585e45 Chris
  # tyear, tmonth, tweek assigned where setting spent_on attributes
10386
  # these attributes make time aggregations easier
10387
  def spent_on=(date)
10388
    super
10389 128:07fa8a8b56a8 Chris
    if spent_on.is_a?(Time)
10390
      self.spent_on = spent_on.to_date
10391
    end
10392 0:513646585e45 Chris
    self.tyear = spent_on ? spent_on.year : nil
10393
    self.tmonth = spent_on ? spent_on.month : nil
10394
    self.tweek = spent_on ? Date.civil(spent_on.year, spent_on.month, spent_on.day).cweek : nil
10395
  end
10396 441:cbce1fd3b1b7 Chris
10397 0:513646585e45 Chris
  # Returns true if the time entry can be edited by usr, otherwise false
10398
  def editable_by?(usr)
10399
    (usr == user && usr.allowed_to?(:edit_own_time_entries, project)) || usr.allowed_to?(:edit_time_entries, project)
10400
  end
10401
end
10402 909:cbb26bc654de Chris
# Redmine - project management software
10403 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
10404 0:513646585e45 Chris
#
10405
# This program is free software; you can redistribute it and/or
10406
# modify it under the terms of the GNU General Public License
10407
# as published by the Free Software Foundation; either version 2
10408
# of the License, or (at your option) any later version.
10409 909:cbb26bc654de Chris
#
10410 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
10411
# but WITHOUT ANY WARRANTY; without even the implied warranty of
10412
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
10413
# GNU General Public License for more details.
10414 909:cbb26bc654de Chris
#
10415 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
10416
# along with this program; if not, write to the Free Software
10417
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
10418
10419
class TimeEntryActivity < Enumeration
10420
  has_many :time_entries, :foreign_key => 'activity_id'
10421
10422
  OptionName = :enumeration_activities
10423 909:cbb26bc654de Chris
10424 0:513646585e45 Chris
  def option_name
10425
    OptionName
10426
  end
10427
10428 1464:261b3d9a4903 Chris
  def objects
10429
    TimeEntry.where(:activity_id => self_and_descendants(1).map(&:id))
10430
  end
10431
10432 0:513646585e45 Chris
  def objects_count
10433 1464:261b3d9a4903 Chris
    objects.count
10434 0:513646585e45 Chris
  end
10435
10436
  def transfer_relations(to)
10437 1464:261b3d9a4903 Chris
    objects.update_all(:activity_id => to.id)
10438 0:513646585e45 Chris
  end
10439
end
10440 909:cbb26bc654de Chris
# Redmine - project management software
10441 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
10442 0:513646585e45 Chris
#
10443
# This program is free software; you can redistribute it and/or
10444
# modify it under the terms of the GNU General Public License
10445
# as published by the Free Software Foundation; either version 2
10446
# of the License, or (at your option) any later version.
10447 909:cbb26bc654de Chris
#
10448 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
10449
# but WITHOUT ANY WARRANTY; without even the implied warranty of
10450
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
10451
# GNU General Public License for more details.
10452 909:cbb26bc654de Chris
#
10453 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
10454
# along with this program; if not, write to the Free Software
10455
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
10456
10457
class TimeEntryActivityCustomField < CustomField
10458
  def type_name
10459
    :enumeration_activities
10460
  end
10461
end
10462 909:cbb26bc654de Chris
# Redmine - project management software
10463 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
10464 0:513646585e45 Chris
#
10465
# This program is free software; you can redistribute it and/or
10466
# modify it under the terms of the GNU General Public License
10467
# as published by the Free Software Foundation; either version 2
10468
# of the License, or (at your option) any later version.
10469 909:cbb26bc654de Chris
#
10470 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
10471
# but WITHOUT ANY WARRANTY; without even the implied warranty of
10472
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
10473
# GNU General Public License for more details.
10474 909:cbb26bc654de Chris
#
10475 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
10476
# along with this program; if not, write to the Free Software
10477
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
10478
10479
class TimeEntryCustomField < CustomField
10480
  def type_name
10481
    :label_spent_time
10482
  end
10483
end
10484
10485 1464:261b3d9a4903 Chris
# Redmine - project management software
10486 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
10487 1464:261b3d9a4903 Chris
#
10488
# This program is free software; you can redistribute it and/or
10489
# modify it under the terms of the GNU General Public License
10490
# as published by the Free Software Foundation; either version 2
10491
# of the License, or (at your option) any later version.
10492
#
10493
# This program is distributed in the hope that it will be useful,
10494
# but WITHOUT ANY WARRANTY; without even the implied warranty of
10495
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
10496
# GNU General Public License for more details.
10497
#
10498
# You should have received a copy of the GNU General Public License
10499
# along with this program; if not, write to the Free Software
10500
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
10501
10502
class TimeEntryQuery < Query
10503
10504
  self.queried_class = TimeEntry
10505
10506
  self.available_columns = [
10507
    QueryColumn.new(:project, :sortable => "#{Project.table_name}.name", :groupable => true),
10508
    QueryColumn.new(:spent_on, :sortable => ["#{TimeEntry.table_name}.spent_on", "#{TimeEntry.table_name}.created_on"], :default_order => 'desc', :groupable => true),
10509
    QueryColumn.new(:user, :sortable => lambda {User.fields_for_order_statement}, :groupable => true),
10510
    QueryColumn.new(:activity, :sortable => "#{TimeEntryActivity.table_name}.position", :groupable => true),
10511
    QueryColumn.new(:issue, :sortable => "#{Issue.table_name}.id"),
10512
    QueryColumn.new(:comments),
10513
    QueryColumn.new(:hours, :sortable => "#{TimeEntry.table_name}.hours"),
10514
  ]
10515
10516
  def initialize(attributes=nil, *args)
10517
    super attributes
10518
    self.filters ||= {}
10519
    add_filter('spent_on', '*') unless filters.present?
10520
  end
10521
10522
  def initialize_available_filters
10523
    add_available_filter "spent_on", :type => :date_past
10524
10525
    principals = []
10526
    if project
10527
      principals += project.principals.sort
10528
      unless project.leaf?
10529
        subprojects = project.descendants.visible.all
10530
        if subprojects.any?
10531
          add_available_filter "subproject_id",
10532
            :type => :list_subprojects,
10533
            :values => subprojects.collect{|s| [s.name, s.id.to_s] }
10534
          principals += Principal.member_of(subprojects)
10535
        end
10536
      end
10537
    else
10538
      if all_projects.any?
10539
        # members of visible projects
10540
        principals += Principal.member_of(all_projects)
10541
        # project filter
10542
        project_values = []
10543
        if User.current.logged? && User.current.memberships.any?
10544
          project_values << ["<< #{l(:label_my_projects).downcase} >>", "mine"]
10545
        end
10546
        project_values += all_projects_values
10547
        add_available_filter("project_id",
10548
          :type => :list, :values => project_values
10549
        ) unless project_values.empty?
10550
      end
10551
    end
10552
    principals.uniq!
10553
    principals.sort!
10554
    users = principals.select {|p| p.is_a?(User)}
10555
10556
    users_values = []
10557
    users_values << ["<< #{l(:label_me)} >>", "me"] if User.current.logged?
10558
    users_values += users.collect{|s| [s.name, s.id.to_s] }
10559
    add_available_filter("user_id",
10560
      :type => :list_optional, :values => users_values
10561
    ) unless users_values.empty?
10562
10563
    activities = (project ? project.activities : TimeEntryActivity.shared.active)
10564
    add_available_filter("activity_id",
10565
      :type => :list, :values => activities.map {|a| [a.name, a.id.to_s]}
10566
    ) unless activities.empty?
10567
10568
    add_available_filter "comments", :type => :text
10569
    add_available_filter "hours", :type => :float
10570
10571
    add_custom_fields_filters(TimeEntryCustomField)
10572
    add_associations_custom_fields_filters :project, :issue, :user
10573
  end
10574
10575
  def available_columns
10576
    return @available_columns if @available_columns
10577
    @available_columns = self.class.available_columns.dup
10578 1517:dffacf8a6908 Chris
    @available_columns += TimeEntryCustomField.visible.
10579
                            map {|cf| QueryCustomFieldColumn.new(cf) }
10580
    @available_columns += IssueCustomField.visible.
10581
                            map {|cf| QueryAssociationCustomFieldColumn.new(:issue, cf) }
10582 1464:261b3d9a4903 Chris
    @available_columns
10583
  end
10584
10585
  def default_columns_names
10586
    @default_columns_names ||= [:project, :spent_on, :user, :activity, :issue, :comments, :hours]
10587
  end
10588
10589
  def results_scope(options={})
10590
    order_option = [group_by_sort_order, options[:order]].flatten.reject(&:blank?)
10591
10592
    TimeEntry.visible.
10593
      where(statement).
10594
      order(order_option).
10595
      joins(joins_for_order_statement(order_option.join(','))).
10596
      includes(:activity)
10597
  end
10598
10599
  def sql_for_activity_id_field(field, operator, value)
10600
    condition_on_id = sql_for_field(field, operator, value, Enumeration.table_name, 'id')
10601
    condition_on_parent_id = sql_for_field(field, operator, value, Enumeration.table_name, 'parent_id')
10602
    ids = value.map(&:to_i).join(',')
10603
    table_name = Enumeration.table_name
10604
    if operator == '='
10605
      "(#{table_name}.id IN (#{ids}) OR #{table_name}.parent_id IN (#{ids}))"
10606
    else
10607
      "(#{table_name}.id NOT IN (#{ids}) AND (#{table_name}.parent_id IS NULL OR #{table_name}.parent_id NOT IN (#{ids})))"
10608
    end
10609
  end
10610
10611
  # Accepts :from/:to params as shortcut filters
10612
  def build_from_params(params)
10613
    super
10614
    if params[:from].present? && params[:to].present?
10615
      add_filter('spent_on', '><', [params[:from], params[:to]])
10616
    elsif params[:from].present?
10617
      add_filter('spent_on', '>=', [params[:from]])
10618
    elsif params[:to].present?
10619
      add_filter('spent_on', '<=', [params[:to]])
10620
    end
10621
    self
10622
  end
10623
end
10624 0:513646585e45 Chris
# Redmine - project management software
10625 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
10626 0:513646585e45 Chris
#
10627
# This program is free software; you can redistribute it and/or
10628
# modify it under the terms of the GNU General Public License
10629
# as published by the Free Software Foundation; either version 2
10630
# of the License, or (at your option) any later version.
10631 909:cbb26bc654de Chris
#
10632 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
10633
# but WITHOUT ANY WARRANTY; without even the implied warranty of
10634
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
10635
# GNU General Public License for more details.
10636 909:cbb26bc654de Chris
#
10637 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
10638
# along with this program; if not, write to the Free Software
10639
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
10640
10641
class Token < ActiveRecord::Base
10642
  belongs_to :user
10643
  validates_uniqueness_of :value
10644 909:cbb26bc654de Chris
10645
  before_create :delete_previous_tokens, :generate_new_token
10646
10647 0:513646585e45 Chris
  @@validity_time = 1.day
10648 909:cbb26bc654de Chris
10649
  def generate_new_token
10650 0:513646585e45 Chris
    self.value = Token.generate_token_value
10651
  end
10652
10653 909:cbb26bc654de Chris
  # Return true if token has expired
10654 0:513646585e45 Chris
  def expired?
10655
    return Time.now > self.created_on + @@validity_time
10656
  end
10657 909:cbb26bc654de Chris
10658 0:513646585e45 Chris
  # Delete all expired tokens
10659
  def self.destroy_expired
10660 1115:433d4f72a19b Chris
    Token.delete_all ["action NOT IN (?) AND created_on < ?", ['feeds', 'api'], Time.now - @@validity_time]
10661 0:513646585e45 Chris
  end
10662 909:cbb26bc654de Chris
10663 1464:261b3d9a4903 Chris
  # Returns the active user who owns the key for the given action
10664
  def self.find_active_user(action, key, validity_days=nil)
10665
    user = find_user(action, key, validity_days)
10666
    if user && user.active?
10667
      user
10668
    end
10669
  end
10670
10671
  # Returns the user who owns the key for the given action
10672
  def self.find_user(action, key, validity_days=nil)
10673
    token = find_token(action, key, validity_days)
10674
    if token
10675
      token.user
10676
    end
10677
  end
10678
10679
  # Returns the token for action and key with an optional
10680
  # validity duration (in number of days)
10681
  def self.find_token(action, key, validity_days=nil)
10682
    action = action.to_s
10683
    key = key.to_s
10684
    return nil unless action.present? && key =~ /\A[a-z0-9]+\z/i
10685
10686
    token = Token.where(:action => action, :value => key).first
10687
    if token && (token.action == action) && (token.value == key) && token.user
10688
      if validity_days.nil? || (token.created_on > validity_days.days.ago)
10689
        token
10690
      end
10691
    end
10692
  end
10693
10694 0:513646585e45 Chris
  def self.generate_token_value
10695 1115:433d4f72a19b Chris
    Redmine::Utils.random_hex(20)
10696 0:513646585e45 Chris
  end
10697 909:cbb26bc654de Chris
10698 1464:261b3d9a4903 Chris
  private
10699
10700 0:513646585e45 Chris
  # Removes obsolete tokens (same user and action)
10701
  def delete_previous_tokens
10702
    if user
10703
      Token.delete_all(['user_id = ? AND action = ?', user.id, action])
10704
    end
10705
  end
10706
end
10707 909:cbb26bc654de Chris
# Redmine - project management software
10708 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
10709 0:513646585e45 Chris
#
10710
# This program is free software; you can redistribute it and/or
10711
# modify it under the terms of the GNU General Public License
10712
# as published by the Free Software Foundation; either version 2
10713
# of the License, or (at your option) any later version.
10714 909:cbb26bc654de Chris
#
10715 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
10716
# but WITHOUT ANY WARRANTY; without even the implied warranty of
10717
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
10718
# GNU General Public License for more details.
10719 909:cbb26bc654de Chris
#
10720 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
10721
# along with this program; if not, write to the Free Software
10722
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
10723
10724
class Tracker < ActiveRecord::Base
10725 1115:433d4f72a19b Chris
10726
  CORE_FIELDS_UNDISABLABLE = %w(project_id tracker_id subject description priority_id is_private).freeze
10727
  # Fields that can be disabled
10728
  # Other (future) fields should be appended, not inserted!
10729
  CORE_FIELDS = %w(assigned_to_id category_id fixed_version_id parent_issue_id start_date due_date estimated_hours done_ratio).freeze
10730
  CORE_FIELDS_ALL = (CORE_FIELDS_UNDISABLABLE + CORE_FIELDS).freeze
10731
10732 909:cbb26bc654de Chris
  before_destroy :check_integrity
10733 0:513646585e45 Chris
  has_many :issues
10734 1115:433d4f72a19b Chris
  has_many :workflow_rules, :dependent => :delete_all do
10735 0:513646585e45 Chris
    def copy(source_tracker)
10736 1115:433d4f72a19b Chris
      WorkflowRule.copy(source_tracker, nil, proxy_association.owner, nil)
10737 0:513646585e45 Chris
    end
10738
  end
10739 909:cbb26bc654de Chris
10740 0:513646585e45 Chris
  has_and_belongs_to_many :projects
10741
  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'
10742
  acts_as_list
10743
10744 1464:261b3d9a4903 Chris
  attr_protected :fields_bits
10745 1115:433d4f72a19b Chris
10746 0:513646585e45 Chris
  validates_presence_of :name
10747
  validates_uniqueness_of :name
10748
  validates_length_of :name, :maximum => 30
10749
10750 1464:261b3d9a4903 Chris
  scope :sorted, lambda { order("#{table_name}.position ASC") }
10751 1115:433d4f72a19b Chris
  scope :named, lambda {|arg| where("LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip)}
10752 909:cbb26bc654de Chris
10753 0:513646585e45 Chris
  def to_s; name end
10754 909:cbb26bc654de Chris
10755 0:513646585e45 Chris
  def <=>(tracker)
10756 1115:433d4f72a19b Chris
    position <=> tracker.position
10757 0:513646585e45 Chris
  end
10758 909:cbb26bc654de Chris
10759 0:513646585e45 Chris
  # Returns an array of IssueStatus that are used
10760
  # in the tracker's workflows
10761
  def issue_statuses
10762
    if @issue_statuses
10763 909:cbb26bc654de Chris
      return @issue_statuses
10764 0:513646585e45 Chris
    elsif new_record?
10765
      return []
10766
    end
10767 909:cbb26bc654de Chris
10768 1115:433d4f72a19b Chris
    ids = WorkflowTransition.
10769
            connection.select_rows("SELECT DISTINCT old_status_id, new_status_id FROM #{WorkflowTransition.table_name} WHERE tracker_id = #{id} AND type = 'WorkflowTransition'").
10770 0:513646585e45 Chris
            flatten.
10771
            uniq
10772 1517:dffacf8a6908 Chris
    @issue_statuses = IssueStatus.where(:id => ids).all.sort
10773 0:513646585e45 Chris
  end
10774 909:cbb26bc654de Chris
10775 1115:433d4f72a19b Chris
  def disabled_core_fields
10776
    i = -1
10777
    @disabled_core_fields ||= CORE_FIELDS.select { i += 1; (fields_bits || 0) & (2 ** i) != 0}
10778
  end
10779
10780
  def core_fields
10781
    CORE_FIELDS - disabled_core_fields
10782
  end
10783
10784
  def core_fields=(fields)
10785
    raise ArgumentError.new("Tracker.core_fields takes an array") unless fields.is_a?(Array)
10786
10787
    bits = 0
10788
    CORE_FIELDS.each_with_index do |field, i|
10789
      unless fields.include?(field)
10790
        bits |= 2 ** i
10791
      end
10792
    end
10793
    self.fields_bits = bits
10794
    @disabled_core_fields = nil
10795
    core_fields
10796
  end
10797
10798
  # Returns the fields that are disabled for all the given trackers
10799
  def self.disabled_core_fields(trackers)
10800
    if trackers.present?
10801
      trackers.uniq.map(&:disabled_core_fields).reduce(:&)
10802
    else
10803
      []
10804
    end
10805
  end
10806
10807
  # Returns the fields that are enabled for one tracker at least
10808
  def self.core_fields(trackers)
10809
    if trackers.present?
10810
      trackers.uniq.map(&:core_fields).reduce(:|)
10811
    else
10812
      CORE_FIELDS.dup
10813
    end
10814
  end
10815
10816 0:513646585e45 Chris
private
10817
  def check_integrity
10818 1115:433d4f72a19b Chris
    raise Exception.new("Can't delete tracker") if Issue.where(:tracker_id => self.id).any?
10819 0:513646585e45 Chris
  end
10820
end
10821
# Redmine - project management software
10822 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
10823 0:513646585e45 Chris
#
10824
# This program is free software; you can redistribute it and/or
10825
# modify it under the terms of the GNU General Public License
10826
# as published by the Free Software Foundation; either version 2
10827
# of the License, or (at your option) any later version.
10828 909:cbb26bc654de Chris
#
10829 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
10830
# but WITHOUT ANY WARRANTY; without even the implied warranty of
10831
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
10832
# GNU General Public License for more details.
10833 909:cbb26bc654de Chris
#
10834 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
10835
# along with this program; if not, write to the Free Software
10836
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
10837
10838
require "digest/sha1"
10839
10840
class User < Principal
10841 119:8661b858af72 Chris
  include Redmine::SafeAttributes
10842 909:cbb26bc654de Chris
10843
  # Different ways of displaying/sorting users
10844 0:513646585e45 Chris
  USER_FORMATS = {
10845 1115:433d4f72a19b Chris
    :firstname_lastname => {
10846
        :string => '#{firstname} #{lastname}',
10847
        :order => %w(firstname lastname id),
10848
        :setting_order => 1
10849
      },
10850
    :firstname_lastinitial => {
10851
        :string => '#{firstname} #{lastname.to_s.chars.first}.',
10852
        :order => %w(firstname lastname id),
10853
        :setting_order => 2
10854
      },
10855 1517:dffacf8a6908 Chris
    :firstinitial_lastname => {
10856
        :string => '#{firstname.to_s.gsub(/(([[:alpha:]])[[:alpha:]]*\.?)/, \'\2.\')} #{lastname}',
10857
        :order => %w(firstname lastname id),
10858
        :setting_order => 2
10859
      },
10860 1115:433d4f72a19b Chris
    :firstname => {
10861
        :string => '#{firstname}',
10862
        :order => %w(firstname id),
10863
        :setting_order => 3
10864
      },
10865
    :lastname_firstname => {
10866
        :string => '#{lastname} #{firstname}',
10867
        :order => %w(lastname firstname id),
10868
        :setting_order => 4
10869
      },
10870
    :lastname_coma_firstname => {
10871
        :string => '#{lastname}, #{firstname}',
10872
        :order => %w(lastname firstname id),
10873
        :setting_order => 5
10874
      },
10875
    :lastname => {
10876
        :string => '#{lastname}',
10877
        :order => %w(lastname id),
10878
        :setting_order => 6
10879
      },
10880
    :username => {
10881
        :string => '#{login}',
10882
        :order => %w(login id),
10883
        :setting_order => 7
10884
      },
10885 0:513646585e45 Chris
  }
10886
10887 37:94944d00e43c chris
  MAIL_NOTIFICATION_OPTIONS = [
10888 119:8661b858af72 Chris
    ['all', :label_user_mail_option_all],
10889
    ['selected', :label_user_mail_option_selected],
10890
    ['only_my_events', :label_user_mail_option_only_my_events],
10891
    ['only_assigned', :label_user_mail_option_only_assigned],
10892
    ['only_owner', :label_user_mail_option_only_owner],
10893
    ['none', :label_user_mail_option_none]
10894
  ]
10895 37:94944d00e43c chris
10896 1517:dffacf8a6908 Chris
  has_and_belongs_to_many :groups,
10897
                          :join_table   => "#{table_name_prefix}groups_users#{table_name_suffix}",
10898
                          :after_add    => Proc.new {|user, group| group.user_added(user)},
10899
                          :after_remove => Proc.new {|user, group| group.user_removed(user)}
10900 0:513646585e45 Chris
  has_many :changesets, :dependent => :nullify
10901
  has_one :preference, :dependent => :destroy, :class_name => 'UserPreference'
10902 128:07fa8a8b56a8 Chris
  has_one :rss_token, :class_name => 'Token', :conditions => "action='feeds'"
10903
  has_one :api_token, :class_name => 'Token', :conditions => "action='api'"
10904 0:513646585e45 Chris
  belongs_to :auth_source
10905 909:cbb26bc654de Chris
10906 1464:261b3d9a4903 Chris
  scope :logged, lambda { where("#{User.table_name}.status <> #{STATUS_ANONYMOUS}") }
10907
  scope :status, lambda {|arg| where(arg.blank? ? nil : {:status => arg.to_i}) }
10908 909:cbb26bc654de Chris
10909 55:bbb139d5ca95 luisf
  has_one :ssamr_user_detail, :dependent => :destroy, :class_name => 'SsamrUserDetail'
10910 65:69ee2e406f71 luisf
  accepts_nested_attributes_for :ssamr_user_detail
10911 403:b15397a5341c luis
10912
  has_one :author
10913 64:9d42bcda8cea luisf
10914 0:513646585e45 Chris
  acts_as_customizable
10915 909:cbb26bc654de Chris
10916 1464:261b3d9a4903 Chris
  attr_accessor :password, :password_confirmation, :generate_password
10917 0:513646585e45 Chris
  attr_accessor :last_before_login_on
10918
  # Prevents unauthorized assignments
10919 119:8661b858af72 Chris
  attr_protected :login, :admin, :password, :password_confirmation, :hashed_password
10920 1115:433d4f72a19b Chris
10921
  LOGIN_LENGTH_LIMIT = 60
10922
  MAIL_LENGTH_LIMIT = 60
10923
10924 0:513646585e45 Chris
  validates_presence_of :login, :firstname, :lastname, :mail, :if => Proc.new { |user| !user.is_a?(AnonymousUser) }
10925 1115:433d4f72a19b Chris
  validates_uniqueness_of :login, :if => Proc.new { |user| user.login_changed? && user.login.present? }, :case_sensitive => false
10926
  validates_uniqueness_of :mail, :if => Proc.new { |user| user.mail_changed? && user.mail.present? }, :case_sensitive => false
10927 1484:51364c0cd58f Chris
10928 1464:261b3d9a4903 Chris
  # Login must contain letters, numbers, underscores only
10929
  validates_format_of :login, :with => /\A[a-z0-9_\-@\.]*\z/i
10930 1115:433d4f72a19b Chris
  validates_length_of :login, :maximum => LOGIN_LENGTH_LIMIT
10931 0:513646585e45 Chris
  validates_length_of :firstname, :lastname, :maximum => 30
10932 1464:261b3d9a4903 Chris
  validates_format_of :mail, :with => /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\z/i, :allow_blank => true
10933 1115:433d4f72a19b Chris
  validates_length_of :mail, :maximum => MAIL_LENGTH_LIMIT, :allow_nil => true
10934 0:513646585e45 Chris
  validates_confirmation_of :password, :allow_nil => true
10935 119:8661b858af72 Chris
  validates_inclusion_of :mail_notification, :in => MAIL_NOTIFICATION_OPTIONS.collect(&:first), :allow_blank => true
10936 909:cbb26bc654de Chris
  validate :validate_password_length
10937 0:513646585e45 Chris
10938 909:cbb26bc654de Chris
  before_create :set_mail_notification
10939 1464:261b3d9a4903 Chris
  before_save   :generate_password_if_needed, :update_hashed_password
10940 128:07fa8a8b56a8 Chris
  before_destroy :remove_references_before_destroy
10941 1464:261b3d9a4903 Chris
  after_save :update_notified_project_ids
10942 909:cbb26bc654de Chris
10943 190:440c4f4bf2d6 luisf
  validates_acceptance_of :terms_and_conditions, :on => :create, :message => :must_accept_terms_and_conditions
10944
10945 1115:433d4f72a19b Chris
  scope :in_group, lambda {|group|
10946 441:cbce1fd3b1b7 Chris
    group_id = group.is_a?(Group) ? group.id : group.to_i
10947 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)
10948 441:cbce1fd3b1b7 Chris
  }
10949 1115:433d4f72a19b Chris
  scope :not_in_group, lambda {|group|
10950 441:cbce1fd3b1b7 Chris
    group_id = group.is_a?(Group) ? group.id : group.to_i
10951 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)
10952 441:cbce1fd3b1b7 Chris
  }
10953 1464:261b3d9a4903 Chris
  scope :sorted, lambda { order(*User.fields_for_order_statement)}
10954 909:cbb26bc654de Chris
10955
  def set_mail_notification
10956 37:94944d00e43c chris
    self.mail_notification = Setting.default_notification_option if self.mail_notification.blank?
10957 0:513646585e45 Chris
    true
10958
  end
10959 909:cbb26bc654de Chris
10960
  def update_hashed_password
10961 0:513646585e45 Chris
    # update hashed_password if password was set
10962 245:051f544170fe Chris
    if self.password && self.auth_source_id.blank?
10963
      salt_password(password)
10964
    end
10965 0:513646585e45 Chris
  end
10966 909:cbb26bc654de Chris
10967 1464:261b3d9a4903 Chris
  alias :base_reload :reload
10968 0:513646585e45 Chris
  def reload(*args)
10969
    @name = nil
10970 441:cbce1fd3b1b7 Chris
    @projects_by_role = nil
10971 1464:261b3d9a4903 Chris
    @membership_by_project_id = nil
10972
    @notified_projects_ids = nil
10973
    @notified_projects_ids_changed = false
10974
    @builtin_role = nil
10975
    base_reload(*args)
10976 0:513646585e45 Chris
  end
10977 909:cbb26bc654de Chris
10978 1:cca12e1c1fd4 Chris
  def mail=(arg)
10979
    write_attribute(:mail, arg.to_s.strip)
10980
  end
10981 909:cbb26bc654de Chris
10982 59:7ff14a13f48a luisf
  def description=(arg)
10983
    write_attribute(:description, arg.to_s.strip)
10984
  end
10985
10986 0:513646585e45 Chris
  def identity_url=(url)
10987
    if url.blank?
10988
      write_attribute(:identity_url, '')
10989
    else
10990
      begin
10991
        write_attribute(:identity_url, OpenIdAuthentication.normalize_identifier(url))
10992
      rescue OpenIdAuthentication::InvalidOpenId
10993 1464:261b3d9a4903 Chris
        # Invalid url, don't save
10994 0:513646585e45 Chris
      end
10995
    end
10996
    self.read_attribute(:identity_url)
10997
  end
10998 909:cbb26bc654de Chris
10999 0:513646585e45 Chris
  # Returns the user that matches provided login and password, or nil
11000 1464:261b3d9a4903 Chris
  def self.try_to_login(login, password, active_only=true)
11001 1115:433d4f72a19b Chris
    login = login.to_s
11002
    password = password.to_s
11003
11004 1464:261b3d9a4903 Chris
    # Make sure no one can sign in with an empty login or password
11005
    return nil if login.empty? || password.empty?
11006 0:513646585e45 Chris
    user = find_by_login(login)
11007
    if user
11008
      # user is already in local database
11009 1464:261b3d9a4903 Chris
      return nil unless user.check_password?(password)
11010
      return nil if !user.active? && active_only
11011 0:513646585e45 Chris
    else
11012
      # user is not yet registered, try to authenticate with available sources
11013
      attrs = AuthSource.authenticate(login, password)
11014
      if attrs
11015
        user = new(attrs)
11016
        user.login = login
11017
        user.language = Setting.default_language
11018
        if user.save
11019
          user.reload
11020
          logger.info("User '#{user.login}' created from external auth source: #{user.auth_source.type} - #{user.auth_source.name}") if logger && user.auth_source
11021
        end
11022
      end
11023 909:cbb26bc654de Chris
    end
11024 1464:261b3d9a4903 Chris
    user.update_column(:last_login_on, Time.now) if user && !user.new_record? && user.active?
11025 0:513646585e45 Chris
    user
11026
  rescue => text
11027
    raise text
11028
  end
11029 909:cbb26bc654de Chris
11030 0:513646585e45 Chris
  # Returns the user who matches the given autologin +key+ or nil
11031
  def self.try_to_autologin(key)
11032 1464:261b3d9a4903 Chris
    user = Token.find_active_user('autologin', key, Setting.autologin.to_i)
11033
    if user
11034
      user.update_column(:last_login_on, Time.now)
11035
      user
11036 0:513646585e45 Chris
    end
11037
  end
11038 909:cbb26bc654de Chris
11039
  def self.name_formatter(formatter = nil)
11040
    USER_FORMATS[formatter || Setting.user_format] || USER_FORMATS[:firstname_lastname]
11041
  end
11042
11043
  # Returns an array of fields names than can be used to make an order statement for users
11044
  # according to how user names are displayed
11045
  # Examples:
11046
  #
11047
  #   User.fields_for_order_statement              => ['users.login', 'users.id']
11048
  #   User.fields_for_order_statement('authors')   => ['authors.login', 'authors.id']
11049
  def self.fields_for_order_statement(table=nil)
11050
    table ||= table_name
11051
    name_formatter[:order].map {|field| "#{table}.#{field}"}
11052
  end
11053
11054 0:513646585e45 Chris
  # Return user's full name for display
11055
  def name(formatter = nil)
11056 909:cbb26bc654de Chris
    f = self.class.name_formatter(formatter)
11057 0:513646585e45 Chris
    if formatter
11058 909:cbb26bc654de Chris
      eval('"' + f[:string] + '"')
11059 0:513646585e45 Chris
    else
11060 909:cbb26bc654de Chris
      @name ||= eval('"' + f[:string] + '"')
11061 0:513646585e45 Chris
    end
11062
  end
11063 909:cbb26bc654de Chris
11064 0:513646585e45 Chris
  def active?
11065
    self.status == STATUS_ACTIVE
11066
  end
11067
11068
  def registered?
11069
    self.status == STATUS_REGISTERED
11070
  end
11071 909:cbb26bc654de Chris
11072 0:513646585e45 Chris
  def locked?
11073
    self.status == STATUS_LOCKED
11074
  end
11075
11076 14:1d32c0a0efbf Chris
  def activate
11077
    self.status = STATUS_ACTIVE
11078
  end
11079
11080
  def register
11081
    self.status = STATUS_REGISTERED
11082
  end
11083
11084
  def lock
11085
    self.status = STATUS_LOCKED
11086
  end
11087
11088
  def activate!
11089
    update_attribute(:status, STATUS_ACTIVE)
11090
  end
11091
11092
  def register!
11093
    update_attribute(:status, STATUS_REGISTERED)
11094
  end
11095
11096
  def lock!
11097
    update_attribute(:status, STATUS_LOCKED)
11098
  end
11099
11100 245:051f544170fe Chris
  # Returns true if +clear_password+ is the correct user's password, otherwise false
11101 0:513646585e45 Chris
  def check_password?(clear_password)
11102
    if auth_source_id.present?
11103
      auth_source.authenticate(self.login, clear_password)
11104
    else
11105 245:051f544170fe Chris
      User.hash_password("#{salt}#{User.hash_password clear_password}") == hashed_password
11106 0:513646585e45 Chris
    end
11107
  end
11108 909:cbb26bc654de Chris
11109 245:051f544170fe Chris
  # Generates a random salt and computes hashed_password for +clear_password+
11110
  # The hashed password is stored in the following form: SHA1(salt + SHA1(password))
11111
  def salt_password(clear_password)
11112
    self.salt = User.generate_salt
11113
    self.hashed_password = User.hash_password("#{salt}#{User.hash_password clear_password}")
11114
  end
11115 0:513646585e45 Chris
11116
  # Does the backend storage allow this user to change their password?
11117
  def change_password_allowed?
11118 1115:433d4f72a19b Chris
    return true if auth_source.nil?
11119 0:513646585e45 Chris
    return auth_source.allow_password_changes?
11120
  end
11121
11122 1464:261b3d9a4903 Chris
  def must_change_password?
11123
    must_change_passwd? && change_password_allowed?
11124
  end
11125
11126
  def generate_password?
11127
    generate_password == '1' || generate_password == true
11128
  end
11129
11130
  # Generate and set a random password on given length
11131
  def random_password(length=40)
11132 0:513646585e45 Chris
    chars = ("a".."z").to_a + ("A".."Z").to_a + ("0".."9").to_a
11133 1464:261b3d9a4903 Chris
    chars -= %w(0 O 1 l)
11134 0:513646585e45 Chris
    password = ''
11135 1464:261b3d9a4903 Chris
    length.times {|i| password << chars[SecureRandom.random_number(chars.size)] }
11136 0:513646585e45 Chris
    self.password = password
11137
    self.password_confirmation = password
11138
    self
11139
  end
11140 909:cbb26bc654de Chris
11141 0:513646585e45 Chris
  def pref
11142
    self.preference ||= UserPreference.new(:user => self)
11143
  end
11144 909:cbb26bc654de Chris
11145 0:513646585e45 Chris
  def time_zone
11146
    @time_zone ||= (self.pref.time_zone.blank? ? nil : ActiveSupport::TimeZone[self.pref.time_zone])
11147
  end
11148 909:cbb26bc654de Chris
11149 1517:dffacf8a6908 Chris
  def force_default_language?
11150
    Setting.force_default_language_for_loggedin?
11151
  end
11152
11153
  def language
11154
    if force_default_language?
11155
      Setting.default_language
11156
    else
11157
      super
11158
    end
11159
  end
11160
11161 0:513646585e45 Chris
  def wants_comments_in_reverse_order?
11162
    self.pref[:comments_sorting] == 'desc'
11163
  end
11164 909:cbb26bc654de Chris
11165 0:513646585e45 Chris
  # Return user's RSS key (a 40 chars long string), used to access feeds
11166
  def rss_key
11167 1115:433d4f72a19b Chris
    if rss_token.nil?
11168
      create_rss_token(:action => 'feeds')
11169
    end
11170
    rss_token.value
11171 0:513646585e45 Chris
  end
11172
11173
  # Return user's API key (a 40 chars long string), used to access the API
11174
  def api_key
11175 1115:433d4f72a19b Chris
    if api_token.nil?
11176
      create_api_token(:action => 'api')
11177
    end
11178
    api_token.value
11179 0:513646585e45 Chris
  end
11180 909:cbb26bc654de Chris
11181 0:513646585e45 Chris
  # Return an array of project ids for which the user has explicitly turned mail notifications on
11182
  def notified_projects_ids
11183
    @notified_projects_ids ||= memberships.select {|m| m.mail_notification?}.collect(&:project_id)
11184
  end
11185 909:cbb26bc654de Chris
11186 0:513646585e45 Chris
  def notified_project_ids=(ids)
11187 1464:261b3d9a4903 Chris
    @notified_projects_ids_changed = true
11188
    @notified_projects_ids = ids
11189 0:513646585e45 Chris
  end
11190
11191 1464:261b3d9a4903 Chris
  # Updates per project notifications (after_save callback)
11192
  def update_notified_project_ids
11193
    if @notified_projects_ids_changed
11194
      ids = (mail_notification == 'selected' ? Array.wrap(notified_projects_ids).reject(&:blank?) : [])
11195
      members.update_all(:mail_notification => false)
11196
      members.where(:project_id => ids).update_all(:mail_notification => true) if ids.any?
11197
    end
11198
  end
11199
  private :update_notified_project_ids
11200
11201 128:07fa8a8b56a8 Chris
  def valid_notification_options
11202
    self.class.valid_notification_options(self)
11203
  end
11204
11205 37:94944d00e43c chris
  # Only users that belong to more than 1 project can select projects for which they are notified
11206 128:07fa8a8b56a8 Chris
  def self.valid_notification_options(user=nil)
11207 37:94944d00e43c chris
    # Note that @user.membership.size would fail since AR ignores
11208
    # :include association option when doing a count
11209 128:07fa8a8b56a8 Chris
    if user.nil? || user.memberships.length < 1
11210
      MAIL_NOTIFICATION_OPTIONS.reject {|option| option.first == 'selected'}
11211 37:94944d00e43c chris
    else
11212
      MAIL_NOTIFICATION_OPTIONS
11213
    end
11214
  end
11215
11216 0:513646585e45 Chris
  # Find a user account by matching the exact login and then a case-insensitive
11217
  # version.  Exact matches will be given priority.
11218
  def self.find_by_login(login)
11219 1517:dffacf8a6908 Chris
    login = Redmine::CodesetUtil.replace_invalid_utf8(login.to_s)
11220 1464:261b3d9a4903 Chris
    if login.present?
11221
      # First look for an exact match
11222 1517:dffacf8a6908 Chris
      user = where(:login => login).detect {|u| u.login == login}
11223 1464:261b3d9a4903 Chris
      unless user
11224
        # Fail over to case-insensitive if none was found
11225
        user = where("LOWER(login) = ?", login.downcase).first
11226
      end
11227
      user
11228 1115:433d4f72a19b Chris
    end
11229 0:513646585e45 Chris
  end
11230
11231
  def self.find_by_rss_key(key)
11232 1464:261b3d9a4903 Chris
    Token.find_active_user('feeds', key)
11233 0:513646585e45 Chris
  end
11234 909:cbb26bc654de Chris
11235 0:513646585e45 Chris
  def self.find_by_api_key(key)
11236 1464:261b3d9a4903 Chris
    Token.find_active_user('api', key)
11237 0:513646585e45 Chris
  end
11238 909:cbb26bc654de Chris
11239 0:513646585e45 Chris
  # Makes find_by_mail case-insensitive
11240
  def self.find_by_mail(mail)
11241 1115:433d4f72a19b Chris
    where("LOWER(mail) = ?", mail.to_s.downcase).first
11242 0:513646585e45 Chris
  end
11243 909:cbb26bc654de Chris
11244 929:5f33065ddc4b Chris
  # Returns true if the default admin account can no longer be used
11245
  def self.default_admin_account_changed?
11246
    !User.active.find_by_login("admin").try(:check_password?, "admin")
11247
  end
11248
11249 0:513646585e45 Chris
  def to_s
11250
    name
11251
  end
11252 909:cbb26bc654de Chris
11253 1115:433d4f72a19b Chris
  CSS_CLASS_BY_STATUS = {
11254
    STATUS_ANONYMOUS  => 'anon',
11255
    STATUS_ACTIVE     => 'active',
11256
    STATUS_REGISTERED => 'registered',
11257
    STATUS_LOCKED     => 'locked'
11258
  }
11259
11260
  def css_classes
11261
    "user #{CSS_CLASS_BY_STATUS[status]}"
11262
  end
11263
11264 0:513646585e45 Chris
  # Returns the current day according to user's time zone
11265
  def today
11266
    if time_zone.nil?
11267
      Date.today
11268
    else
11269
      Time.now.in_time_zone(time_zone).to_date
11270
    end
11271
  end
11272 909:cbb26bc654de Chris
11273 1115:433d4f72a19b Chris
  # Returns the day of +time+ according to user's time zone
11274
  def time_to_date(time)
11275
    if time_zone.nil?
11276
      time.to_date
11277
    else
11278
      time.in_time_zone(time_zone).to_date
11279
    end
11280
  end
11281
11282 0:513646585e45 Chris
  def logged?
11283
    true
11284
  end
11285 909:cbb26bc654de Chris
11286 0:513646585e45 Chris
  def anonymous?
11287
    !logged?
11288
  end
11289 909:cbb26bc654de Chris
11290 1464:261b3d9a4903 Chris
  # Returns user's membership for the given project
11291
  # or nil if the user is not a member of project
11292
  def membership(project)
11293
    project_id = project.is_a?(Project) ? project.id : project
11294
11295
    @membership_by_project_id ||= Hash.new {|h, project_id|
11296
      h[project_id] = memberships.where(:project_id => project_id).first
11297
    }
11298
    @membership_by_project_id[project_id]
11299
  end
11300
11301
  # Returns the user's bult-in role
11302
  def builtin_role
11303
    @builtin_role ||= Role.non_member
11304
  end
11305
11306 0:513646585e45 Chris
  # Return user's roles for project
11307
  def roles_for_project(project)
11308
    roles = []
11309
    # No role on archived projects
11310 1115:433d4f72a19b Chris
    return roles if project.nil? || project.archived?
11311 1464:261b3d9a4903 Chris
    if membership = membership(project)
11312
      roles = membership.roles
11313 0:513646585e45 Chris
    else
11314 1464:261b3d9a4903 Chris
      roles << builtin_role
11315 0:513646585e45 Chris
    end
11316
    roles
11317
  end
11318 909:cbb26bc654de Chris
11319 0:513646585e45 Chris
  # Return true if the user is a member of project
11320
  def member_of?(project)
11321 1464:261b3d9a4903 Chris
    projects.to_a.include?(project)
11322 0:513646585e45 Chris
  end
11323 909:cbb26bc654de Chris
11324 441:cbce1fd3b1b7 Chris
  # Returns a hash of user's projects grouped by roles
11325
  def projects_by_role
11326
    return @projects_by_role if @projects_by_role
11327 909:cbb26bc654de Chris
11328 1115:433d4f72a19b Chris
    @projects_by_role = Hash.new([])
11329 441:cbce1fd3b1b7 Chris
    memberships.each do |membership|
11330 1115:433d4f72a19b Chris
      if membership.project
11331
        membership.roles.each do |role|
11332
          @projects_by_role[role] = [] unless @projects_by_role.key?(role)
11333
          @projects_by_role[role] << membership.project
11334
        end
11335 441:cbce1fd3b1b7 Chris
      end
11336
    end
11337
    @projects_by_role.each do |role, projects|
11338
      projects.uniq!
11339
    end
11340 909:cbb26bc654de Chris
11341 441:cbce1fd3b1b7 Chris
    @projects_by_role
11342
  end
11343 909:cbb26bc654de Chris
11344
  # Returns true if user is arg or belongs to arg
11345
  def is_or_belongs_to?(arg)
11346
    if arg.is_a?(User)
11347
      self == arg
11348
    elsif arg.is_a?(Group)
11349
      arg.users.include?(self)
11350
    else
11351
      false
11352
    end
11353
  end
11354
11355 37:94944d00e43c chris
  # Return true if the user is allowed to do the specified action on a specific context
11356
  # Action can be:
11357 0:513646585e45 Chris
  # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
11358
  # * a permission Symbol (eg. :edit_project)
11359 37:94944d00e43c chris
  # Context can be:
11360
  # * a project : returns true if user is allowed to do the specified action on this project
11361 441:cbce1fd3b1b7 Chris
  # * an array of projects : returns true if user is allowed on every project
11362 909:cbb26bc654de Chris
  # * nil with options[:global] set : check if user has at least one role allowed for this action,
11363 37:94944d00e43c chris
  #   or falls back to Non Member / Anonymous permissions depending if the user is logged
11364 441:cbce1fd3b1b7 Chris
  def allowed_to?(action, context, options={}, &block)
11365 37:94944d00e43c chris
    if context && context.is_a?(Project)
11366
      return false unless context.allows_to?(action)
11367 0:513646585e45 Chris
      # Admin users are authorized for anything else
11368
      return true if admin?
11369 909:cbb26bc654de Chris
11370 37:94944d00e43c chris
      roles = roles_for_project(context)
11371 0:513646585e45 Chris
      return false unless roles
11372 1115:433d4f72a19b Chris
      roles.any? {|role|
11373 441:cbce1fd3b1b7 Chris
        (context.is_public? || role.member?) &&
11374
        role.allowed_to?(action) &&
11375
        (block_given? ? yield(role, self) : true)
11376
      }
11377 37:94944d00e43c chris
    elsif context && context.is_a?(Array)
11378 1115:433d4f72a19b Chris
      if context.empty?
11379
        false
11380
      else
11381
        # Authorize if user is authorized on every element of the array
11382
        context.map {|project| allowed_to?(action, project, options, &block)}.reduce(:&)
11383 37:94944d00e43c chris
      end
11384 0:513646585e45 Chris
    elsif options[:global]
11385
      # Admin users are always authorized
11386
      return true if admin?
11387 909:cbb26bc654de Chris
11388 0:513646585e45 Chris
      # authorize if user has at least one role that has this permission
11389
      roles = memberships.collect {|m| m.roles}.flatten.uniq
11390 441:cbce1fd3b1b7 Chris
      roles << (self.logged? ? Role.non_member : Role.anonymous)
11391 1115:433d4f72a19b Chris
      roles.any? {|role|
11392 441:cbce1fd3b1b7 Chris
        role.allowed_to?(action) &&
11393
        (block_given? ? yield(role, self) : true)
11394
      }
11395 0:513646585e45 Chris
    else
11396
      false
11397
    end
11398
  end
11399 22:40f7cfd4df19 chris
11400
  # Is the user allowed to do the specified action on any project?
11401
  # See allowed_to? for the actions and valid options.
11402 441:cbce1fd3b1b7 Chris
  def allowed_to_globally?(action, options, &block)
11403
    allowed_to?(action, nil, options.reverse_merge(:global => true), &block)
11404 22:40f7cfd4df19 chris
  end
11405 119:8661b858af72 Chris
11406 1464:261b3d9a4903 Chris
  # Returns true if the user is allowed to delete the user's own account
11407 1115:433d4f72a19b Chris
  def own_account_deletable?
11408
    Setting.unsubscribe? &&
11409
      (!admin? || User.active.where("admin = ? AND id <> ?", true, id).exists?)
11410
  end
11411
11412 119:8661b858af72 Chris
  safe_attributes 'login',
11413
    'firstname',
11414
    'lastname',
11415
    'mail',
11416
    'mail_notification',
11417 1464:261b3d9a4903 Chris
    'notified_project_ids',
11418 119:8661b858af72 Chris
    'language',
11419
    'custom_field_values',
11420
    'custom_fields',
11421
    'identity_url'
11422 909:cbb26bc654de Chris
11423 119:8661b858af72 Chris
  safe_attributes 'status',
11424
    'auth_source_id',
11425 1464:261b3d9a4903 Chris
    'generate_password',
11426
    'must_change_passwd',
11427 119:8661b858af72 Chris
    :if => lambda {|user, current_user| current_user.admin?}
11428 909:cbb26bc654de Chris
11429 119:8661b858af72 Chris
  safe_attributes 'group_ids',
11430
    :if => lambda {|user, current_user| current_user.admin? && !user.new_record?}
11431 909:cbb26bc654de Chris
11432 37:94944d00e43c chris
  # Utility method to help check if a user should be notified about an
11433
  # event.
11434
  #
11435
  # TODO: only supports Issue events currently
11436
  def notify_about?(object)
11437 1464:261b3d9a4903 Chris
    if mail_notification == 'all'
11438 37:94944d00e43c chris
      true
11439 1464:261b3d9a4903 Chris
    elsif mail_notification.blank? || mail_notification == 'none'
11440
      false
11441
    else
11442
      case object
11443
      when Issue
11444
        case mail_notification
11445
        when 'selected', 'only_my_events'
11446
          # user receives notifications for created/assigned issues on unselected projects
11447
          object.author == self || is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.assigned_to_was)
11448
        when 'only_assigned'
11449
          is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.assigned_to_was)
11450
        when 'only_owner'
11451
          object.author == self
11452
        end
11453
      when News
11454
        # always send to project members except when mail_notification is set to 'none'
11455 210:0579821a129a Chris
        true
11456
      end
11457 37:94944d00e43c chris
    end
11458
  end
11459 909:cbb26bc654de Chris
11460 0:513646585e45 Chris
  def self.current=(user)
11461 1464:261b3d9a4903 Chris
    Thread.current[:current_user] = user
11462 0:513646585e45 Chris
  end
11463 909:cbb26bc654de Chris
11464 0:513646585e45 Chris
  def self.current
11465 1464:261b3d9a4903 Chris
    Thread.current[:current_user] ||= User.anonymous
11466 0:513646585e45 Chris
  end
11467 909:cbb26bc654de Chris
11468 0:513646585e45 Chris
  # Returns the anonymous user.  If the anonymous user does not exist, it is created.  There can be only
11469
  # one anonymous user per database.
11470
  def self.anonymous
11471 1115:433d4f72a19b Chris
    anonymous_user = AnonymousUser.first
11472 0:513646585e45 Chris
    if anonymous_user.nil?
11473
      anonymous_user = AnonymousUser.create(:lastname => 'Anonymous', :firstname => '', :mail => '', :login => '', :status => 0)
11474
      raise 'Unable to create the anonymous user.' if anonymous_user.new_record?
11475
    end
11476
    anonymous_user
11477
  end
11478 245:051f544170fe Chris
11479
  # Salts all existing unsalted passwords
11480
  # It changes password storage scheme from SHA1(password) to SHA1(salt + SHA1(password))
11481
  # This method is used in the SaltPasswords migration and is to be kept as is
11482
  def self.salt_unsalted_passwords!
11483
    transaction do
11484 1115:433d4f72a19b Chris
      User.where("salt IS NULL OR salt = ''").find_each do |user|
11485 245:051f544170fe Chris
        next if user.hashed_password.blank?
11486
        salt = User.generate_salt
11487
        hashed_password = User.hash_password("#{salt}#{user.hashed_password}")
11488 1115:433d4f72a19b Chris
        User.where(:id => user.id).update_all(:salt => salt, :hashed_password => hashed_password)
11489 245:051f544170fe Chris
      end
11490
    end
11491
  end
11492 909:cbb26bc654de Chris
11493 0:513646585e45 Chris
  protected
11494 909:cbb26bc654de Chris
11495
  def validate_password_length
11496 1464:261b3d9a4903 Chris
    return if password.blank? && generate_password?
11497 0:513646585e45 Chris
    # Password length validation based on setting
11498
    if !password.nil? && password.size < Setting.password_min_length.to_i
11499
      errors.add(:password, :too_short, :count => Setting.password_min_length.to_i)
11500
    end
11501
  end
11502 909:cbb26bc654de Chris
11503 0:513646585e45 Chris
  private
11504 909:cbb26bc654de Chris
11505 1464:261b3d9a4903 Chris
  def generate_password_if_needed
11506
    if generate_password? && auth_source.nil?
11507
      length = [Setting.password_min_length.to_i + 2, 10].max
11508
      random_password(length)
11509
    end
11510
  end
11511
11512 128:07fa8a8b56a8 Chris
  # Removes references that are not handled by associations
11513
  # Things that are not deleted are reassociated with the anonymous user
11514
  def remove_references_before_destroy
11515
    return if self.id.nil?
11516 909:cbb26bc654de Chris
11517 128:07fa8a8b56a8 Chris
    substitute = User.anonymous
11518 1517:dffacf8a6908 Chris
    Attachment.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
11519
    Comment.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
11520
    Issue.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
11521
    Issue.where(['assigned_to_id = ?', id]).update_all('assigned_to_id = NULL')
11522
    Journal.where(['user_id = ?', id]).update_all(['user_id = ?', substitute.id])
11523
    JournalDetail.
11524
      where(["property = 'attr' AND prop_key = 'assigned_to_id' AND old_value = ?", id.to_s]).
11525
      update_all(['old_value = ?', substitute.id.to_s])
11526
    JournalDetail.
11527
      where(["property = 'attr' AND prop_key = 'assigned_to_id' AND value = ?", id.to_s]).
11528
      update_all(['value = ?', substitute.id.to_s])
11529
    Message.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
11530
    News.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
11531 128:07fa8a8b56a8 Chris
    # Remove private queries and keep public ones
11532 1464:261b3d9a4903 Chris
    ::Query.delete_all ['user_id = ? AND visibility = ?', id, ::Query::VISIBILITY_PRIVATE]
11533 1517:dffacf8a6908 Chris
    ::Query.where(['user_id = ?', id]).update_all(['user_id = ?', substitute.id])
11534
    TimeEntry.where(['user_id = ?', id]).update_all(['user_id = ?', substitute.id])
11535 128:07fa8a8b56a8 Chris
    Token.delete_all ['user_id = ?', id]
11536
    Watcher.delete_all ['user_id = ?', id]
11537 1517:dffacf8a6908 Chris
    WikiContent.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
11538
    WikiContent::Version.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
11539 128:07fa8a8b56a8 Chris
  end
11540 909:cbb26bc654de Chris
11541 0:513646585e45 Chris
  # Return password digest
11542
  def self.hash_password(clear_password)
11543
    Digest::SHA1.hexdigest(clear_password || "")
11544
  end
11545 909:cbb26bc654de Chris
11546 245:051f544170fe Chris
  # Returns a 128bits random salt as a hex string (32 chars long)
11547
  def self.generate_salt
11548 1115:433d4f72a19b Chris
    Redmine::Utils.random_hex(16)
11549 245:051f544170fe Chris
  end
11550 909:cbb26bc654de Chris
11551 0:513646585e45 Chris
end
11552
11553
class AnonymousUser < User
11554 1115:433d4f72a19b Chris
  validate :validate_anonymous_uniqueness, :on => :create
11555 909:cbb26bc654de Chris
11556 1115:433d4f72a19b Chris
  def validate_anonymous_uniqueness
11557 0:513646585e45 Chris
    # There should be only one AnonymousUser in the database
11558 1464:261b3d9a4903 Chris
    errors.add :base, 'An anonymous user already exists.' if AnonymousUser.exists?
11559 0:513646585e45 Chris
  end
11560 909:cbb26bc654de Chris
11561 0:513646585e45 Chris
  def available_custom_fields
11562
    []
11563
  end
11564 909:cbb26bc654de Chris
11565 0:513646585e45 Chris
  # Overrides a few properties
11566
  def logged?; false end
11567
  def admin; false end
11568
  def name(*args); I18n.t(:label_user_anonymous) end
11569
  def mail; nil end
11570
  def time_zone; nil end
11571
  def rss_key; nil end
11572 909:cbb26bc654de Chris
11573 1115:433d4f72a19b Chris
  def pref
11574
    UserPreference.new(:user => self)
11575
  end
11576
11577 1464:261b3d9a4903 Chris
  # Returns the user's bult-in role
11578
  def builtin_role
11579
    @builtin_role ||= Role.anonymous
11580
  end
11581
11582
  def membership(*args)
11583
    nil
11584
  end
11585
11586
  def member_of?(*args)
11587
    false
11588
  end
11589
11590 128:07fa8a8b56a8 Chris
  # Anonymous user can not be destroyed
11591
  def destroy
11592
    false
11593
  end
11594 0:513646585e45 Chris
end
11595 909:cbb26bc654de Chris
# Redmine - project management software
11596 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
11597 0:513646585e45 Chris
#
11598
# This program is free software; you can redistribute it and/or
11599
# modify it under the terms of the GNU General Public License
11600
# as published by the Free Software Foundation; either version 2
11601
# of the License, or (at your option) any later version.
11602 909:cbb26bc654de Chris
#
11603 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
11604
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11605
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11606
# GNU General Public License for more details.
11607 909:cbb26bc654de Chris
#
11608 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
11609
# along with this program; if not, write to the Free Software
11610
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
11611
11612
class UserCustomField < CustomField
11613
  def type_name
11614
    :label_user_plural
11615
  end
11616
end
11617
11618 909:cbb26bc654de Chris
# Redmine - project management software
11619 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
11620 0:513646585e45 Chris
#
11621
# This program is free software; you can redistribute it and/or
11622
# modify it under the terms of the GNU General Public License
11623
# as published by the Free Software Foundation; either version 2
11624
# of the License, or (at your option) any later version.
11625 909:cbb26bc654de Chris
#
11626 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
11627
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11628
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11629
# GNU General Public License for more details.
11630 909:cbb26bc654de Chris
#
11631 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
11632
# along with this program; if not, write to the Free Software
11633
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
11634
11635
class UserPreference < ActiveRecord::Base
11636
  belongs_to :user
11637
  serialize :others
11638 909:cbb26bc654de Chris
11639 929:5f33065ddc4b Chris
  attr_protected :others, :user_id
11640 909:cbb26bc654de Chris
11641 1115:433d4f72a19b Chris
  before_save :set_others_hash
11642 1464:261b3d9a4903 Chris
11643 1115:433d4f72a19b Chris
  def initialize(attributes=nil, *args)
11644 0:513646585e45 Chris
    super
11645
    self.others ||= {}
11646
  end
11647 909:cbb26bc654de Chris
11648 1115:433d4f72a19b Chris
  def set_others_hash
11649 0:513646585e45 Chris
    self.others ||= {}
11650
  end
11651 909:cbb26bc654de Chris
11652 0:513646585e45 Chris
  def [](attr_name)
11653 1464:261b3d9a4903 Chris
    if has_attribute? attr_name
11654 0:513646585e45 Chris
      super
11655
    else
11656
      others ? others[attr_name] : nil
11657
    end
11658
  end
11659 909:cbb26bc654de Chris
11660 0:513646585e45 Chris
  def []=(attr_name, value)
11661 1464:261b3d9a4903 Chris
    if has_attribute? attr_name
11662 0:513646585e45 Chris
      super
11663
    else
11664 1115:433d4f72a19b Chris
      h = (read_attribute(:others) || {}).dup
11665 0:513646585e45 Chris
      h.update(attr_name => value)
11666
      write_attribute(:others, h)
11667
      value
11668
    end
11669
  end
11670 909:cbb26bc654de Chris
11671 0:513646585e45 Chris
  def comments_sorting; self[:comments_sorting] end
11672
  def comments_sorting=(order); self[:comments_sorting]=order end
11673 909:cbb26bc654de Chris
11674 245:051f544170fe Chris
  def warn_on_leaving_unsaved; self[:warn_on_leaving_unsaved] || '1'; end
11675
  def warn_on_leaving_unsaved=(value); self[:warn_on_leaving_unsaved]=value; end
11676 1464:261b3d9a4903 Chris
11677
  def no_self_notified; (self[:no_self_notified] == true || self[:no_self_notified] == '1'); end
11678
  def no_self_notified=(value); self[:no_self_notified]=value; end
11679 0:513646585e45 Chris
end
11680
# Redmine - project management software
11681 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
11682 0:513646585e45 Chris
#
11683
# This program is free software; you can redistribute it and/or
11684
# modify it under the terms of the GNU General Public License
11685
# as published by the Free Software Foundation; either version 2
11686
# of the License, or (at your option) any later version.
11687 909:cbb26bc654de Chris
#
11688 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
11689
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11690
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11691
# GNU General Public License for more details.
11692 909:cbb26bc654de Chris
#
11693 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
11694
# along with this program; if not, write to the Free Software
11695
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
11696
11697
class Version < ActiveRecord::Base
11698 929:5f33065ddc4b Chris
  include Redmine::SafeAttributes
11699 0:513646585e45 Chris
  after_update :update_issues_from_sharing_change
11700
  belongs_to :project
11701
  has_many :fixed_issues, :class_name => 'Issue', :foreign_key => 'fixed_version_id', :dependent => :nullify
11702
  acts_as_customizable
11703
  acts_as_attachable :view_permission => :view_files,
11704
                     :delete_permission => :manage_files
11705
11706
  VERSION_STATUSES = %w(open locked closed)
11707
  VERSION_SHARINGS = %w(none descendants hierarchy tree system)
11708 909:cbb26bc654de Chris
11709 0:513646585e45 Chris
  validates_presence_of :name
11710
  validates_uniqueness_of :name, :scope => [:project_id]
11711
  validates_length_of :name, :maximum => 60
11712 1464:261b3d9a4903 Chris
  validates :effective_date, :date => true
11713 0:513646585e45 Chris
  validates_inclusion_of :status, :in => VERSION_STATUSES
11714
  validates_inclusion_of :sharing, :in => VERSION_SHARINGS
11715
11716 1115:433d4f72a19b Chris
  scope :named, lambda {|arg| where("LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip)}
11717 1464:261b3d9a4903 Chris
  scope :open, lambda { where(:status => 'open') }
11718 1115:433d4f72a19b Chris
  scope :visible, lambda {|*args|
11719
    includes(:project).where(Project.allowed_to_condition(args.first || User.current, :view_issues))
11720
  }
11721 0:513646585e45 Chris
11722 1464:261b3d9a4903 Chris
  safe_attributes 'name',
11723 929:5f33065ddc4b Chris
    'description',
11724
    'effective_date',
11725
    'due_date',
11726
    'wiki_page_title',
11727
    'status',
11728
    'sharing',
11729 1464:261b3d9a4903 Chris
    'custom_field_values',
11730
    'custom_fields'
11731 929:5f33065ddc4b Chris
11732 0:513646585e45 Chris
  # Returns true if +user+ or current user is allowed to view the version
11733
  def visible?(user=User.current)
11734
    user.allowed_to?(:view_issues, self.project)
11735
  end
11736 909:cbb26bc654de Chris
11737
  # Version files have same visibility as project files
11738
  def attachments_visible?(*args)
11739
    project.present? && project.attachments_visible?(*args)
11740
  end
11741
11742 0:513646585e45 Chris
  def start_date
11743 119:8661b858af72 Chris
    @start_date ||= fixed_issues.minimum('start_date')
11744 0:513646585e45 Chris
  end
11745 909:cbb26bc654de Chris
11746 0:513646585e45 Chris
  def due_date
11747
    effective_date
11748
  end
11749 909:cbb26bc654de Chris
11750
  def due_date=(arg)
11751
    self.effective_date=(arg)
11752
  end
11753
11754 0:513646585e45 Chris
  # Returns the total estimated time for this version
11755
  # (sum of leaves estimated_hours)
11756
  def estimated_hours
11757
    @estimated_hours ||= fixed_issues.leaves.sum(:estimated_hours).to_f
11758
  end
11759 909:cbb26bc654de Chris
11760 0:513646585e45 Chris
  # Returns the total reported time for this version
11761
  def spent_hours
11762 1115:433d4f72a19b Chris
    @spent_hours ||= TimeEntry.joins(:issue).where("#{Issue.table_name}.fixed_version_id = ?", id).sum(:hours).to_f
11763 0:513646585e45 Chris
  end
11764 909:cbb26bc654de Chris
11765 0:513646585e45 Chris
  def closed?
11766
    status == 'closed'
11767
  end
11768
11769
  def open?
11770
    status == 'open'
11771
  end
11772 909:cbb26bc654de Chris
11773 0:513646585e45 Chris
  # Returns true if the version is completed: due date reached and no open issues
11774
  def completed?
11775 1115:433d4f72a19b Chris
    effective_date && (effective_date < Date.today) && (open_issues_count == 0)
11776 0:513646585e45 Chris
  end
11777 22:40f7cfd4df19 chris
11778
  def behind_schedule?
11779 1464:261b3d9a4903 Chris
    if completed_percent == 100
11780 22:40f7cfd4df19 chris
      return false
11781 119:8661b858af72 Chris
    elsif due_date && start_date
11782 1464:261b3d9a4903 Chris
      done_date = start_date + ((due_date - start_date+1)* completed_percent/100).floor
11783 22:40f7cfd4df19 chris
      return done_date <= Date.today
11784
    else
11785
      false # No issues so it's not late
11786
    end
11787
  end
11788 909:cbb26bc654de Chris
11789 0:513646585e45 Chris
  # Returns the completion percentage of this version based on the amount of open/closed issues
11790
  # and the time spent on the open issues.
11791 1464:261b3d9a4903 Chris
  def completed_percent
11792 0:513646585e45 Chris
    if issues_count == 0
11793
      0
11794
    elsif open_issues_count == 0
11795
      100
11796
    else
11797
      issues_progress(false) + issues_progress(true)
11798
    end
11799
  end
11800 909:cbb26bc654de Chris
11801 1464:261b3d9a4903 Chris
  # TODO: remove in Redmine 3.0
11802
  def completed_pourcent
11803
    ActiveSupport::Deprecation.warn "Version#completed_pourcent is deprecated and will be removed in Redmine 3.0. Please use #completed_percent instead."
11804
    completed_percent
11805
  end
11806
11807 0:513646585e45 Chris
  # Returns the percentage of issues that have been marked as 'closed'.
11808 1464:261b3d9a4903 Chris
  def closed_percent
11809 0:513646585e45 Chris
    if issues_count == 0
11810
      0
11811
    else
11812
      issues_progress(false)
11813
    end
11814
  end
11815 909:cbb26bc654de Chris
11816 1464:261b3d9a4903 Chris
  # TODO: remove in Redmine 3.0
11817
  def closed_pourcent
11818
    ActiveSupport::Deprecation.warn "Version#closed_pourcent is deprecated and will be removed in Redmine 3.0. Please use #closed_percent instead."
11819
    closed_percent
11820
  end
11821
11822 0:513646585e45 Chris
  # Returns true if the version is overdue: due date reached and some open issues
11823
  def overdue?
11824
    effective_date && (effective_date < Date.today) && (open_issues_count > 0)
11825
  end
11826 909:cbb26bc654de Chris
11827 0:513646585e45 Chris
  # Returns assigned issues count
11828
  def issues_count
11829 1115:433d4f72a19b Chris
    load_issue_counts
11830
    @issue_count
11831 0:513646585e45 Chris
  end
11832 909:cbb26bc654de Chris
11833 0:513646585e45 Chris
  # Returns the total amount of open issues for this version.
11834
  def open_issues_count
11835 1115:433d4f72a19b Chris
    load_issue_counts
11836
    @open_issues_count
11837 0:513646585e45 Chris
  end
11838
11839
  # Returns the total amount of closed issues for this version.
11840
  def closed_issues_count
11841 1115:433d4f72a19b Chris
    load_issue_counts
11842
    @closed_issues_count
11843 0:513646585e45 Chris
  end
11844 909:cbb26bc654de Chris
11845 0:513646585e45 Chris
  def wiki_page
11846
    if project.wiki && !wiki_page_title.blank?
11847
      @wiki_page ||= project.wiki.find_page(wiki_page_title)
11848
    end
11849
    @wiki_page
11850
  end
11851 909:cbb26bc654de Chris
11852 0:513646585e45 Chris
  def to_s; name end
11853 22:40f7cfd4df19 chris
11854
  def to_s_with_project
11855
    "#{project} - #{name}"
11856
  end
11857 909:cbb26bc654de Chris
11858 1115:433d4f72a19b Chris
  # Versions are sorted by effective_date and name
11859
  # Those with no effective_date are at the end, sorted by name
11860 0:513646585e45 Chris
  def <=>(version)
11861
    if self.effective_date
11862
      if version.effective_date
11863
        if self.effective_date == version.effective_date
11864 1115:433d4f72a19b Chris
          name == version.name ? id <=> version.id : name <=> version.name
11865 0:513646585e45 Chris
        else
11866
          self.effective_date <=> version.effective_date
11867
        end
11868
      else
11869
        -1
11870
      end
11871
    else
11872
      if version.effective_date
11873
        1
11874
      else
11875 1115:433d4f72a19b Chris
        name == version.name ? id <=> version.id : name <=> version.name
11876 0:513646585e45 Chris
      end
11877
    end
11878
  end
11879 909:cbb26bc654de Chris
11880 1115:433d4f72a19b Chris
  def self.fields_for_order_statement(table=nil)
11881
    table ||= table_name
11882
    ["(CASE WHEN #{table}.effective_date IS NULL THEN 1 ELSE 0 END)", "#{table}.effective_date", "#{table}.name", "#{table}.id"]
11883
  end
11884
11885 1517:dffacf8a6908 Chris
  scope :sorted, lambda { order(fields_for_order_statement) }
11886 1115:433d4f72a19b Chris
11887 0:513646585e45 Chris
  # Returns the sharings that +user+ can set the version to
11888
  def allowed_sharings(user = User.current)
11889
    VERSION_SHARINGS.select do |s|
11890
      if sharing == s
11891
        true
11892
      else
11893
        case s
11894
        when 'system'
11895
          # Only admin users can set a systemwide sharing
11896
          user.admin?
11897
        when 'hierarchy', 'tree'
11898
          # Only users allowed to manage versions of the root project can
11899
          # set sharing to hierarchy or tree
11900
          project.nil? || user.allowed_to?(:manage_versions, project.root)
11901
        else
11902
          true
11903
        end
11904
      end
11905
    end
11906
  end
11907 909:cbb26bc654de Chris
11908 0:513646585e45 Chris
  private
11909
11910 1115:433d4f72a19b Chris
  def load_issue_counts
11911
    unless @issue_count
11912
      @open_issues_count = 0
11913
      @closed_issues_count = 0
11914 1517:dffacf8a6908 Chris
      fixed_issues.group(:status).count.each do |status, count|
11915 1115:433d4f72a19b Chris
        if status.is_closed?
11916
          @closed_issues_count += count
11917
        else
11918
          @open_issues_count += count
11919
        end
11920
      end
11921
      @issue_count = @open_issues_count + @closed_issues_count
11922
    end
11923
  end
11924
11925 0:513646585e45 Chris
  # Update the issue's fixed versions. Used if a version's sharing changes.
11926
  def update_issues_from_sharing_change
11927
    if sharing_changed?
11928
      if VERSION_SHARINGS.index(sharing_was).nil? ||
11929
          VERSION_SHARINGS.index(sharing).nil? ||
11930
          VERSION_SHARINGS.index(sharing_was) > VERSION_SHARINGS.index(sharing)
11931
        Issue.update_versions_from_sharing_change self
11932
      end
11933
    end
11934
  end
11935 909:cbb26bc654de Chris
11936 0:513646585e45 Chris
  # Returns the average estimated time of assigned issues
11937
  # or 1 if no issue has an estimated time
11938
  # Used to weigth unestimated issues in progress calculation
11939
  def estimated_average
11940
    if @estimated_average.nil?
11941
      average = fixed_issues.average(:estimated_hours).to_f
11942
      if average == 0
11943
        average = 1
11944
      end
11945
      @estimated_average = average
11946
    end
11947
    @estimated_average
11948
  end
11949 909:cbb26bc654de Chris
11950 0:513646585e45 Chris
  # Returns the total progress of open or closed issues.  The returned percentage takes into account
11951
  # the amount of estimated time set for this version.
11952
  #
11953
  # Examples:
11954
  # issues_progress(true)   => returns the progress percentage for open issues.
11955
  # issues_progress(false)  => returns the progress percentage for closed issues.
11956
  def issues_progress(open)
11957
    @issues_progress ||= {}
11958
    @issues_progress[open] ||= begin
11959
      progress = 0
11960
      if issues_count > 0
11961
        ratio = open ? 'done_ratio' : 100
11962 909:cbb26bc654de Chris
11963 1115:433d4f72a19b Chris
        done = fixed_issues.open(open).sum("COALESCE(estimated_hours, #{estimated_average}) * #{ratio}").to_f
11964 0:513646585e45 Chris
        progress = done / (estimated_average * issues_count)
11965
      end
11966
      progress
11967
    end
11968
  end
11969
end
11970
# Redmine - project management software
11971 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
11972 0:513646585e45 Chris
#
11973
# This program is free software; you can redistribute it and/or
11974
# modify it under the terms of the GNU General Public License
11975
# as published by the Free Software Foundation; either version 2
11976
# of the License, or (at your option) any later version.
11977 909:cbb26bc654de Chris
#
11978 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
11979
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11980
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11981
# GNU General Public License for more details.
11982 909:cbb26bc654de Chris
#
11983 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
11984
# along with this program; if not, write to the Free Software
11985
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
11986
11987
class VersionCustomField < CustomField
11988
  def type_name
11989
    :label_version_plural
11990
  end
11991
end
11992 909:cbb26bc654de Chris
# Redmine - project management software
11993 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
11994 0:513646585e45 Chris
#
11995
# This program is free software; you can redistribute it and/or
11996
# modify it under the terms of the GNU General Public License
11997
# as published by the Free Software Foundation; either version 2
11998
# of the License, or (at your option) any later version.
11999 909:cbb26bc654de Chris
#
12000 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
12001
# but WITHOUT ANY WARRANTY; without even the implied warranty of
12002
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12003
# GNU General Public License for more details.
12004 909:cbb26bc654de Chris
#
12005 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
12006
# along with this program; if not, write to the Free Software
12007
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
12008
12009
class Watcher < ActiveRecord::Base
12010
  belongs_to :watchable, :polymorphic => true
12011
  belongs_to :user
12012 909:cbb26bc654de Chris
12013 0:513646585e45 Chris
  validates_presence_of :user
12014
  validates_uniqueness_of :user_id, :scope => [:watchable_type, :watchable_id]
12015 1115:433d4f72a19b Chris
  validate :validate_user
12016 0:513646585e45 Chris
12017 1464:261b3d9a4903 Chris
  # Returns true if at least one object among objects is watched by user
12018
  def self.any_watched?(objects, user)
12019
    objects = objects.reject(&:new_record?)
12020
    if objects.any?
12021
      objects.group_by {|object| object.class.base_class}.each do |base_class, objects|
12022
        if Watcher.where(:watchable_type => base_class.name, :watchable_id => objects.map(&:id), :user_id => user.id).exists?
12023
          return true
12024
        end
12025
      end
12026
    end
12027
    false
12028
  end
12029
12030 0:513646585e45 Chris
  # Unwatch things that users are no longer allowed to view
12031
  def self.prune(options={})
12032
    if options.has_key?(:user)
12033
      prune_single_user(options[:user], options)
12034
    else
12035
      pruned = 0
12036 1517:dffacf8a6908 Chris
      User.where("id IN (SELECT DISTINCT user_id FROM #{table_name})").each do |user|
12037 0:513646585e45 Chris
        pruned += prune_single_user(user, options)
12038
      end
12039
      pruned
12040
    end
12041
  end
12042 909:cbb26bc654de Chris
12043 0:513646585e45 Chris
  protected
12044 909:cbb26bc654de Chris
12045 1115:433d4f72a19b Chris
  def validate_user
12046 0:513646585e45 Chris
    errors.add :user_id, :invalid unless user.nil? || user.active?
12047
  end
12048 909:cbb26bc654de Chris
12049 0:513646585e45 Chris
  private
12050 909:cbb26bc654de Chris
12051 0:513646585e45 Chris
  def self.prune_single_user(user, options={})
12052
    return unless user.is_a?(User)
12053
    pruned = 0
12054 1517:dffacf8a6908 Chris
    where(:user_id => user.id).each do |watcher|
12055 0:513646585e45 Chris
      next if watcher.watchable.nil?
12056
      if options.has_key?(:project)
12057 1517:dffacf8a6908 Chris
        unless watcher.watchable.respond_to?(:project) &&
12058
                 watcher.watchable.project == options[:project]
12059
          next
12060
        end
12061 0:513646585e45 Chris
      end
12062
      if watcher.watchable.respond_to?(:visible?)
12063
        unless watcher.watchable.visible?(user)
12064
          watcher.destroy
12065
          pruned += 1
12066
        end
12067
      end
12068
    end
12069
    pruned
12070
  end
12071
end
12072
# Redmine - project management software
12073 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
12074 0:513646585e45 Chris
#
12075
# This program is free software; you can redistribute it and/or
12076
# modify it under the terms of the GNU General Public License
12077
# as published by the Free Software Foundation; either version 2
12078
# of the License, or (at your option) any later version.
12079 909:cbb26bc654de Chris
#
12080 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
12081
# but WITHOUT ANY WARRANTY; without even the implied warranty of
12082
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12083
# GNU General Public License for more details.
12084 909:cbb26bc654de Chris
#
12085 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
12086
# along with this program; if not, write to the Free Software
12087
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
12088
12089
class Wiki < ActiveRecord::Base
12090 929:5f33065ddc4b Chris
  include Redmine::SafeAttributes
12091 0:513646585e45 Chris
  belongs_to :project
12092
  has_many :pages, :class_name => 'WikiPage', :dependent => :destroy, :order => 'title'
12093
  has_many :redirects, :class_name => 'WikiRedirect', :dependent => :delete_all
12094 909:cbb26bc654de Chris
12095 0:513646585e45 Chris
  acts_as_watchable
12096 909:cbb26bc654de Chris
12097 0:513646585e45 Chris
  validates_presence_of :start_page
12098 1464:261b3d9a4903 Chris
  validates_format_of :start_page, :with => /\A[^,\.\/\?\;\|\:]*\z/
12099 909:cbb26bc654de Chris
12100 929:5f33065ddc4b Chris
  safe_attributes 'start_page'
12101
12102 0:513646585e45 Chris
  def visible?(user=User.current)
12103
    !user.nil? && user.allowed_to?(:view_wiki_pages, project)
12104
  end
12105 909:cbb26bc654de Chris
12106 0:513646585e45 Chris
  # Returns the wiki page that acts as the sidebar content
12107
  # or nil if no such page exists
12108
  def sidebar
12109
    @sidebar ||= find_page('Sidebar', :with_redirect => false)
12110
  end
12111 909:cbb26bc654de Chris
12112 0:513646585e45 Chris
  # find the page with the given title
12113
  # if page doesn't exist, return a new page
12114
  def find_or_new_page(title)
12115
    title = start_page if title.blank?
12116
    find_page(title) || WikiPage.new(:wiki => self, :title => Wiki.titleize(title))
12117
  end
12118 909:cbb26bc654de Chris
12119 0:513646585e45 Chris
  # find the page with the given title
12120
  def find_page(title, options = {})
12121 441:cbce1fd3b1b7 Chris
    @page_found_with_redirect = false
12122 0:513646585e45 Chris
    title = start_page if title.blank?
12123
    title = Wiki.titleize(title)
12124 1464:261b3d9a4903 Chris
    page = pages.where("LOWER(title) = LOWER(?)", title).first
12125 0:513646585e45 Chris
    if !page && !(options[:with_redirect] == false)
12126
      # search for a redirect
12127 1464:261b3d9a4903 Chris
      redirect = redirects.where("LOWER(title) = LOWER(?)", title).first
12128 441:cbce1fd3b1b7 Chris
      if redirect
12129
        page = find_page(redirect.redirects_to, :with_redirect => false)
12130
        @page_found_with_redirect = true
12131
      end
12132 0:513646585e45 Chris
    end
12133
    page
12134
  end
12135 909:cbb26bc654de Chris
12136 441:cbce1fd3b1b7 Chris
  # Returns true if the last page was found with a redirect
12137
  def page_found_with_redirect?
12138
    @page_found_with_redirect
12139
  end
12140
12141 0:513646585e45 Chris
  # Finds a page by title
12142
  # The given string can be of one of the forms: "title" or "project:title"
12143
  # Examples:
12144
  #   Wiki.find_page("bar", project => foo)
12145
  #   Wiki.find_page("foo:bar")
12146
  def self.find_page(title, options = {})
12147
    project = options[:project]
12148
    if title.to_s =~ %r{^([^\:]+)\:(.*)$}
12149
      project_identifier, title = $1, $2
12150
      project = Project.find_by_identifier(project_identifier) || Project.find_by_name(project_identifier)
12151
    end
12152
    if project && project.wiki
12153
      page = project.wiki.find_page(title)
12154
      if page && page.content
12155
        page
12156
      end
12157
    end
12158
  end
12159 909:cbb26bc654de Chris
12160 0:513646585e45 Chris
  # turn a string into a valid page title
12161
  def self.titleize(title)
12162
    # replace spaces with _ and remove unwanted caracters
12163
    title = title.gsub(/\s+/, '_').delete(',./?;|:') if title
12164
    # upcase the first letter
12165
    title = (title.slice(0..0).upcase + (title.slice(1..-1) || '')) if title
12166
    title
12167 909:cbb26bc654de Chris
  end
12168 0:513646585e45 Chris
end
12169 1115:433d4f72a19b Chris
# Redmine - project management software
12170 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
12171 0:513646585e45 Chris
#
12172
# This program is free software; you can redistribute it and/or
12173
# modify it under the terms of the GNU General Public License
12174
# as published by the Free Software Foundation; either version 2
12175
# of the License, or (at your option) any later version.
12176 441:cbce1fd3b1b7 Chris
#
12177 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
12178
# but WITHOUT ANY WARRANTY; without even the implied warranty of
12179
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12180
# GNU General Public License for more details.
12181 441:cbce1fd3b1b7 Chris
#
12182 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
12183
# along with this program; if not, write to the Free Software
12184
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
12185
12186
require 'zlib'
12187
12188
class WikiContent < ActiveRecord::Base
12189 1115:433d4f72a19b Chris
  self.locking_column = 'version'
12190 0:513646585e45 Chris
  belongs_to :page, :class_name => 'WikiPage', :foreign_key => 'page_id'
12191
  belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
12192
  validates_presence_of :text
12193
  validates_length_of :comments, :maximum => 255, :allow_nil => true
12194 441:cbce1fd3b1b7 Chris
12195 0:513646585e45 Chris
  acts_as_versioned
12196 441:cbce1fd3b1b7 Chris
12197 1464:261b3d9a4903 Chris
  after_save :send_notification
12198
12199 0:513646585e45 Chris
  def visible?(user=User.current)
12200
    page.visible?(user)
12201
  end
12202 441:cbce1fd3b1b7 Chris
12203 0:513646585e45 Chris
  def project
12204
    page.project
12205
  end
12206 441:cbce1fd3b1b7 Chris
12207 0:513646585e45 Chris
  def attachments
12208
    page.nil? ? [] : page.attachments
12209
  end
12210 441:cbce1fd3b1b7 Chris
12211 0:513646585e45 Chris
  # Returns the mail adresses of users that should be notified
12212
  def recipients
12213
    notified = project.notified_users
12214
    notified.reject! {|user| !visible?(user)}
12215
    notified.collect(&:mail)
12216
  end
12217 441:cbce1fd3b1b7 Chris
12218 909:cbb26bc654de Chris
  # Return true if the content is the current page content
12219
  def current_version?
12220
    true
12221
  end
12222
12223 0:513646585e45 Chris
  class Version
12224
    belongs_to :page, :class_name => '::WikiPage', :foreign_key => 'page_id'
12225
    belongs_to :author, :class_name => '::User', :foreign_key => 'author_id'
12226
    attr_protected :data
12227
12228
    acts_as_event :title => Proc.new {|o| "#{l(:label_wiki_edit)}: #{o.page.title} (##{o.version})"},
12229
                  :description => :comments,
12230
                  :datetime => :updated_on,
12231
                  :type => 'wiki-page',
12232 1464:261b3d9a4903 Chris
                  :group => :page,
12233 37:94944d00e43c chris
                  :url => Proc.new {|o| {:controller => 'wiki', :action => 'show', :project_id => o.page.wiki.project, :id => o.page.title, :version => o.version}}
12234 0:513646585e45 Chris
12235
    acts_as_activity_provider :type => 'wiki_edits',
12236
                              :timestamp => "#{WikiContent.versioned_table_name}.updated_on",
12237
                              :author_key => "#{WikiContent.versioned_table_name}.author_id",
12238
                              :permission => :view_wiki_edits,
12239
                              :find_options => {:select => "#{WikiContent.versioned_table_name}.updated_on, #{WikiContent.versioned_table_name}.comments, " +
12240
                                                           "#{WikiContent.versioned_table_name}.#{WikiContent.version_column}, #{WikiPage.table_name}.title, " +
12241
                                                           "#{WikiContent.versioned_table_name}.page_id, #{WikiContent.versioned_table_name}.author_id, " +
12242
                                                           "#{WikiContent.versioned_table_name}.id",
12243
                                                :joins => "LEFT JOIN #{WikiPage.table_name} ON #{WikiPage.table_name}.id = #{WikiContent.versioned_table_name}.page_id " +
12244
                                                          "LEFT JOIN #{Wiki.table_name} ON #{Wiki.table_name}.id = #{WikiPage.table_name}.wiki_id " +
12245
                                                          "LEFT JOIN #{Project.table_name} ON #{Project.table_name}.id = #{Wiki.table_name}.project_id"}
12246
12247 1115:433d4f72a19b Chris
    after_destroy :page_update_after_destroy
12248
12249 0:513646585e45 Chris
    def text=(plain)
12250
      case Setting.wiki_compression
12251
      when 'gzip'
12252
      begin
12253
        self.data = Zlib::Deflate.deflate(plain, Zlib::BEST_COMPRESSION)
12254
        self.compression = 'gzip'
12255
      rescue
12256
        self.data = plain
12257
        self.compression = ''
12258
      end
12259
      else
12260
        self.data = plain
12261
        self.compression = ''
12262
      end
12263
      plain
12264
    end
12265 441:cbce1fd3b1b7 Chris
12266 0:513646585e45 Chris
    def text
12267 1115:433d4f72a19b Chris
      @text ||= begin
12268
        str = case compression
12269
              when 'gzip'
12270
                Zlib::Inflate.inflate(data)
12271
              else
12272
                # uncompressed data
12273
                data
12274
              end
12275 909:cbb26bc654de Chris
        str.force_encoding("UTF-8") if str.respond_to?(:force_encoding)
12276
        str
12277 441:cbce1fd3b1b7 Chris
      end
12278 0:513646585e45 Chris
    end
12279 441:cbce1fd3b1b7 Chris
12280 0:513646585e45 Chris
    def project
12281
      page.project
12282
    end
12283 441:cbce1fd3b1b7 Chris
12284 909:cbb26bc654de Chris
    # Return true if the content is the current page content
12285
    def current_version?
12286
      page.content.version == self.version
12287
    end
12288
12289 0:513646585e45 Chris
    # Returns the previous version or nil
12290
    def previous
12291 1115:433d4f72a19b Chris
      @previous ||= WikiContent::Version.
12292
        reorder('version DESC').
12293
        includes(:author).
12294
        where("wiki_content_id = ? AND version < ?", wiki_content_id, version).first
12295
    end
12296
12297
    # Returns the next version or nil
12298
    def next
12299
      @next ||= WikiContent::Version.
12300
        reorder('version ASC').
12301
        includes(:author).
12302
        where("wiki_content_id = ? AND version > ?", wiki_content_id, version).first
12303
    end
12304
12305
    private
12306
12307
    # Updates page's content if the latest version is removed
12308
    # or destroys the page if it was the only version
12309
    def page_update_after_destroy
12310
      latest = page.content.versions.reorder("#{self.class.table_name}.version DESC").first
12311
      if latest && page.content.version != latest.version
12312
        raise ActiveRecord::Rollback unless page.content.revert_to!(latest)
12313
      elsif latest.nil?
12314
        raise ActiveRecord::Rollback unless page.destroy
12315
      end
12316 0:513646585e45 Chris
    end
12317
  end
12318 1464:261b3d9a4903 Chris
12319
  private
12320
12321
  def send_notification
12322
    # new_record? returns false in after_save callbacks
12323
    if id_changed?
12324
      if Setting.notified_events.include?('wiki_content_added')
12325
        Mailer.wiki_content_added(self).deliver
12326
      end
12327
    elsif text_changed?
12328
      if Setting.notified_events.include?('wiki_content_updated')
12329
        Mailer.wiki_content_updated(self).deliver
12330
      end
12331
    end
12332
  end
12333 0:513646585e45 Chris
end
12334
# Redmine - project management software
12335 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
12336 0:513646585e45 Chris
#
12337
# This program is free software; you can redistribute it and/or
12338
# modify it under the terms of the GNU General Public License
12339
# as published by the Free Software Foundation; either version 2
12340
# of the License, or (at your option) any later version.
12341 909:cbb26bc654de Chris
#
12342 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
12343
# but WITHOUT ANY WARRANTY; without even the implied warranty of
12344
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12345
# GNU General Public License for more details.
12346 909:cbb26bc654de Chris
#
12347 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
12348
# along with this program; if not, write to the Free Software
12349
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
12350
12351
require 'diff'
12352
require 'enumerator'
12353
12354
class WikiPage < ActiveRecord::Base
12355 1115:433d4f72a19b Chris
  include Redmine::SafeAttributes
12356
12357 0:513646585e45 Chris
  belongs_to :wiki
12358
  has_one :content, :class_name => 'WikiContent', :foreign_key => 'page_id', :dependent => :destroy
12359
  acts_as_attachable :delete_permission => :delete_wiki_pages_attachments
12360
  acts_as_tree :dependent => :nullify, :order => 'title'
12361
12362
  acts_as_watchable
12363
  acts_as_event :title => Proc.new {|o| "#{l(:label_wiki)}: #{o.title}"},
12364
                :description => :text,
12365
                :datetime => :created_on,
12366 37:94944d00e43c chris
                :url => Proc.new {|o| {:controller => 'wiki', :action => 'show', :project_id => o.wiki.project, :id => o.title}}
12367 0:513646585e45 Chris
12368 909:cbb26bc654de Chris
  acts_as_searchable :columns => ['title', "#{WikiContent.table_name}.text"],
12369 0:513646585e45 Chris
                     :include => [{:wiki => :project}, :content],
12370 441:cbce1fd3b1b7 Chris
                     :permission => :view_wiki_pages,
12371 0:513646585e45 Chris
                     :project_key => "#{Wiki.table_name}.project_id"
12372
12373
  attr_accessor :redirect_existing_links
12374 909:cbb26bc654de Chris
12375 0:513646585e45 Chris
  validates_presence_of :title
12376 1464:261b3d9a4903 Chris
  validates_format_of :title, :with => /\A[^,\.\/\?\;\|\s]*\z/
12377 0:513646585e45 Chris
  validates_uniqueness_of :title, :scope => :wiki_id, :case_sensitive => false
12378
  validates_associated :content
12379 909:cbb26bc654de Chris
12380
  validate :validate_parent_title
12381
  before_destroy :remove_redirects
12382
  before_save    :handle_redirects
12383
12384 441:cbce1fd3b1b7 Chris
  # eager load information about last updates, without loading text
12385 1464:261b3d9a4903 Chris
  scope :with_updated_on, lambda {
12386
    select("#{WikiPage.table_name}.*, #{WikiContent.table_name}.updated_on, #{WikiContent.table_name}.version").
12387
      joins("LEFT JOIN #{WikiContent.table_name} ON #{WikiContent.table_name}.page_id = #{WikiPage.table_name}.id")
12388 441:cbce1fd3b1b7 Chris
  }
12389 909:cbb26bc654de Chris
12390 0:513646585e45 Chris
  # Wiki pages that are protected by default
12391
  DEFAULT_PROTECTED_PAGES = %w(sidebar)
12392 909:cbb26bc654de Chris
12393 1115:433d4f72a19b Chris
  safe_attributes 'parent_id', 'parent_title',
12394
    :if => lambda {|page, user| page.new_record? || user.allowed_to?(:rename_wiki_pages, page.project)}
12395
12396
  def initialize(attributes=nil, *args)
12397
    super
12398 0:513646585e45 Chris
    if new_record? && DEFAULT_PROTECTED_PAGES.include?(title.to_s.downcase)
12399
      self.protected = true
12400
    end
12401
  end
12402 909:cbb26bc654de Chris
12403 0:513646585e45 Chris
  def visible?(user=User.current)
12404
    !user.nil? && user.allowed_to?(:view_wiki_pages, project)
12405
  end
12406
12407
  def title=(value)
12408
    value = Wiki.titleize(value)
12409
    @previous_title = read_attribute(:title) if @previous_title.blank?
12410
    write_attribute(:title, value)
12411
  end
12412
12413 909:cbb26bc654de Chris
  def handle_redirects
12414
    self.title = Wiki.titleize(title)
12415 0:513646585e45 Chris
    # Manage redirects if the title has changed
12416
    if !@previous_title.blank? && (@previous_title != title) && !new_record?
12417
      # Update redirects that point to the old title
12418 1517:dffacf8a6908 Chris
      wiki.redirects.where(:redirects_to => @previous_title).each do |r|
12419 0:513646585e45 Chris
        r.redirects_to = title
12420
        r.title == r.redirects_to ? r.destroy : r.save
12421
      end
12422
      # Remove redirects for the new title
12423 1517:dffacf8a6908 Chris
      wiki.redirects.where(:title => title).each(&:destroy)
12424 0:513646585e45 Chris
      # Create a redirect to the new title
12425
      wiki.redirects << WikiRedirect.new(:title => @previous_title, :redirects_to => title) unless redirect_existing_links == "0"
12426
      @previous_title = nil
12427
    end
12428
  end
12429 909:cbb26bc654de Chris
12430
  def remove_redirects
12431 0:513646585e45 Chris
    # Remove redirects to this page
12432 1517:dffacf8a6908 Chris
    wiki.redirects.where(:redirects_to => title).each(&:destroy)
12433 0:513646585e45 Chris
  end
12434 909:cbb26bc654de Chris
12435 0:513646585e45 Chris
  def pretty_title
12436
    WikiPage.pretty_title(title)
12437
  end
12438 909:cbb26bc654de Chris
12439 0:513646585e45 Chris
  def content_for_version(version=nil)
12440 1517:dffacf8a6908 Chris
    if content
12441
      result = content.versions.find_by_version(version.to_i) if version
12442
      result ||= content
12443
      result
12444
    end
12445 0:513646585e45 Chris
  end
12446 909:cbb26bc654de Chris
12447 0:513646585e45 Chris
  def diff(version_to=nil, version_from=nil)
12448
    version_to = version_to ? version_to.to_i : self.content.version
12449 1115:433d4f72a19b Chris
    content_to = content.versions.find_by_version(version_to)
12450
    content_from = version_from ? content.versions.find_by_version(version_from.to_i) : content_to.try(:previous)
12451
    return nil unless content_to && content_from
12452 909:cbb26bc654de Chris
12453 1115:433d4f72a19b Chris
    if content_from.version > content_to.version
12454
      content_to, content_from = content_from, content_to
12455
    end
12456 909:cbb26bc654de Chris
12457 0:513646585e45 Chris
    (content_to && content_from) ? WikiDiff.new(content_to, content_from) : nil
12458
  end
12459 909:cbb26bc654de Chris
12460 0:513646585e45 Chris
  def annotate(version=nil)
12461
    version = version ? version.to_i : self.content.version
12462
    c = content.versions.find_by_version(version)
12463
    c ? WikiAnnotate.new(c) : nil
12464
  end
12465 909:cbb26bc654de Chris
12466 0:513646585e45 Chris
  def self.pretty_title(str)
12467
    (str && str.is_a?(String)) ? str.tr('_', ' ') : str
12468
  end
12469 909:cbb26bc654de Chris
12470 0:513646585e45 Chris
  def project
12471
    wiki.project
12472
  end
12473 909:cbb26bc654de Chris
12474 0:513646585e45 Chris
  def text
12475
    content.text if content
12476
  end
12477 909:cbb26bc654de Chris
12478 441:cbce1fd3b1b7 Chris
  def updated_on
12479
    unless @updated_on
12480
      if time = read_attribute(:updated_on)
12481
        # content updated_on was eager loaded with the page
12482 1115:433d4f72a19b Chris
        begin
12483
          @updated_on = (self.class.default_timezone == :utc ? Time.parse(time.to_s).utc : Time.parse(time.to_s).localtime)
12484
        rescue
12485
        end
12486 441:cbce1fd3b1b7 Chris
      else
12487
        @updated_on = content && content.updated_on
12488
      end
12489
    end
12490
    @updated_on
12491
  end
12492 909:cbb26bc654de Chris
12493 0:513646585e45 Chris
  # Returns true if usr is allowed to edit the page, otherwise false
12494
  def editable_by?(usr)
12495
    !protected? || usr.allowed_to?(:protect_wiki_pages, wiki.project)
12496
  end
12497 909:cbb26bc654de Chris
12498 0:513646585e45 Chris
  def attachments_deletable?(usr=User.current)
12499
    editable_by?(usr) && super(usr)
12500
  end
12501 909:cbb26bc654de Chris
12502 0:513646585e45 Chris
  def parent_title
12503
    @parent_title || (self.parent && self.parent.pretty_title)
12504
  end
12505 909:cbb26bc654de Chris
12506 0:513646585e45 Chris
  def parent_title=(t)
12507
    @parent_title = t
12508
    parent_page = t.blank? ? nil : self.wiki.find_page(t)
12509
    self.parent = parent_page
12510
  end
12511 37:94944d00e43c chris
12512 1115:433d4f72a19b Chris
  # Saves the page and its content if text was changed
12513 1464:261b3d9a4903 Chris
  def save_with_content(content)
12514 1115:433d4f72a19b Chris
    ret = nil
12515
    transaction do
12516 1464:261b3d9a4903 Chris
      self.content = content
12517 1115:433d4f72a19b Chris
      if new_record?
12518
        # Rails automatically saves associated content
12519
        ret = save
12520
      else
12521
        ret = save && (content.text_changed? ? content.save : true)
12522
      end
12523
      raise ActiveRecord::Rollback unless ret
12524
    end
12525
    ret
12526
  end
12527
12528 0:513646585e45 Chris
  protected
12529 909:cbb26bc654de Chris
12530
  def validate_parent_title
12531 0:513646585e45 Chris
    errors.add(:parent_title, :invalid) if !@parent_title.blank? && parent.nil?
12532
    errors.add(:parent_title, :circular_dependency) if parent && (parent == self || parent.ancestors.include?(self))
12533
    errors.add(:parent_title, :not_same_project) if parent && (parent.wiki_id != wiki_id)
12534
  end
12535
end
12536
12537 245:051f544170fe Chris
class WikiDiff < Redmine::Helpers::Diff
12538
  attr_reader :content_to, :content_from
12539 909:cbb26bc654de Chris
12540 0:513646585e45 Chris
  def initialize(content_to, content_from)
12541
    @content_to = content_to
12542
    @content_from = content_from
12543 245:051f544170fe Chris
    super(content_to.text, content_from.text)
12544 0:513646585e45 Chris
  end
12545
end
12546
12547
class WikiAnnotate
12548
  attr_reader :lines, :content
12549 909:cbb26bc654de Chris
12550 0:513646585e45 Chris
  def initialize(content)
12551
    @content = content
12552
    current = content
12553
    current_lines = current.text.split(/\r?\n/)
12554
    @lines = current_lines.collect {|t| [nil, nil, t]}
12555
    positions = []
12556
    current_lines.size.times {|i| positions << i}
12557
    while (current.previous)
12558
      d = current.previous.text.split(/\r?\n/).diff(current.text.split(/\r?\n/)).diffs.flatten
12559
      d.each_slice(3) do |s|
12560
        sign, line = s[0], s[1]
12561
        if sign == '+' && positions[line] && positions[line] != -1
12562
          if @lines[positions[line]][0].nil?
12563
            @lines[positions[line]][0] = current.version
12564
            @lines[positions[line]][1] = current.author
12565
          end
12566
        end
12567
      end
12568
      d.each_slice(3) do |s|
12569
        sign, line = s[0], s[1]
12570
        if sign == '-'
12571
          positions.insert(line, -1)
12572
        else
12573
          positions[line] = nil
12574
        end
12575
      end
12576
      positions.compact!
12577
      # Stop if every line is annotated
12578
      break unless @lines.detect { |line| line[0].nil? }
12579
      current = current.previous
12580
    end
12581 909:cbb26bc654de Chris
    @lines.each { |line|
12582 507:0c939c159af4 Chris
      line[0] ||= current.version
12583
      # if the last known version is > 1 (eg. history was cleared), we don't know the author
12584
      line[1] ||= current.author if current.version == 1
12585
    }
12586 0:513646585e45 Chris
  end
12587
end
12588 909:cbb26bc654de Chris
# Redmine - project management software
12589 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
12590 0:513646585e45 Chris
#
12591
# This program is free software; you can redistribute it and/or
12592
# modify it under the terms of the GNU General Public License
12593
# as published by the Free Software Foundation; either version 2
12594
# of the License, or (at your option) any later version.
12595 909:cbb26bc654de Chris
#
12596 0:513646585e45 Chris
# This program is distributed in the hope that it will be useful,
12597
# but WITHOUT ANY WARRANTY; without even the implied warranty of
12598
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12599
# GNU General Public License for more details.
12600 909:cbb26bc654de Chris
#
12601 0:513646585e45 Chris
# You should have received a copy of the GNU General Public License
12602
# along with this program; if not, write to the Free Software
12603
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
12604
12605
class WikiRedirect < ActiveRecord::Base
12606
  belongs_to :wiki
12607 909:cbb26bc654de Chris
12608 0:513646585e45 Chris
  validates_presence_of :title, :redirects_to
12609
  validates_length_of :title, :redirects_to, :maximum => 255
12610
end
12611 1115:433d4f72a19b Chris
# Redmine - project management software
12612 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
12613 1115:433d4f72a19b Chris
#
12614
# This program is free software; you can redistribute it and/or
12615
# modify it under the terms of the GNU General Public License
12616
# as published by the Free Software Foundation; either version 2
12617
# of the License, or (at your option) any later version.
12618
#
12619
# This program is distributed in the hope that it will be useful,
12620
# but WITHOUT ANY WARRANTY; without even the implied warranty of
12621
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12622
# GNU General Public License for more details.
12623
#
12624
# You should have received a copy of the GNU General Public License
12625
# along with this program; if not, write to the Free Software
12626
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
12627
12628
class WorkflowPermission < WorkflowRule
12629
  validates_inclusion_of :rule, :in => %w(readonly required)
12630
  validate :validate_field_name
12631
12632
  # Replaces the workflow permissions for the given tracker and role
12633
  #
12634
  # Example:
12635
  #   WorkflowPermission.replace_permissions role, tracker, {'due_date' => {'1' => 'readonly', '2' => 'required'}}
12636
  def self.replace_permissions(tracker, role, permissions)
12637
    destroy_all(:tracker_id => tracker.id, :role_id => role.id)
12638
12639
    permissions.each { |field, rule_by_status_id|
12640
      rule_by_status_id.each { |status_id, rule|
12641
        if rule.present?
12642
          WorkflowPermission.create(:role_id => role.id, :tracker_id => tracker.id, :old_status_id => status_id, :field_name => field, :rule => rule)
12643
        end
12644
      }
12645
    }
12646
  end
12647
12648
  protected
12649
12650
  def validate_field_name
12651
    unless Tracker::CORE_FIELDS_ALL.include?(field_name) || field_name.to_s.match(/^\d+$/)
12652
      errors.add :field_name, :invalid
12653
    end
12654
  end
12655
end
12656
# Redmine - project management software
12657 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
12658 1115:433d4f72a19b Chris
#
12659
# This program is free software; you can redistribute it and/or
12660
# modify it under the terms of the GNU General Public License
12661
# as published by the Free Software Foundation; either version 2
12662
# of the License, or (at your option) any later version.
12663
#
12664
# This program is distributed in the hope that it will be useful,
12665
# but WITHOUT ANY WARRANTY; without even the implied warranty of
12666
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12667
# GNU General Public License for more details.
12668
#
12669
# You should have received a copy of the GNU General Public License
12670
# along with this program; if not, write to the Free Software
12671
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
12672
12673
class WorkflowRule < ActiveRecord::Base
12674
  self.table_name = "#{table_name_prefix}workflows#{table_name_suffix}"
12675
12676
  belongs_to :role
12677
  belongs_to :tracker
12678
  belongs_to :old_status, :class_name => 'IssueStatus', :foreign_key => 'old_status_id'
12679
  belongs_to :new_status, :class_name => 'IssueStatus', :foreign_key => 'new_status_id'
12680
12681
  validates_presence_of :role, :tracker, :old_status
12682
12683
  # Copies workflows from source to targets
12684
  def self.copy(source_tracker, source_role, target_trackers, target_roles)
12685
    unless source_tracker.is_a?(Tracker) || source_role.is_a?(Role)
12686
      raise ArgumentError.new("source_tracker or source_role must be specified")
12687
    end
12688
12689
    target_trackers = [target_trackers].flatten.compact
12690
    target_roles = [target_roles].flatten.compact
12691
12692
    target_trackers = Tracker.sorted.all if target_trackers.empty?
12693
    target_roles = Role.all if target_roles.empty?
12694
12695
    target_trackers.each do |target_tracker|
12696
      target_roles.each do |target_role|
12697
        copy_one(source_tracker || target_tracker,
12698
                   source_role || target_role,
12699
                   target_tracker,
12700
                   target_role)
12701
      end
12702
    end
12703
  end
12704
12705
  # Copies a single set of workflows from source to target
12706
  def self.copy_one(source_tracker, source_role, target_tracker, target_role)
12707
    unless source_tracker.is_a?(Tracker) && !source_tracker.new_record? &&
12708
      source_role.is_a?(Role) && !source_role.new_record? &&
12709
      target_tracker.is_a?(Tracker) && !target_tracker.new_record? &&
12710
      target_role.is_a?(Role) && !target_role.new_record?
12711
12712
      raise ArgumentError.new("arguments can not be nil or unsaved objects")
12713
    end
12714
12715
    if source_tracker == target_tracker && source_role == target_role
12716
      false
12717
    else
12718
      transaction do
12719
        delete_all :tracker_id => target_tracker.id, :role_id => target_role.id
12720 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)" +
12721
                          " SELECT #{target_tracker.id}, #{target_role.id}, old_status_id, new_status_id, author, assignee, field_name, #{connection.quote_column_name 'rule'}, type" +
12722 1115:433d4f72a19b Chris
                          " FROM #{WorkflowRule.table_name}" +
12723
                          " WHERE tracker_id = #{source_tracker.id} AND role_id = #{source_role.id}"
12724
      end
12725
      true
12726
    end
12727
  end
12728
end
12729
# Redmine - project management software
12730 1494:e248c7af89ec Chris
# Copyright (C) 2006-2014  Jean-Philippe Lang
12731 1115:433d4f72a19b Chris
#
12732
# This program is free software; you can redistribute it and/or
12733
# modify it under the terms of the GNU General Public License
12734
# as published by the Free Software Foundation; either version 2
12735
# of the License, or (at your option) any later version.
12736
#
12737
# This program is distributed in the hope that it will be useful,
12738
# but WITHOUT ANY WARRANTY; without even the implied warranty of
12739
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12740
# GNU General Public License for more details.
12741
#
12742
# You should have received a copy of the GNU General Public License
12743
# along with this program; if not, write to the Free Software
12744
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
12745
12746
class WorkflowTransition < WorkflowRule
12747
  validates_presence_of :new_status
12748
12749
  # Returns workflow transitions count by tracker and role
12750
  def self.count_by_tracker_and_role
12751
    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")
12752
    roles = Role.sorted.all
12753
    trackers = Tracker.sorted.all
12754
12755
    result = []
12756
    trackers.each do |tracker|
12757
      t = []
12758
      roles.each do |role|
12759
        row = counts.detect {|c| c['role_id'].to_s == role.id.to_s && c['tracker_id'].to_s == tracker.id.to_s}
12760
        t << [role, (row.nil? ? 0 : row['c'].to_i)]
12761
      end
12762
      result << [tracker, t]
12763
    end
12764
12765
    result
12766
  end
12767
end