To check out this repository please hg clone the following URL, or open the URL using EasyMercurial or your preferred Mercurial client.
root / app / models @ 1533:59e13100ea95
| 1 | 441:cbce1fd3b1b7 | Chris | # Redmine - project management software |
|---|---|---|---|
| 2 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 3 | 0:513646585e45 | Chris | # |
| 4 | # This program is free software; you can redistribute it and/or |
||
| 5 | # modify it under the terms of the GNU General Public License |
||
| 6 | # as published by the Free Software Foundation; either version 2 |
||
| 7 | # of the License, or (at your option) any later version. |
||
| 8 | 441:cbce1fd3b1b7 | Chris | # |
| 9 | 0:513646585e45 | Chris | # This program is distributed in the hope that it will be useful, |
| 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 12 | # GNU General Public License for more details. |
||
| 13 | 441:cbce1fd3b1b7 | Chris | # |
| 14 | 0:513646585e45 | Chris | # You should have received a copy of the GNU General Public License |
| 15 | # along with this program; if not, write to the Free Software |
||
| 16 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 17 | |||
| 18 | require "digest/md5" |
||
| 19 | 1464:261b3d9a4903 | Chris | require "fileutils" |
| 20 | 0:513646585e45 | Chris | |
| 21 | class Attachment < ActiveRecord::Base |
||
| 22 | belongs_to :container, :polymorphic => true |
||
| 23 | belongs_to :author, :class_name => "User", :foreign_key => "author_id" |
||
| 24 | 441:cbce1fd3b1b7 | Chris | |
| 25 | 1115:433d4f72a19b | Chris | validates_presence_of :filename, :author |
| 26 | 0:513646585e45 | Chris | validates_length_of :filename, :maximum => 255 |
| 27 | validates_length_of :disk_filename, :maximum => 255 |
||
| 28 | 1115:433d4f72a19b | Chris | validates_length_of :description, :maximum => 255 |
| 29 | 909:cbb26bc654de | Chris | validate :validate_max_file_size |
| 30 | 0:513646585e45 | Chris | |
| 31 | acts_as_event :title => :filename, |
||
| 32 | :url => Proc.new {|o| {:controller => 'attachments', :action => 'download', :id => o.id, :filename => o.filename}}
|
||
| 33 | |||
| 34 | acts_as_activity_provider :type => 'files', |
||
| 35 | :permission => :view_files, |
||
| 36 | :author_key => :author_id, |
||
| 37 | 441:cbce1fd3b1b7 | Chris | :find_options => {:select => "#{Attachment.table_name}.*",
|
| 38 | 0:513646585e45 | Chris | :joins => "LEFT JOIN #{Version.table_name} ON #{Attachment.table_name}.container_type='Version' AND #{Version.table_name}.id = #{Attachment.table_name}.container_id " +
|
| 39 | "LEFT JOIN #{Project.table_name} ON #{Version.table_name}.project_id = #{Project.table_name}.id OR ( #{Attachment.table_name}.container_type='Project' AND #{Attachment.table_name}.container_id = #{Project.table_name}.id )"}
|
||
| 40 | 441:cbce1fd3b1b7 | Chris | |
| 41 | 0:513646585e45 | Chris | acts_as_activity_provider :type => 'documents', |
| 42 | :permission => :view_documents, |
||
| 43 | :author_key => :author_id, |
||
| 44 | 441:cbce1fd3b1b7 | Chris | :find_options => {:select => "#{Attachment.table_name}.*",
|
| 45 | 0:513646585e45 | Chris | :joins => "LEFT JOIN #{Document.table_name} ON #{Attachment.table_name}.container_type='Document' AND #{Document.table_name}.id = #{Attachment.table_name}.container_id " +
|
| 46 | "LEFT JOIN #{Project.table_name} ON #{Document.table_name}.project_id = #{Project.table_name}.id"}
|
||
| 47 | |||
| 48 | cattr_accessor :storage_path |
||
| 49 | 1115:433d4f72a19b | Chris | @@storage_path = Redmine::Configuration['attachments_storage_path'] || File.join(Rails.root, "files") |
| 50 | |||
| 51 | cattr_accessor :thumbnails_storage_path |
||
| 52 | @@thumbnails_storage_path = File.join(Rails.root, "tmp", "thumbnails") |
||
| 53 | 441:cbce1fd3b1b7 | Chris | |
| 54 | 909:cbb26bc654de | Chris | before_save :files_to_final_location |
| 55 | after_destroy :delete_from_disk |
||
| 56 | |||
| 57 | 1115:433d4f72a19b | Chris | # Returns an unsaved copy of the attachment |
| 58 | def copy(attributes=nil) |
||
| 59 | copy = self.class.new |
||
| 60 | copy.attributes = self.attributes.dup.except("id", "downloads")
|
||
| 61 | copy.attributes = attributes if attributes |
||
| 62 | copy |
||
| 63 | end |
||
| 64 | |||
| 65 | 909:cbb26bc654de | Chris | def validate_max_file_size |
| 66 | 1115:433d4f72a19b | Chris | if @temp_file && self.filesize > Setting.attachment_max_size.to_i.kilobytes |
| 67 | errors.add(:base, l(:error_attachment_too_big, :max_size => Setting.attachment_max_size.to_i.kilobytes)) |
||
| 68 | 0:513646585e45 | Chris | end |
| 69 | end |
||
| 70 | |||
| 71 | def file=(incoming_file) |
||
| 72 | unless incoming_file.nil? |
||
| 73 | @temp_file = incoming_file |
||
| 74 | if @temp_file.size > 0 |
||
| 75 | 1115:433d4f72a19b | Chris | if @temp_file.respond_to?(:original_filename) |
| 76 | self.filename = @temp_file.original_filename |
||
| 77 | self.filename.force_encoding("UTF-8") if filename.respond_to?(:force_encoding)
|
||
| 78 | end |
||
| 79 | if @temp_file.respond_to?(:content_type) |
||
| 80 | self.content_type = @temp_file.content_type.to_s.chomp |
||
| 81 | end |
||
| 82 | if content_type.blank? && filename.present? |
||
| 83 | 0:513646585e45 | Chris | self.content_type = Redmine::MimeType.of(filename) |
| 84 | end |
||
| 85 | self.filesize = @temp_file.size |
||
| 86 | end |
||
| 87 | end |
||
| 88 | end |
||
| 89 | 1115:433d4f72a19b | Chris | |
| 90 | 0:513646585e45 | Chris | def file |
| 91 | nil |
||
| 92 | end |
||
| 93 | |||
| 94 | 1115:433d4f72a19b | Chris | def filename=(arg) |
| 95 | write_attribute :filename, sanitize_filename(arg.to_s) |
||
| 96 | filename |
||
| 97 | end |
||
| 98 | |||
| 99 | 0:513646585e45 | Chris | # Copies the temporary file to its final location |
| 100 | # and computes its MD5 hash |
||
| 101 | 909:cbb26bc654de | Chris | def files_to_final_location |
| 102 | 0:513646585e45 | Chris | if @temp_file && (@temp_file.size > 0) |
| 103 | 1464:261b3d9a4903 | Chris | self.disk_directory = target_directory |
| 104 | self.disk_filename = Attachment.disk_filename(filename, disk_directory) |
||
| 105 | logger.info("Saving attachment '#{self.diskfile}' (#{@temp_file.size} bytes)") if logger
|
||
| 106 | path = File.dirname(diskfile) |
||
| 107 | unless File.directory?(path) |
||
| 108 | FileUtils.mkdir_p(path) |
||
| 109 | end |
||
| 110 | 0:513646585e45 | Chris | md5 = Digest::MD5.new |
| 111 | 441:cbce1fd3b1b7 | Chris | File.open(diskfile, "wb") do |f| |
| 112 | 1115:433d4f72a19b | Chris | if @temp_file.respond_to?(:read) |
| 113 | buffer = "" |
||
| 114 | while (buffer = @temp_file.read(8192)) |
||
| 115 | f.write(buffer) |
||
| 116 | md5.update(buffer) |
||
| 117 | end |
||
| 118 | else |
||
| 119 | f.write(@temp_file) |
||
| 120 | md5.update(@temp_file) |
||
| 121 | 0:513646585e45 | Chris | end |
| 122 | end |
||
| 123 | self.digest = md5.hexdigest |
||
| 124 | end |
||
| 125 | 909:cbb26bc654de | Chris | @temp_file = nil |
| 126 | 0:513646585e45 | Chris | # Don't save the content type if it's longer than the authorized length |
| 127 | if self.content_type && self.content_type.length > 255 |
||
| 128 | self.content_type = nil |
||
| 129 | end |
||
| 130 | end |
||
| 131 | |||
| 132 | 1115:433d4f72a19b | Chris | # Deletes the file from the file system if it's not referenced by other attachments |
| 133 | 909:cbb26bc654de | Chris | def delete_from_disk |
| 134 | 1115:433d4f72a19b | Chris | if Attachment.where("disk_filename = ? AND id <> ?", disk_filename, id).empty?
|
| 135 | delete_from_disk! |
||
| 136 | end |
||
| 137 | 0:513646585e45 | Chris | end |
| 138 | |||
| 139 | # Returns file's location on disk |
||
| 140 | def diskfile |
||
| 141 | 1464:261b3d9a4903 | Chris | File.join(self.class.storage_path, disk_directory.to_s, disk_filename.to_s) |
| 142 | 1115:433d4f72a19b | Chris | end |
| 143 | |||
| 144 | def title |
||
| 145 | title = filename.to_s |
||
| 146 | if description.present? |
||
| 147 | title << " (#{description})"
|
||
| 148 | end |
||
| 149 | title |
||
| 150 | 0:513646585e45 | Chris | end |
| 151 | 441:cbce1fd3b1b7 | Chris | |
| 152 | 0:513646585e45 | Chris | def increment_download |
| 153 | increment!(:downloads) |
||
| 154 | end |
||
| 155 | |||
| 156 | def project |
||
| 157 | 1115:433d4f72a19b | Chris | container.try(:project) |
| 158 | 0:513646585e45 | Chris | end |
| 159 | 441:cbce1fd3b1b7 | Chris | |
| 160 | 0:513646585e45 | Chris | def visible?(user=User.current) |
| 161 | 1464:261b3d9a4903 | Chris | if container_id |
| 162 | container && container.attachments_visible?(user) |
||
| 163 | else |
||
| 164 | author == user |
||
| 165 | end |
||
| 166 | 0:513646585e45 | Chris | end |
| 167 | 441:cbce1fd3b1b7 | Chris | |
| 168 | 0:513646585e45 | Chris | def deletable?(user=User.current) |
| 169 | 1464:261b3d9a4903 | Chris | if container_id |
| 170 | container && container.attachments_deletable?(user) |
||
| 171 | else |
||
| 172 | author == user |
||
| 173 | end |
||
| 174 | 0:513646585e45 | Chris | end |
| 175 | 441:cbce1fd3b1b7 | Chris | |
| 176 | 0:513646585e45 | Chris | def image? |
| 177 | 1115:433d4f72a19b | Chris | !!(self.filename =~ /\.(bmp|gif|jpg|jpe|jpeg|png)$/i) |
| 178 | end |
||
| 179 | |||
| 180 | def thumbnailable? |
||
| 181 | image? |
||
| 182 | end |
||
| 183 | |||
| 184 | # Returns the full path the attachment thumbnail, or nil |
||
| 185 | # if the thumbnail cannot be generated. |
||
| 186 | def thumbnail(options={})
|
||
| 187 | if thumbnailable? && readable? |
||
| 188 | size = options[:size].to_i |
||
| 189 | if size > 0 |
||
| 190 | # Limit the number of thumbnails per image |
||
| 191 | size = (size / 50) * 50 |
||
| 192 | # Maximum thumbnail size |
||
| 193 | size = 800 if size > 800 |
||
| 194 | else |
||
| 195 | size = Setting.thumbnails_size.to_i |
||
| 196 | end |
||
| 197 | size = 100 unless size > 0 |
||
| 198 | target = File.join(self.class.thumbnails_storage_path, "#{id}_#{digest}_#{size}.thumb")
|
||
| 199 | |||
| 200 | begin |
||
| 201 | Redmine::Thumbnail.generate(self.diskfile, target, size) |
||
| 202 | rescue => e |
||
| 203 | logger.error "An error occured while generating thumbnail for #{disk_filename} to #{target}\nException was: #{e.message}" if logger
|
||
| 204 | return nil |
||
| 205 | end |
||
| 206 | end |
||
| 207 | end |
||
| 208 | |||
| 209 | # Deletes all thumbnails |
||
| 210 | def self.clear_thumbnails |
||
| 211 | Dir.glob(File.join(thumbnails_storage_path, "*.thumb")).each do |file| |
||
| 212 | File.delete file |
||
| 213 | end |
||
| 214 | 0:513646585e45 | Chris | end |
| 215 | 441:cbce1fd3b1b7 | Chris | |
| 216 | 0:513646585e45 | Chris | def is_text? |
| 217 | Redmine::MimeType.is_type?('text', filename)
|
||
| 218 | end |
||
| 219 | 441:cbce1fd3b1b7 | Chris | |
| 220 | 0:513646585e45 | Chris | def is_diff? |
| 221 | self.filename =~ /\.(patch|diff)$/i |
||
| 222 | end |
||
| 223 | 441:cbce1fd3b1b7 | Chris | |
| 224 | 0:513646585e45 | Chris | # Returns true if the file is readable |
| 225 | def readable? |
||
| 226 | File.readable?(diskfile) |
||
| 227 | end |
||
| 228 | |||
| 229 | 1115:433d4f72a19b | Chris | # Returns the attachment token |
| 230 | def token |
||
| 231 | "#{id}.#{digest}"
|
||
| 232 | end |
||
| 233 | |||
| 234 | # Finds an attachment that matches the given token and that has no container |
||
| 235 | def self.find_by_token(token) |
||
| 236 | if token.to_s =~ /^(\d+)\.([0-9a-f]+)$/ |
||
| 237 | attachment_id, attachment_digest = $1, $2 |
||
| 238 | attachment = Attachment.where(:id => attachment_id, :digest => attachment_digest).first |
||
| 239 | if attachment && attachment.container.nil? |
||
| 240 | attachment |
||
| 241 | end |
||
| 242 | end |
||
| 243 | end |
||
| 244 | |||
| 245 | 0:513646585e45 | Chris | # Bulk attaches a set of files to an object |
| 246 | # |
||
| 247 | # Returns a Hash of the results: |
||
| 248 | # :files => array of the attached files |
||
| 249 | # :unsaved => array of the files that could not be attached |
||
| 250 | def self.attach_files(obj, attachments) |
||
| 251 | 1115:433d4f72a19b | Chris | result = obj.save_attachments(attachments, User.current) |
| 252 | obj.attach_saved_attachments |
||
| 253 | result |
||
| 254 | 0:513646585e45 | Chris | end |
| 255 | 441:cbce1fd3b1b7 | Chris | |
| 256 | 909:cbb26bc654de | Chris | def self.latest_attach(attachments, filename) |
| 257 | 1115:433d4f72a19b | Chris | attachments.sort_by(&:created_on).reverse.detect {
|
| 258 | 909:cbb26bc654de | Chris | |att| att.filename.downcase == filename.downcase |
| 259 | } |
||
| 260 | end |
||
| 261 | |||
| 262 | 1115:433d4f72a19b | Chris | def self.prune(age=1.day) |
| 263 | Attachment.where("created_on < ? AND (container_type IS NULL OR container_type = '')", Time.now - age).destroy_all
|
||
| 264 | end |
||
| 265 | |||
| 266 | 1464:261b3d9a4903 | Chris | # Moves an existing attachment to its target directory |
| 267 | def move_to_target_directory! |
||
| 268 | return unless !new_record? & readable? |
||
| 269 | |||
| 270 | src = diskfile |
||
| 271 | self.disk_directory = target_directory |
||
| 272 | dest = diskfile |
||
| 273 | |||
| 274 | return if src == dest |
||
| 275 | |||
| 276 | if !FileUtils.mkdir_p(File.dirname(dest)) |
||
| 277 | logger.error "Could not create directory #{File.dirname(dest)}" if logger
|
||
| 278 | return |
||
| 279 | end |
||
| 280 | |||
| 281 | if !FileUtils.mv(src, dest) |
||
| 282 | logger.error "Could not move attachment from #{src} to #{dest}" if logger
|
||
| 283 | return |
||
| 284 | end |
||
| 285 | |||
| 286 | update_column :disk_directory, disk_directory |
||
| 287 | end |
||
| 288 | |||
| 289 | # Moves existing attachments that are stored at the root of the files |
||
| 290 | # directory (ie. created before Redmine 2.3) to their target subdirectories |
||
| 291 | def self.move_from_root_to_target_directory |
||
| 292 | Attachment.where("disk_directory IS NULL OR disk_directory = ''").find_each do |attachment|
|
||
| 293 | attachment.move_to_target_directory! |
||
| 294 | end |
||
| 295 | end |
||
| 296 | |||
| 297 | 1115:433d4f72a19b | Chris | private |
| 298 | |||
| 299 | # Physically deletes the file from the file system |
||
| 300 | def delete_from_disk! |
||
| 301 | if disk_filename.present? && File.exist?(diskfile) |
||
| 302 | File.delete(diskfile) |
||
| 303 | end |
||
| 304 | end |
||
| 305 | |||
| 306 | 0:513646585e45 | Chris | def sanitize_filename(value) |
| 307 | # get only the filename, not the whole path |
||
| 308 | 1464:261b3d9a4903 | Chris | just_filename = value.gsub(/\A.*(\\|\/)/m, '') |
| 309 | 0:513646585e45 | Chris | |
| 310 | 909:cbb26bc654de | Chris | # Finally, replace invalid characters with underscore |
| 311 | 1464:261b3d9a4903 | Chris | @filename = just_filename.gsub(/[\/\?\%\*\:\|\"\'<>\n\r]+/, '_') |
| 312 | 0:513646585e45 | Chris | end |
| 313 | 441:cbce1fd3b1b7 | Chris | |
| 314 | 1464:261b3d9a4903 | Chris | # Returns the subdirectory in which the attachment will be saved |
| 315 | def target_directory |
||
| 316 | time = created_on || DateTime.now |
||
| 317 | time.strftime("%Y/%m")
|
||
| 318 | end |
||
| 319 | |||
| 320 | # Returns an ASCII or hashed filename that do not |
||
| 321 | # exists yet in the given subdirectory |
||
| 322 | def self.disk_filename(filename, directory=nil) |
||
| 323 | 0:513646585e45 | Chris | timestamp = DateTime.now.strftime("%y%m%d%H%M%S")
|
| 324 | ascii = '' |
||
| 325 | if filename =~ %r{^[a-zA-Z0-9_\.\-]*$}
|
||
| 326 | ascii = filename |
||
| 327 | else |
||
| 328 | ascii = Digest::MD5.hexdigest(filename) |
||
| 329 | # keep the extension if any |
||
| 330 | ascii << $1 if filename =~ %r{(\.[a-zA-Z0-9]+)$}
|
||
| 331 | end |
||
| 332 | 1464:261b3d9a4903 | Chris | while File.exist?(File.join(storage_path, directory.to_s, "#{timestamp}_#{ascii}"))
|
| 333 | 0:513646585e45 | Chris | timestamp.succ! |
| 334 | end |
||
| 335 | "#{timestamp}_#{ascii}"
|
||
| 336 | end |
||
| 337 | end |
||
| 338 | 909:cbb26bc654de | Chris | # Redmine - project management software |
| 339 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 340 | 0:513646585e45 | Chris | # |
| 341 | # This program is free software; you can redistribute it and/or |
||
| 342 | # modify it under the terms of the GNU General Public License |
||
| 343 | # as published by the Free Software Foundation; either version 2 |
||
| 344 | # of the License, or (at your option) any later version. |
||
| 345 | 909:cbb26bc654de | Chris | # |
| 346 | 0:513646585e45 | Chris | # This program is distributed in the hope that it will be useful, |
| 347 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 348 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 349 | # GNU General Public License for more details. |
||
| 350 | 909:cbb26bc654de | Chris | # |
| 351 | 0:513646585e45 | Chris | # You should have received a copy of the GNU General Public License |
| 352 | # along with this program; if not, write to the Free Software |
||
| 353 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 354 | |||
| 355 | 1115:433d4f72a19b | Chris | # Generic exception for when the AuthSource can not be reached |
| 356 | # (eg. can not connect to the LDAP) |
||
| 357 | class AuthSourceException < Exception; end |
||
| 358 | class AuthSourceTimeoutException < AuthSourceException; end |
||
| 359 | |||
| 360 | 0:513646585e45 | Chris | class AuthSource < ActiveRecord::Base |
| 361 | 1115:433d4f72a19b | Chris | include Redmine::SubclassFactory |
| 362 | 245:051f544170fe | Chris | include Redmine::Ciphering |
| 363 | 909:cbb26bc654de | Chris | |
| 364 | 0:513646585e45 | Chris | has_many :users |
| 365 | 909:cbb26bc654de | Chris | |
| 366 | 0:513646585e45 | Chris | validates_presence_of :name |
| 367 | validates_uniqueness_of :name |
||
| 368 | validates_length_of :name, :maximum => 60 |
||
| 369 | |||
| 370 | def authenticate(login, password) |
||
| 371 | end |
||
| 372 | 909:cbb26bc654de | Chris | |
| 373 | 0:513646585e45 | Chris | def test_connection |
| 374 | end |
||
| 375 | 909:cbb26bc654de | Chris | |
| 376 | 0:513646585e45 | Chris | def auth_method_name |
| 377 | "Abstract" |
||
| 378 | end |
||
| 379 | 909:cbb26bc654de | Chris | |
| 380 | 245:051f544170fe | Chris | def account_password |
| 381 | read_ciphered_attribute(:account_password) |
||
| 382 | end |
||
| 383 | 909:cbb26bc654de | Chris | |
| 384 | 245:051f544170fe | Chris | def account_password=(arg) |
| 385 | write_ciphered_attribute(:account_password, arg) |
||
| 386 | end |
||
| 387 | 0:513646585e45 | Chris | |
| 388 | 1464:261b3d9a4903 | Chris | def searchable? |
| 389 | false |
||
| 390 | end |
||
| 391 | |||
| 392 | def self.search(q) |
||
| 393 | results = [] |
||
| 394 | AuthSource.all.each do |source| |
||
| 395 | begin |
||
| 396 | if source.searchable? |
||
| 397 | results += source.search(q) |
||
| 398 | end |
||
| 399 | rescue AuthSourceException => e |
||
| 400 | logger.error "Error while searching users in #{source.name}: #{e.message}"
|
||
| 401 | end |
||
| 402 | end |
||
| 403 | results |
||
| 404 | end |
||
| 405 | |||
| 406 | 0:513646585e45 | Chris | def allow_password_changes? |
| 407 | self.class.allow_password_changes? |
||
| 408 | end |
||
| 409 | |||
| 410 | # Does this auth source backend allow password changes? |
||
| 411 | def self.allow_password_changes? |
||
| 412 | false |
||
| 413 | end |
||
| 414 | |||
| 415 | # Try to authenticate a user not yet registered against available sources |
||
| 416 | def self.authenticate(login, password) |
||
| 417 | 1517:dffacf8a6908 | Chris | AuthSource.where(:onthefly_register => true).each do |source| |
| 418 | 0:513646585e45 | Chris | begin |
| 419 | logger.debug "Authenticating '#{login}' against '#{source.name}'" if logger && logger.debug?
|
||
| 420 | attrs = source.authenticate(login, password) |
||
| 421 | rescue => e |
||
| 422 | logger.error "Error during authentication: #{e.message}"
|
||
| 423 | attrs = nil |
||
| 424 | end |
||
| 425 | return attrs if attrs |
||
| 426 | end |
||
| 427 | return nil |
||
| 428 | end |
||
| 429 | end |
||
| 430 | 909:cbb26bc654de | Chris | # Redmine - project management software |
| 431 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 432 | 0:513646585e45 | Chris | # |
| 433 | # This program is free software; you can redistribute it and/or |
||
| 434 | # modify it under the terms of the GNU General Public License |
||
| 435 | # as published by the Free Software Foundation; either version 2 |
||
| 436 | # of the License, or (at your option) any later version. |
||
| 437 | 909:cbb26bc654de | Chris | # |
| 438 | 0:513646585e45 | Chris | # This program is distributed in the hope that it will be useful, |
| 439 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 440 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 441 | # GNU General Public License for more details. |
||
| 442 | 909:cbb26bc654de | Chris | # |
| 443 | 0:513646585e45 | Chris | # You should have received a copy of the GNU General Public License |
| 444 | # along with this program; if not, write to the Free Software |
||
| 445 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 446 | |||
| 447 | require 'net/ldap' |
||
| 448 | 1115:433d4f72a19b | Chris | require 'net/ldap/dn' |
| 449 | require 'timeout' |
||
| 450 | 0:513646585e45 | Chris | |
| 451 | 909:cbb26bc654de | Chris | class AuthSourceLdap < AuthSource |
| 452 | 0:513646585e45 | Chris | validates_presence_of :host, :port, :attr_login |
| 453 | 245:051f544170fe | Chris | validates_length_of :name, :host, :maximum => 60, :allow_nil => true |
| 454 | 1115:433d4f72a19b | Chris | validates_length_of :account, :account_password, :base_dn, :filter, :maximum => 255, :allow_blank => true |
| 455 | 0:513646585e45 | Chris | validates_length_of :attr_login, :attr_firstname, :attr_lastname, :attr_mail, :maximum => 30, :allow_nil => true |
| 456 | validates_numericality_of :port, :only_integer => true |
||
| 457 | 1115:433d4f72a19b | Chris | validates_numericality_of :timeout, :only_integer => true, :allow_blank => true |
| 458 | validate :validate_filter |
||
| 459 | 909:cbb26bc654de | Chris | |
| 460 | 0:513646585e45 | Chris | before_validation :strip_ldap_attributes |
| 461 | 909:cbb26bc654de | Chris | |
| 462 | 1115:433d4f72a19b | Chris | def initialize(attributes=nil, *args) |
| 463 | super |
||
| 464 | 0:513646585e45 | Chris | self.port = 389 if self.port == 0 |
| 465 | end |
||
| 466 | 909:cbb26bc654de | Chris | |
| 467 | 0:513646585e45 | Chris | def authenticate(login, password) |
| 468 | return nil if login.blank? || password.blank? |
||
| 469 | 909:cbb26bc654de | Chris | |
| 470 | 1115:433d4f72a19b | Chris | with_timeout do |
| 471 | attrs = get_user_dn(login, password) |
||
| 472 | if attrs && attrs[:dn] && authenticate_dn(attrs[:dn], password) |
||
| 473 | logger.debug "Authentication successful for '#{login}'" if logger && logger.debug?
|
||
| 474 | return attrs.except(:dn) |
||
| 475 | end |
||
| 476 | 0:513646585e45 | Chris | end |
| 477 | 1115:433d4f72a19b | Chris | rescue Net::LDAP::LdapError => e |
| 478 | raise AuthSourceException.new(e.message) |
||
| 479 | 0:513646585e45 | Chris | end |
| 480 | |||
| 481 | # test the connection to the LDAP |
||
| 482 | def test_connection |
||
| 483 | 1115:433d4f72a19b | Chris | with_timeout do |
| 484 | ldap_con = initialize_ldap_con(self.account, self.account_password) |
||
| 485 | ldap_con.open { }
|
||
| 486 | end |
||
| 487 | rescue Net::LDAP::LdapError => e |
||
| 488 | raise AuthSourceException.new(e.message) |
||
| 489 | 0:513646585e45 | Chris | end |
| 490 | 909:cbb26bc654de | Chris | |
| 491 | 0:513646585e45 | Chris | def auth_method_name |
| 492 | "LDAP" |
||
| 493 | end |
||
| 494 | 909:cbb26bc654de | Chris | |
| 495 | 1464:261b3d9a4903 | Chris | # Returns true if this source can be searched for users |
| 496 | def searchable? |
||
| 497 | !account.to_s.include?("$login") && %w(login firstname lastname mail).all? {|a| send("attr_#{a}?")}
|
||
| 498 | end |
||
| 499 | |||
| 500 | # Searches the source for users and returns an array of results |
||
| 501 | def search(q) |
||
| 502 | q = q.to_s.strip |
||
| 503 | return [] unless searchable? && q.present? |
||
| 504 | |||
| 505 | results = [] |
||
| 506 | search_filter = base_filter & Net::LDAP::Filter.begins(self.attr_login, q) |
||
| 507 | ldap_con = initialize_ldap_con(self.account, self.account_password) |
||
| 508 | ldap_con.search(:base => self.base_dn, |
||
| 509 | :filter => search_filter, |
||
| 510 | :attributes => ['dn', self.attr_login, self.attr_firstname, self.attr_lastname, self.attr_mail], |
||
| 511 | :size => 10) do |entry| |
||
| 512 | attrs = get_user_attributes_from_ldap_entry(entry) |
||
| 513 | attrs[:login] = AuthSourceLdap.get_attr(entry, self.attr_login) |
||
| 514 | results << attrs |
||
| 515 | end |
||
| 516 | results |
||
| 517 | rescue Net::LDAP::LdapError => e |
||
| 518 | raise AuthSourceException.new(e.message) |
||
| 519 | end |
||
| 520 | |||
| 521 | 0:513646585e45 | Chris | private |
| 522 | 909:cbb26bc654de | Chris | |
| 523 | 1115:433d4f72a19b | Chris | def with_timeout(&block) |
| 524 | timeout = self.timeout |
||
| 525 | timeout = 20 unless timeout && timeout > 0 |
||
| 526 | Timeout.timeout(timeout) do |
||
| 527 | return yield |
||
| 528 | end |
||
| 529 | rescue Timeout::Error => e |
||
| 530 | raise AuthSourceTimeoutException.new(e.message) |
||
| 531 | end |
||
| 532 | |||
| 533 | def ldap_filter |
||
| 534 | if filter.present? |
||
| 535 | Net::LDAP::Filter.construct(filter) |
||
| 536 | end |
||
| 537 | rescue Net::LDAP::LdapError |
||
| 538 | nil |
||
| 539 | end |
||
| 540 | |||
| 541 | 1464:261b3d9a4903 | Chris | def base_filter |
| 542 | filter = Net::LDAP::Filter.eq("objectClass", "*")
|
||
| 543 | if f = ldap_filter |
||
| 544 | filter = filter & f |
||
| 545 | end |
||
| 546 | filter |
||
| 547 | end |
||
| 548 | |||
| 549 | 1115:433d4f72a19b | Chris | def validate_filter |
| 550 | if filter.present? && ldap_filter.nil? |
||
| 551 | errors.add(:filter, :invalid) |
||
| 552 | end |
||
| 553 | end |
||
| 554 | |||
| 555 | 0:513646585e45 | Chris | def strip_ldap_attributes |
| 556 | [:attr_login, :attr_firstname, :attr_lastname, :attr_mail].each do |attr| |
||
| 557 | write_attribute(attr, read_attribute(attr).strip) unless read_attribute(attr).nil? |
||
| 558 | end |
||
| 559 | end |
||
| 560 | 909:cbb26bc654de | Chris | |
| 561 | 0:513646585e45 | Chris | def initialize_ldap_con(ldap_user, ldap_password) |
| 562 | options = { :host => self.host,
|
||
| 563 | :port => self.port, |
||
| 564 | :encryption => (self.tls ? :simple_tls : nil) |
||
| 565 | } |
||
| 566 | options.merge!(:auth => { :method => :simple, :username => ldap_user, :password => ldap_password }) unless ldap_user.blank? && ldap_password.blank?
|
||
| 567 | Net::LDAP.new options |
||
| 568 | end |
||
| 569 | |||
| 570 | def get_user_attributes_from_ldap_entry(entry) |
||
| 571 | {
|
||
| 572 | :dn => entry.dn, |
||
| 573 | :firstname => AuthSourceLdap.get_attr(entry, self.attr_firstname), |
||
| 574 | :lastname => AuthSourceLdap.get_attr(entry, self.attr_lastname), |
||
| 575 | :mail => AuthSourceLdap.get_attr(entry, self.attr_mail), |
||
| 576 | :auth_source_id => self.id |
||
| 577 | } |
||
| 578 | end |
||
| 579 | |||
| 580 | # Return the attributes needed for the LDAP search. It will only |
||
| 581 | # include the user attributes if on-the-fly registration is enabled |
||
| 582 | def search_attributes |
||
| 583 | if onthefly_register? |
||
| 584 | ['dn', self.attr_firstname, self.attr_lastname, self.attr_mail] |
||
| 585 | else |
||
| 586 | ['dn'] |
||
| 587 | end |
||
| 588 | end |
||
| 589 | |||
| 590 | # Check if a DN (user record) authenticates with the password |
||
| 591 | def authenticate_dn(dn, password) |
||
| 592 | if dn.present? && password.present? |
||
| 593 | initialize_ldap_con(dn, password).bind |
||
| 594 | end |
||
| 595 | end |
||
| 596 | |||
| 597 | # Get the user's dn and any attributes for them, given their login |
||
| 598 | 1115:433d4f72a19b | Chris | def get_user_dn(login, password) |
| 599 | ldap_con = nil |
||
| 600 | if self.account && self.account.include?("$login")
|
||
| 601 | ldap_con = initialize_ldap_con(self.account.sub("$login", Net::LDAP::DN.escape(login)), password)
|
||
| 602 | else |
||
| 603 | ldap_con = initialize_ldap_con(self.account, self.account_password) |
||
| 604 | end |
||
| 605 | 0:513646585e45 | Chris | attrs = {}
|
| 606 | 1464:261b3d9a4903 | Chris | search_filter = base_filter & Net::LDAP::Filter.eq(self.attr_login, login) |
| 607 | 1115:433d4f72a19b | Chris | |
| 608 | 909:cbb26bc654de | Chris | ldap_con.search( :base => self.base_dn, |
| 609 | 1115:433d4f72a19b | Chris | :filter => search_filter, |
| 610 | 0:513646585e45 | Chris | :attributes=> search_attributes) do |entry| |
| 611 | |||
| 612 | if onthefly_register? |
||
| 613 | attrs = get_user_attributes_from_ldap_entry(entry) |
||
| 614 | else |
||
| 615 | attrs = {:dn => entry.dn}
|
||
| 616 | end |
||
| 617 | |||
| 618 | logger.debug "DN found for #{login}: #{attrs[:dn]}" if logger && logger.debug?
|
||
| 619 | end |
||
| 620 | |||
| 621 | attrs |
||
| 622 | end |
||
| 623 | 909:cbb26bc654de | Chris | |
| 624 | 0:513646585e45 | Chris | def self.get_attr(entry, attr_name) |
| 625 | if !attr_name.blank? |
||
| 626 | entry[attr_name].is_a?(Array) ? entry[attr_name].first : entry[attr_name] |
||
| 627 | end |
||
| 628 | end |
||
| 629 | end |
||
| 630 | 909:cbb26bc654de | Chris | # Redmine - project management software |
| 631 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 632 | 0:513646585e45 | Chris | # |
| 633 | # This program is free software; you can redistribute it and/or |
||
| 634 | # modify it under the terms of the GNU General Public License |
||
| 635 | # as published by the Free Software Foundation; either version 2 |
||
| 636 | # of the License, or (at your option) any later version. |
||
| 637 | 909:cbb26bc654de | Chris | # |
| 638 | 0:513646585e45 | Chris | # This program is distributed in the hope that it will be useful, |
| 639 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 640 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 641 | # GNU General Public License for more details. |
||
| 642 | 909:cbb26bc654de | Chris | # |
| 643 | 0:513646585e45 | Chris | # You should have received a copy of the GNU General Public License |
| 644 | # along with this program; if not, write to the Free Software |
||
| 645 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 646 | |||
| 647 | class Board < ActiveRecord::Base |
||
| 648 | 929:5f33065ddc4b | Chris | include Redmine::SafeAttributes |
| 649 | 0:513646585e45 | Chris | belongs_to :project |
| 650 | has_many :topics, :class_name => 'Message', :conditions => "#{Message.table_name}.parent_id IS NULL", :order => "#{Message.table_name}.created_on DESC"
|
||
| 651 | 119:8661b858af72 | Chris | has_many :messages, :dependent => :destroy, :order => "#{Message.table_name}.created_on DESC"
|
| 652 | 0:513646585e45 | Chris | belongs_to :last_message, :class_name => 'Message', :foreign_key => :last_message_id |
| 653 | 1115:433d4f72a19b | Chris | acts_as_tree :dependent => :nullify |
| 654 | acts_as_list :scope => '(project_id = #{project_id} AND parent_id #{parent_id ? "= #{parent_id}" : "IS NULL"})'
|
||
| 655 | 0:513646585e45 | Chris | acts_as_watchable |
| 656 | 909:cbb26bc654de | Chris | |
| 657 | 0:513646585e45 | Chris | validates_presence_of :name, :description |
| 658 | validates_length_of :name, :maximum => 30 |
||
| 659 | validates_length_of :description, :maximum => 255 |
||
| 660 | 1115:433d4f72a19b | Chris | validate :validate_board |
| 661 | 909:cbb26bc654de | Chris | |
| 662 | 1464:261b3d9a4903 | Chris | scope :visible, lambda {|*args|
|
| 663 | includes(:project).where(Project.allowed_to_condition(args.shift || User.current, :view_messages, *args)) |
||
| 664 | } |
||
| 665 | 909:cbb26bc654de | Chris | |
| 666 | 1115:433d4f72a19b | Chris | safe_attributes 'name', 'description', 'parent_id', 'move_to' |
| 667 | 929:5f33065ddc4b | Chris | |
| 668 | 0:513646585e45 | Chris | def visible?(user=User.current) |
| 669 | !user.nil? && user.allowed_to?(:view_messages, project) |
||
| 670 | end |
||
| 671 | 909:cbb26bc654de | Chris | |
| 672 | 1115:433d4f72a19b | Chris | def reload(*args) |
| 673 | @valid_parents = nil |
||
| 674 | super |
||
| 675 | end |
||
| 676 | |||
| 677 | 0:513646585e45 | Chris | def to_s |
| 678 | name |
||
| 679 | end |
||
| 680 | 909:cbb26bc654de | Chris | |
| 681 | 1115:433d4f72a19b | Chris | def valid_parents |
| 682 | @valid_parents ||= project.boards - self_and_descendants |
||
| 683 | end |
||
| 684 | |||
| 685 | 0:513646585e45 | Chris | def reset_counters! |
| 686 | self.class.reset_counters!(id) |
||
| 687 | end |
||
| 688 | 909:cbb26bc654de | Chris | |
| 689 | 0:513646585e45 | Chris | # Updates topics_count, messages_count and last_message_id attributes for +board_id+ |
| 690 | def self.reset_counters!(board_id) |
||
| 691 | board_id = board_id.to_i |
||
| 692 | 1517:dffacf8a6908 | Chris | where(["id = ?", board_id]). |
| 693 | update_all("topics_count = (SELECT COUNT(*) FROM #{Message.table_name} WHERE board_id=#{board_id} AND parent_id IS NULL)," +
|
||
| 694 | 0:513646585e45 | Chris | " messages_count = (SELECT COUNT(*) FROM #{Message.table_name} WHERE board_id=#{board_id})," +
|
| 695 | 1517:dffacf8a6908 | Chris | " last_message_id = (SELECT MAX(id) FROM #{Message.table_name} WHERE board_id=#{board_id})")
|
| 696 | 0:513646585e45 | Chris | end |
| 697 | 1115:433d4f72a19b | Chris | |
| 698 | def self.board_tree(boards, parent_id=nil, level=0) |
||
| 699 | tree = [] |
||
| 700 | boards.select {|board| board.parent_id == parent_id}.sort_by(&:position).each do |board|
|
||
| 701 | tree << [board, level] |
||
| 702 | tree += board_tree(boards, board.id, level+1) |
||
| 703 | end |
||
| 704 | if block_given? |
||
| 705 | tree.each do |board, level| |
||
| 706 | yield board, level |
||
| 707 | end |
||
| 708 | end |
||
| 709 | tree |
||
| 710 | end |
||
| 711 | |||
| 712 | protected |
||
| 713 | |||
| 714 | def validate_board |
||
| 715 | if parent_id && parent_id_changed? |
||
| 716 | errors.add(:parent_id, :invalid) unless valid_parents.include?(parent) |
||
| 717 | end |
||
| 718 | end |
||
| 719 | 0:513646585e45 | Chris | end |
| 720 | 441:cbce1fd3b1b7 | Chris | # Redmine - project management software |
| 721 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 722 | 0:513646585e45 | Chris | # |
| 723 | # This program is free software; you can redistribute it and/or |
||
| 724 | # modify it under the terms of the GNU General Public License |
||
| 725 | # as published by the Free Software Foundation; either version 2 |
||
| 726 | # of the License, or (at your option) any later version. |
||
| 727 | 441:cbce1fd3b1b7 | Chris | # |
| 728 | 0:513646585e45 | Chris | # This program is distributed in the hope that it will be useful, |
| 729 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 730 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 731 | # GNU General Public License for more details. |
||
| 732 | 441:cbce1fd3b1b7 | Chris | # |
| 733 | 0:513646585e45 | Chris | # You should have received a copy of the GNU General Public License |
| 734 | # along with this program; if not, write to the Free Software |
||
| 735 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 736 | |||
| 737 | class Change < ActiveRecord::Base |
||
| 738 | belongs_to :changeset |
||
| 739 | 441:cbce1fd3b1b7 | Chris | |
| 740 | 0:513646585e45 | Chris | validates_presence_of :changeset_id, :action, :path |
| 741 | 1:cca12e1c1fd4 | Chris | before_save :init_path |
| 742 | 909:cbb26bc654de | Chris | before_validation :replace_invalid_utf8_of_path |
| 743 | 441:cbce1fd3b1b7 | Chris | |
| 744 | 0:513646585e45 | Chris | def relative_path |
| 745 | changeset.repository.relative_path(path) |
||
| 746 | end |
||
| 747 | 441:cbce1fd3b1b7 | Chris | |
| 748 | 909:cbb26bc654de | Chris | def replace_invalid_utf8_of_path |
| 749 | 441:cbce1fd3b1b7 | Chris | self.path = Redmine::CodesetUtil.replace_invalid_utf8(self.path) |
| 750 | self.from_path = Redmine::CodesetUtil.replace_invalid_utf8(self.from_path) |
||
| 751 | end |
||
| 752 | |||
| 753 | 1:cca12e1c1fd4 | Chris | def init_path |
| 754 | self.path ||= "" |
||
| 755 | 0:513646585e45 | Chris | end |
| 756 | end |
||
| 757 | # Redmine - project management software |
||
| 758 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 759 | 0:513646585e45 | Chris | # |
| 760 | # This program is free software; you can redistribute it and/or |
||
| 761 | # modify it under the terms of the GNU General Public License |
||
| 762 | # as published by the Free Software Foundation; either version 2 |
||
| 763 | # of the License, or (at your option) any later version. |
||
| 764 | 441:cbce1fd3b1b7 | Chris | # |
| 765 | 0:513646585e45 | Chris | # This program is distributed in the hope that it will be useful, |
| 766 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 767 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 768 | # GNU General Public License for more details. |
||
| 769 | 441:cbce1fd3b1b7 | Chris | # |
| 770 | 0:513646585e45 | Chris | # You should have received a copy of the GNU General Public License |
| 771 | # along with this program; if not, write to the Free Software |
||
| 772 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 773 | |||
| 774 | class Changeset < ActiveRecord::Base |
||
| 775 | belongs_to :repository |
||
| 776 | belongs_to :user |
||
| 777 | 1115:433d4f72a19b | Chris | has_many :filechanges, :class_name => 'Change', :dependent => :delete_all |
| 778 | 0:513646585e45 | Chris | has_and_belongs_to_many :issues |
| 779 | 909:cbb26bc654de | Chris | has_and_belongs_to_many :parents, |
| 780 | :class_name => "Changeset", |
||
| 781 | :join_table => "#{table_name_prefix}changeset_parents#{table_name_suffix}",
|
||
| 782 | :association_foreign_key => 'parent_id', :foreign_key => 'changeset_id' |
||
| 783 | has_and_belongs_to_many :children, |
||
| 784 | :class_name => "Changeset", |
||
| 785 | :join_table => "#{table_name_prefix}changeset_parents#{table_name_suffix}",
|
||
| 786 | :association_foreign_key => 'changeset_id', :foreign_key => 'parent_id' |
||
| 787 | 0:513646585e45 | Chris | |
| 788 | 1115:433d4f72a19b | Chris | acts_as_event :title => Proc.new {|o| o.title},
|
| 789 | 0:513646585e45 | Chris | :description => :long_comments, |
| 790 | :datetime => :committed_on, |
||
| 791 | 1115:433d4f72a19b | Chris | :url => Proc.new {|o| {:controller => 'repositories', :action => 'revision', :id => o.repository.project, :repository_id => o.repository.identifier_param, :rev => o.identifier}}
|
| 792 | 441:cbce1fd3b1b7 | Chris | |
| 793 | 0:513646585e45 | Chris | acts_as_searchable :columns => 'comments', |
| 794 | :include => {:repository => :project},
|
||
| 795 | :project_key => "#{Repository.table_name}.project_id",
|
||
| 796 | :date_column => 'committed_on' |
||
| 797 | 441:cbce1fd3b1b7 | Chris | |
| 798 | 0:513646585e45 | Chris | acts_as_activity_provider :timestamp => "#{table_name}.committed_on",
|
| 799 | :author_key => :user_id, |
||
| 800 | :find_options => {:include => [:user, {:repository => :project}]}
|
||
| 801 | 441:cbce1fd3b1b7 | Chris | |
| 802 | 0:513646585e45 | Chris | validates_presence_of :repository_id, :revision, :committed_on, :commit_date |
| 803 | validates_uniqueness_of :revision, :scope => :repository_id |
||
| 804 | validates_uniqueness_of :scmid, :scope => :repository_id, :allow_nil => true |
||
| 805 | 441:cbce1fd3b1b7 | Chris | |
| 806 | 1464:261b3d9a4903 | Chris | scope :visible, lambda {|*args|
|
| 807 | includes(:repository => :project).where(Project.allowed_to_condition(args.shift || User.current, :view_changesets, *args)) |
||
| 808 | } |
||
| 809 | 441:cbce1fd3b1b7 | Chris | |
| 810 | 909:cbb26bc654de | Chris | after_create :scan_for_issues |
| 811 | before_create :before_create_cs |
||
| 812 | |||
| 813 | 0:513646585e45 | Chris | def revision=(r) |
| 814 | write_attribute :revision, (r.nil? ? nil : r.to_s) |
||
| 815 | end |
||
| 816 | 119:8661b858af72 | Chris | |
| 817 | # Returns the identifier of this changeset; depending on repository backends |
||
| 818 | def identifier |
||
| 819 | if repository.class.respond_to? :changeset_identifier |
||
| 820 | repository.class.changeset_identifier self |
||
| 821 | else |
||
| 822 | revision.to_s |
||
| 823 | end |
||
| 824 | end |
||
| 825 | 0:513646585e45 | Chris | |
| 826 | def committed_on=(date) |
||
| 827 | self.commit_date = date |
||
| 828 | super |
||
| 829 | end |
||
| 830 | 119:8661b858af72 | Chris | |
| 831 | # Returns the readable identifier |
||
| 832 | def format_identifier |
||
| 833 | if repository.class.respond_to? :format_changeset_identifier |
||
| 834 | repository.class.format_changeset_identifier self |
||
| 835 | else |
||
| 836 | identifier |
||
| 837 | end |
||
| 838 | end |
||
| 839 | 441:cbce1fd3b1b7 | Chris | |
| 840 | 0:513646585e45 | Chris | def project |
| 841 | repository.project |
||
| 842 | end |
||
| 843 | 441:cbce1fd3b1b7 | Chris | |
| 844 | 0:513646585e45 | Chris | def author |
| 845 | user || committer.to_s.split('<').first
|
||
| 846 | end |
||
| 847 | 441:cbce1fd3b1b7 | Chris | |
| 848 | 909:cbb26bc654de | Chris | def before_create_cs |
| 849 | 245:051f544170fe | Chris | self.committer = self.class.to_utf8(self.committer, repository.repo_log_encoding) |
| 850 | 441:cbce1fd3b1b7 | Chris | self.comments = self.class.normalize_comments( |
| 851 | self.comments, repository.repo_log_encoding) |
||
| 852 | 245:051f544170fe | Chris | self.user = repository.find_committer_user(self.committer) |
| 853 | 0:513646585e45 | Chris | end |
| 854 | 245:051f544170fe | Chris | |
| 855 | 909:cbb26bc654de | Chris | def scan_for_issues |
| 856 | 0:513646585e45 | Chris | scan_comment_for_issue_ids |
| 857 | end |
||
| 858 | 441:cbce1fd3b1b7 | Chris | |
| 859 | 119:8661b858af72 | Chris | TIMELOG_RE = / |
| 860 | ( |
||
| 861 | 245:051f544170fe | Chris | ((\d+)(h|hours?))((\d+)(m|min)?)? |
| 862 | | |
||
| 863 | ((\d+)(h|hours?|m|min)) |
||
| 864 | 119:8661b858af72 | Chris | | |
| 865 | (\d+):(\d+) |
||
| 866 | | |
||
| 867 | 245:051f544170fe | Chris | (\d+([\.,]\d+)?)h? |
| 868 | 119:8661b858af72 | Chris | ) |
| 869 | /x |
||
| 870 | 441:cbce1fd3b1b7 | Chris | |
| 871 | 0:513646585e45 | Chris | def scan_comment_for_issue_ids |
| 872 | return if comments.blank? |
||
| 873 | # keywords used to reference issues |
||
| 874 | ref_keywords = Setting.commit_ref_keywords.downcase.split(",").collect(&:strip)
|
||
| 875 | 119:8661b858af72 | Chris | ref_keywords_any = ref_keywords.delete('*')
|
| 876 | 0:513646585e45 | Chris | # keywords used to fix issues |
| 877 | 1464:261b3d9a4903 | Chris | fix_keywords = Setting.commit_update_keywords_array.map {|r| r['keywords']}.flatten.compact
|
| 878 | 441:cbce1fd3b1b7 | Chris | |
| 879 | 0:513646585e45 | Chris | kw_regexp = (ref_keywords + fix_keywords).collect{|kw| Regexp.escape(kw)}.join("|")
|
| 880 | 441:cbce1fd3b1b7 | Chris | |
| 881 | 0:513646585e45 | Chris | referenced_issues = [] |
| 882 | 441:cbce1fd3b1b7 | Chris | |
| 883 | 119:8661b858af72 | Chris | comments.scan(/([\s\(\[,-]|^)((#{kw_regexp})[\s:]+)?(#\d+(\s+@#{TIMELOG_RE})?([\s,;&]+#\d+(\s+@#{TIMELOG_RE})?)*)(?=[[:punct:]]|\s|<|$)/i) do |match|
|
| 884 | 1464:261b3d9a4903 | Chris | action, refs = match[2].to_s.downcase, match[3] |
| 885 | 119:8661b858af72 | Chris | next unless action.present? || ref_keywords_any |
| 886 | 441:cbce1fd3b1b7 | Chris | |
| 887 | 119:8661b858af72 | Chris | refs.scan(/#(\d+)(\s+@#{TIMELOG_RE})?/).each do |m|
|
| 888 | issue, hours = find_referenced_issue_by_id(m[0].to_i), m[2] |
||
| 889 | if issue |
||
| 890 | referenced_issues << issue |
||
| 891 | 1464:261b3d9a4903 | Chris | # Don't update issues or log time when importing old commits |
| 892 | unless repository.created_on && committed_on && committed_on < repository.created_on |
||
| 893 | fix_issue(issue, action) if fix_keywords.include?(action) |
||
| 894 | log_time(issue, hours) if hours && Setting.commit_logtime_enabled? |
||
| 895 | end |
||
| 896 | 0:513646585e45 | Chris | end |
| 897 | end |
||
| 898 | end |
||
| 899 | 441:cbce1fd3b1b7 | Chris | |
| 900 | 0:513646585e45 | Chris | referenced_issues.uniq! |
| 901 | self.issues = referenced_issues unless referenced_issues.empty? |
||
| 902 | end |
||
| 903 | 441:cbce1fd3b1b7 | Chris | |
| 904 | 0:513646585e45 | Chris | def short_comments |
| 905 | @short_comments || split_comments.first |
||
| 906 | end |
||
| 907 | 441:cbce1fd3b1b7 | Chris | |
| 908 | 0:513646585e45 | Chris | def long_comments |
| 909 | @long_comments || split_comments.last |
||
| 910 | end |
||
| 911 | 119:8661b858af72 | Chris | |
| 912 | 929:5f33065ddc4b | Chris | def text_tag(ref_project=nil) |
| 913 | 1494:e248c7af89ec | Chris | repo = "" |
| 914 | if repository && repository.identifier.present? |
||
| 915 | repo = "#{repository.identifier}|"
|
||
| 916 | end |
||
| 917 | 929:5f33065ddc4b | Chris | tag = if scmid? |
| 918 | 1494:e248c7af89ec | Chris | "commit:#{repo}#{scmid}"
|
| 919 | 119:8661b858af72 | Chris | else |
| 920 | 1494:e248c7af89ec | Chris | "#{repo}r#{revision}"
|
| 921 | 1115:433d4f72a19b | Chris | end |
| 922 | 929:5f33065ddc4b | Chris | if ref_project && project && ref_project != project |
| 923 | 1115:433d4f72a19b | Chris | tag = "#{project.identifier}:#{tag}"
|
| 924 | 929:5f33065ddc4b | Chris | end |
| 925 | tag |
||
| 926 | 119:8661b858af72 | Chris | end |
| 927 | 441:cbce1fd3b1b7 | Chris | |
| 928 | 1115:433d4f72a19b | Chris | # Returns the title used for the changeset in the activity/search results |
| 929 | def title |
||
| 930 | repo = (repository && repository.identifier.present?) ? " (#{repository.identifier})" : ''
|
||
| 931 | comm = short_comments.blank? ? '' : (': ' + short_comments)
|
||
| 932 | "#{l(:label_revision)} #{format_identifier}#{repo}#{comm}"
|
||
| 933 | end |
||
| 934 | |||
| 935 | 0:513646585e45 | Chris | # Returns the previous changeset |
| 936 | def previous |
||
| 937 | 1115:433d4f72a19b | Chris | @previous ||= Changeset.where(["id < ? AND repository_id = ?", id, repository_id]).order('id DESC').first
|
| 938 | 0:513646585e45 | Chris | end |
| 939 | |||
| 940 | # Returns the next changeset |
||
| 941 | def next |
||
| 942 | 1115:433d4f72a19b | Chris | @next ||= Changeset.where(["id > ? AND repository_id = ?", id, repository_id]).order('id ASC').first
|
| 943 | 0:513646585e45 | Chris | end |
| 944 | 441:cbce1fd3b1b7 | Chris | |
| 945 | 0:513646585e45 | Chris | # Creates a new Change from it's common parameters |
| 946 | def create_change(change) |
||
| 947 | 441:cbce1fd3b1b7 | Chris | Change.create(:changeset => self, |
| 948 | :action => change[:action], |
||
| 949 | :path => change[:path], |
||
| 950 | :from_path => change[:from_path], |
||
| 951 | 0:513646585e45 | Chris | :from_revision => change[:from_revision]) |
| 952 | end |
||
| 953 | 245:051f544170fe | Chris | |
| 954 | 119:8661b858af72 | Chris | # Finds an issue that can be referenced by the commit message |
| 955 | def find_referenced_issue_by_id(id) |
||
| 956 | return nil if id.blank? |
||
| 957 | 1517:dffacf8a6908 | Chris | issue = Issue.includes(:project).where(:id => id.to_i).first |
| 958 | 1115:433d4f72a19b | Chris | if Setting.commit_cross_project_ref? |
| 959 | # all issues can be referenced/fixed |
||
| 960 | elsif issue |
||
| 961 | # issue that belong to the repository project, a subproject or a parent project only |
||
| 962 | 441:cbce1fd3b1b7 | Chris | unless issue.project && |
| 963 | (project == issue.project || project.is_ancestor_of?(issue.project) || |
||
| 964 | project.is_descendant_of?(issue.project)) |
||
| 965 | 119:8661b858af72 | Chris | issue = nil |
| 966 | end |
||
| 967 | end |
||
| 968 | issue |
||
| 969 | end |
||
| 970 | 441:cbce1fd3b1b7 | Chris | |
| 971 | 1115:433d4f72a19b | Chris | private |
| 972 | |||
| 973 | 1464:261b3d9a4903 | Chris | # Updates the +issue+ according to +action+ |
| 974 | def fix_issue(issue, action) |
||
| 975 | 119:8661b858af72 | Chris | # the issue may have been updated by the closure of another one (eg. duplicate) |
| 976 | issue.reload |
||
| 977 | # don't change the status is the issue is closed |
||
| 978 | return if issue.status && issue.status.is_closed? |
||
| 979 | 441:cbce1fd3b1b7 | Chris | |
| 980 | 1464:261b3d9a4903 | Chris | journal = issue.init_journal(user || User.anonymous, |
| 981 | ll(Setting.default_language, |
||
| 982 | :text_status_changed_by_changeset, |
||
| 983 | text_tag(issue.project))) |
||
| 984 | rule = Setting.commit_update_keywords_array.detect do |rule| |
||
| 985 | rule['keywords'].include?(action) && |
||
| 986 | (rule['if_tracker_id'].blank? || rule['if_tracker_id'] == issue.tracker_id.to_s) |
||
| 987 | end |
||
| 988 | if rule |
||
| 989 | issue.assign_attributes rule.slice(*Issue.attribute_names) |
||
| 990 | 119:8661b858af72 | Chris | end |
| 991 | Redmine::Hook.call_hook(:model_changeset_scan_commit_for_issue_ids_pre_issue_update, |
||
| 992 | 1464:261b3d9a4903 | Chris | { :changeset => self, :issue => issue, :action => action })
|
| 993 | 119:8661b858af72 | Chris | unless issue.save |
| 994 | logger.warn("Issue ##{issue.id} could not be saved by changeset #{id}: #{issue.errors.full_messages}") if logger
|
||
| 995 | end |
||
| 996 | issue |
||
| 997 | end |
||
| 998 | 441:cbce1fd3b1b7 | Chris | |
| 999 | 119:8661b858af72 | Chris | def log_time(issue, hours) |
| 1000 | time_entry = TimeEntry.new( |
||
| 1001 | :user => user, |
||
| 1002 | :hours => hours, |
||
| 1003 | :issue => issue, |
||
| 1004 | :spent_on => commit_date, |
||
| 1005 | 929:5f33065ddc4b | Chris | :comments => l(:text_time_logged_by_changeset, :value => text_tag(issue.project), |
| 1006 | 441:cbce1fd3b1b7 | Chris | :locale => Setting.default_language) |
| 1007 | 119:8661b858af72 | Chris | ) |
| 1008 | time_entry.activity = log_time_activity unless log_time_activity.nil? |
||
| 1009 | 441:cbce1fd3b1b7 | Chris | |
| 1010 | 119:8661b858af72 | Chris | unless time_entry.save |
| 1011 | logger.warn("TimeEntry could not be created by changeset #{id}: #{time_entry.errors.full_messages}") if logger
|
||
| 1012 | end |
||
| 1013 | time_entry |
||
| 1014 | end |
||
| 1015 | 441:cbce1fd3b1b7 | Chris | |
| 1016 | 119:8661b858af72 | Chris | def log_time_activity |
| 1017 | if Setting.commit_logtime_activity_id.to_i > 0 |
||
| 1018 | TimeEntryActivity.find_by_id(Setting.commit_logtime_activity_id.to_i) |
||
| 1019 | end |
||
| 1020 | 0:513646585e45 | Chris | end |
| 1021 | 441:cbce1fd3b1b7 | Chris | |
| 1022 | 0:513646585e45 | Chris | def split_comments |
| 1023 | comments =~ /\A(.+?)\r?\n(.*)$/m |
||
| 1024 | @short_comments = $1 || comments |
||
| 1025 | @long_comments = $2.to_s.strip |
||
| 1026 | return @short_comments, @long_comments |
||
| 1027 | end |
||
| 1028 | |||
| 1029 | 245:051f544170fe | Chris | public |
| 1030 | 119:8661b858af72 | Chris | |
| 1031 | 245:051f544170fe | Chris | # Strips and reencodes a commit log before insertion into the database |
| 1032 | def self.normalize_comments(str, encoding) |
||
| 1033 | Changeset.to_utf8(str.to_s.strip, encoding) |
||
| 1034 | end |
||
| 1035 | |||
| 1036 | def self.to_utf8(str, encoding) |
||
| 1037 | 909:cbb26bc654de | Chris | Redmine::CodesetUtil.to_utf8(str, encoding) |
| 1038 | 0:513646585e45 | Chris | end |
| 1039 | end |
||
| 1040 | 909:cbb26bc654de | Chris | # Redmine - project management software |
| 1041 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 1042 | 0:513646585e45 | Chris | # |
| 1043 | # This program is free software; you can redistribute it and/or |
||
| 1044 | # modify it under the terms of the GNU General Public License |
||
| 1045 | # as published by the Free Software Foundation; either version 2 |
||
| 1046 | # of the License, or (at your option) any later version. |
||
| 1047 | 909:cbb26bc654de | Chris | # |
| 1048 | 0:513646585e45 | Chris | # This program is distributed in the hope that it will be useful, |
| 1049 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 1050 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 1051 | # GNU General Public License for more details. |
||
| 1052 | 909:cbb26bc654de | Chris | # |
| 1053 | 0:513646585e45 | Chris | # You should have received a copy of the GNU General Public License |
| 1054 | # along with this program; if not, write to the Free Software |
||
| 1055 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 1056 | |||
| 1057 | class Comment < ActiveRecord::Base |
||
| 1058 | 929:5f33065ddc4b | Chris | include Redmine::SafeAttributes |
| 1059 | 0:513646585e45 | Chris | belongs_to :commented, :polymorphic => true, :counter_cache => true |
| 1060 | belongs_to :author, :class_name => 'User', :foreign_key => 'author_id' |
||
| 1061 | |||
| 1062 | validates_presence_of :commented, :author, :comments |
||
| 1063 | 929:5f33065ddc4b | Chris | |
| 1064 | 1464:261b3d9a4903 | Chris | after_create :send_notification |
| 1065 | |||
| 1066 | 929:5f33065ddc4b | Chris | safe_attributes 'comments' |
| 1067 | 1464:261b3d9a4903 | Chris | |
| 1068 | private |
||
| 1069 | |||
| 1070 | def send_notification |
||
| 1071 | mailer_method = "#{commented.class.name.underscore}_comment_added"
|
||
| 1072 | if Setting.notified_events.include?(mailer_method) |
||
| 1073 | Mailer.send(mailer_method, self).deliver |
||
| 1074 | end |
||
| 1075 | end |
||
| 1076 | 0:513646585e45 | Chris | end |
| 1077 | 441:cbce1fd3b1b7 | Chris | # Redmine - project management software |
| 1078 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 1079 | 0:513646585e45 | Chris | # |
| 1080 | # This program is free software; you can redistribute it and/or |
||
| 1081 | # modify it under the terms of the GNU General Public License |
||
| 1082 | # as published by the Free Software Foundation; either version 2 |
||
| 1083 | # of the License, or (at your option) any later version. |
||
| 1084 | 909:cbb26bc654de | Chris | # |
| 1085 | 0:513646585e45 | Chris | # This program is distributed in the hope that it will be useful, |
| 1086 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 1087 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 1088 | # GNU General Public License for more details. |
||
| 1089 | 909:cbb26bc654de | Chris | # |
| 1090 | 0:513646585e45 | Chris | # You should have received a copy of the GNU General Public License |
| 1091 | # along with this program; if not, write to the Free Software |
||
| 1092 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 1093 | |||
| 1094 | class CustomField < ActiveRecord::Base |
||
| 1095 | 1115:433d4f72a19b | Chris | include Redmine::SubclassFactory |
| 1096 | |||
| 1097 | 0:513646585e45 | Chris | has_many :custom_values, :dependent => :delete_all |
| 1098 | 1464:261b3d9a4903 | Chris | has_and_belongs_to_many :roles, :join_table => "#{table_name_prefix}custom_fields_roles#{table_name_suffix}", :foreign_key => "custom_field_id"
|
| 1099 | 0:513646585e45 | Chris | acts_as_list :scope => 'type = \'#{self.class}\''
|
| 1100 | serialize :possible_values |
||
| 1101 | 1517:dffacf8a6908 | Chris | store :format_store |
| 1102 | 909:cbb26bc654de | Chris | |
| 1103 | 0:513646585e45 | Chris | validates_presence_of :name, :field_format |
| 1104 | validates_uniqueness_of :name, :scope => :type |
||
| 1105 | validates_length_of :name, :maximum => 30 |
||
| 1106 | 1517:dffacf8a6908 | Chris | validates_inclusion_of :field_format, :in => Proc.new { Redmine::FieldFormat.available_formats }
|
| 1107 | 1464:261b3d9a4903 | Chris | validate :validate_custom_field |
| 1108 | 0:513646585e45 | Chris | |
| 1109 | 1115:433d4f72a19b | Chris | before_validation :set_searchable |
| 1110 | 1517:dffacf8a6908 | Chris | before_save do |field| |
| 1111 | field.format.before_custom_field_save(field) |
||
| 1112 | end |
||
| 1113 | 1464:261b3d9a4903 | Chris | after_save :handle_multiplicity_change |
| 1114 | after_save do |field| |
||
| 1115 | if field.visible_changed? && field.visible |
||
| 1116 | field.roles.clear |
||
| 1117 | end |
||
| 1118 | end |
||
| 1119 | 909:cbb26bc654de | Chris | |
| 1120 | 1464:261b3d9a4903 | Chris | scope :sorted, lambda { order("#{table_name}.position ASC") }
|
| 1121 | scope :visible, lambda {|*args|
|
||
| 1122 | user = args.shift || User.current |
||
| 1123 | if user.admin? |
||
| 1124 | # nop |
||
| 1125 | elsif user.memberships.any? |
||
| 1126 | where("#{table_name}.visible = ? OR #{table_name}.id IN (SELECT DISTINCT cfr.custom_field_id FROM #{Member.table_name} m" +
|
||
| 1127 | " INNER JOIN #{MemberRole.table_name} mr ON mr.member_id = m.id" +
|
||
| 1128 | " INNER JOIN #{table_name_prefix}custom_fields_roles#{table_name_suffix} cfr ON cfr.role_id = mr.role_id" +
|
||
| 1129 | " WHERE m.user_id = ?)", |
||
| 1130 | true, user.id) |
||
| 1131 | else |
||
| 1132 | where(:visible => true) |
||
| 1133 | end |
||
| 1134 | } |
||
| 1135 | 1115:433d4f72a19b | Chris | |
| 1136 | 1464:261b3d9a4903 | Chris | def visible_by?(project, user=User.current) |
| 1137 | visible? || user.admin? |
||
| 1138 | end |
||
| 1139 | 1115:433d4f72a19b | Chris | |
| 1140 | 1517:dffacf8a6908 | Chris | def format |
| 1141 | @format ||= Redmine::FieldFormat.find(field_format) |
||
| 1142 | end |
||
| 1143 | |||
| 1144 | 1115:433d4f72a19b | Chris | def field_format=(arg) |
| 1145 | # cannot change format of a saved custom field |
||
| 1146 | 1517:dffacf8a6908 | Chris | if new_record? |
| 1147 | @format = nil |
||
| 1148 | super |
||
| 1149 | end |
||
| 1150 | 0:513646585e45 | Chris | end |
| 1151 | 909:cbb26bc654de | Chris | |
| 1152 | 1115:433d4f72a19b | Chris | def set_searchable |
| 1153 | 0:513646585e45 | Chris | # make sure these fields are not searchable |
| 1154 | 1517:dffacf8a6908 | Chris | self.searchable = false unless format.class.searchable_supported |
| 1155 | 1115:433d4f72a19b | Chris | # make sure only these fields can have multiple values |
| 1156 | 1517:dffacf8a6908 | Chris | self.multiple = false unless format.class.multiple_supported |
| 1157 | 0:513646585e45 | Chris | true |
| 1158 | end |
||
| 1159 | 909:cbb26bc654de | Chris | |
| 1160 | 1115:433d4f72a19b | Chris | def validate_custom_field |
| 1161 | 1517:dffacf8a6908 | Chris | format.validate_custom_field(self).each do |attribute, message| |
| 1162 | errors.add attribute, message |
||
| 1163 | 0:513646585e45 | Chris | end |
| 1164 | 909:cbb26bc654de | Chris | |
| 1165 | if regexp.present? |
||
| 1166 | begin |
||
| 1167 | Regexp.new(regexp) |
||
| 1168 | rescue |
||
| 1169 | errors.add(:regexp, :invalid) |
||
| 1170 | end |
||
| 1171 | end |
||
| 1172 | |||
| 1173 | 1517:dffacf8a6908 | Chris | if default_value.present? |
| 1174 | validate_field_value(default_value).each do |message| |
||
| 1175 | errors.add :default_value, message |
||
| 1176 | end |
||
| 1177 | 1115:433d4f72a19b | Chris | end |
| 1178 | 0:513646585e45 | Chris | end |
| 1179 | 909:cbb26bc654de | Chris | |
| 1180 | 1517:dffacf8a6908 | Chris | def possible_custom_value_options(custom_value) |
| 1181 | format.possible_custom_value_options(custom_value) |
||
| 1182 | end |
||
| 1183 | |||
| 1184 | def possible_values_options(object=nil) |
||
| 1185 | if object.is_a?(Array) |
||
| 1186 | object.map {|o| format.possible_values_options(self, o)}.reduce(:&) || []
|
||
| 1187 | 441:cbce1fd3b1b7 | Chris | else |
| 1188 | 1517:dffacf8a6908 | Chris | format.possible_values_options(self, object) || [] |
| 1189 | 441:cbce1fd3b1b7 | Chris | end |
| 1190 | end |
||
| 1191 | 909:cbb26bc654de | Chris | |
| 1192 | 1517:dffacf8a6908 | Chris | def possible_values |
| 1193 | values = read_attribute(:possible_values) |
||
| 1194 | if values.is_a?(Array) |
||
| 1195 | values.each do |value| |
||
| 1196 | value.force_encoding('UTF-8') if value.respond_to?(:force_encoding)
|
||
| 1197 | end |
||
| 1198 | values |
||
| 1199 | 441:cbce1fd3b1b7 | Chris | else |
| 1200 | 1517:dffacf8a6908 | Chris | [] |
| 1201 | 441:cbce1fd3b1b7 | Chris | end |
| 1202 | end |
||
| 1203 | 909:cbb26bc654de | Chris | |
| 1204 | 0:513646585e45 | Chris | # Makes possible_values accept a multiline string |
| 1205 | def possible_values=(arg) |
||
| 1206 | if arg.is_a?(Array) |
||
| 1207 | 1517:dffacf8a6908 | Chris | values = arg.compact.collect(&:strip).select {|v| !v.blank?}
|
| 1208 | write_attribute(:possible_values, values) |
||
| 1209 | 0:513646585e45 | Chris | else |
| 1210 | self.possible_values = arg.to_s.split(/[\n\r]+/) |
||
| 1211 | end |
||
| 1212 | end |
||
| 1213 | 909:cbb26bc654de | Chris | |
| 1214 | 0:513646585e45 | Chris | def cast_value(value) |
| 1215 | 1517:dffacf8a6908 | Chris | format.cast_value(self, value) |
| 1216 | 0:513646585e45 | Chris | end |
| 1217 | 909:cbb26bc654de | Chris | |
| 1218 | 1115:433d4f72a19b | Chris | def value_from_keyword(keyword, customized) |
| 1219 | possible_values_options = possible_values_options(customized) |
||
| 1220 | if possible_values_options.present? |
||
| 1221 | keyword = keyword.to_s.downcase |
||
| 1222 | if v = possible_values_options.detect {|text, id| text.downcase == keyword}
|
||
| 1223 | if v.is_a?(Array) |
||
| 1224 | v.last |
||
| 1225 | else |
||
| 1226 | v |
||
| 1227 | end |
||
| 1228 | end |
||
| 1229 | else |
||
| 1230 | keyword |
||
| 1231 | end |
||
| 1232 | end |
||
| 1233 | 1464:261b3d9a4903 | Chris | |
| 1234 | 0:513646585e45 | Chris | # Returns a ORDER BY clause that can used to sort customized |
| 1235 | # objects by their value of the custom field. |
||
| 1236 | 1115:433d4f72a19b | Chris | # Returns nil if the custom field can not be used for sorting. |
| 1237 | 0:513646585e45 | Chris | def order_statement |
| 1238 | 1115:433d4f72a19b | Chris | return nil if multiple? |
| 1239 | 1517:dffacf8a6908 | Chris | format.order_statement(self) |
| 1240 | 0:513646585e45 | Chris | end |
| 1241 | |||
| 1242 | 1115:433d4f72a19b | Chris | # Returns a GROUP BY clause that can used to group by custom value |
| 1243 | # Returns nil if the custom field can not be used for grouping. |
||
| 1244 | 1464:261b3d9a4903 | Chris | def group_statement |
| 1245 | 1115:433d4f72a19b | Chris | return nil if multiple? |
| 1246 | 1517:dffacf8a6908 | Chris | format.group_statement(self) |
| 1247 | 1115:433d4f72a19b | Chris | end |
| 1248 | |||
| 1249 | def join_for_order_statement |
||
| 1250 | 1517:dffacf8a6908 | Chris | format.join_for_order_statement(self) |
| 1251 | 1115:433d4f72a19b | Chris | end |
| 1252 | |||
| 1253 | 1517:dffacf8a6908 | Chris | def visibility_by_project_condition(project_key=nil, user=User.current, id_column=nil) |
| 1254 | 1464:261b3d9a4903 | Chris | if visible? || user.admin? |
| 1255 | "1=1" |
||
| 1256 | elsif user.anonymous? |
||
| 1257 | "1=0" |
||
| 1258 | else |
||
| 1259 | project_key ||= "#{self.class.customized_class.table_name}.project_id"
|
||
| 1260 | 1517:dffacf8a6908 | Chris | id_column ||= id |
| 1261 | 1464:261b3d9a4903 | Chris | "#{project_key} IN (SELECT DISTINCT m.project_id FROM #{Member.table_name} m" +
|
| 1262 | " INNER JOIN #{MemberRole.table_name} mr ON mr.member_id = m.id" +
|
||
| 1263 | " INNER JOIN #{table_name_prefix}custom_fields_roles#{table_name_suffix} cfr ON cfr.role_id = mr.role_id" +
|
||
| 1264 | 1517:dffacf8a6908 | Chris | " WHERE m.user_id = #{user.id} AND cfr.custom_field_id = #{id_column})"
|
| 1265 | 1464:261b3d9a4903 | Chris | end |
| 1266 | end |
||
| 1267 | |||
| 1268 | def self.visibility_condition |
||
| 1269 | if user.admin? |
||
| 1270 | "1=1" |
||
| 1271 | elsif user.anonymous? |
||
| 1272 | "#{table_name}.visible"
|
||
| 1273 | else |
||
| 1274 | "#{project_key} IN (SELECT DISTINCT m.project_id FROM #{Member.table_name} m" +
|
||
| 1275 | " INNER JOIN #{MemberRole.table_name} mr ON mr.member_id = m.id" +
|
||
| 1276 | " INNER JOIN #{table_name_prefix}custom_fields_roles#{table_name_suffix} cfr ON cfr.role_id = mr.role_id" +
|
||
| 1277 | " WHERE m.user_id = #{user.id} AND cfr.custom_field_id = #{id})"
|
||
| 1278 | end |
||
| 1279 | end |
||
| 1280 | |||
| 1281 | 0:513646585e45 | Chris | def <=>(field) |
| 1282 | position <=> field.position |
||
| 1283 | end |
||
| 1284 | 909:cbb26bc654de | Chris | |
| 1285 | 1115:433d4f72a19b | Chris | # Returns the class that values represent |
| 1286 | def value_class |
||
| 1287 | 1517:dffacf8a6908 | Chris | format.target_class if format.respond_to?(:target_class) |
| 1288 | 1115:433d4f72a19b | Chris | end |
| 1289 | |||
| 1290 | 0:513646585e45 | Chris | def self.customized_class |
| 1291 | self.name =~ /^(.+)CustomField$/ |
||
| 1292 | 1464:261b3d9a4903 | Chris | $1.constantize rescue nil |
| 1293 | 0:513646585e45 | Chris | end |
| 1294 | 909:cbb26bc654de | Chris | |
| 1295 | 0:513646585e45 | Chris | # to move in project_custom_field |
| 1296 | def self.for_all |
||
| 1297 | 1464:261b3d9a4903 | Chris | where(:is_for_all => true).order('position').all
|
| 1298 | 0:513646585e45 | Chris | end |
| 1299 | 909:cbb26bc654de | Chris | |
| 1300 | 0:513646585e45 | Chris | def type_name |
| 1301 | nil |
||
| 1302 | end |
||
| 1303 | 1115:433d4f72a19b | Chris | |
| 1304 | # Returns the error messages for the given value |
||
| 1305 | # or an empty array if value is a valid value for the custom field |
||
| 1306 | 1517:dffacf8a6908 | Chris | def validate_custom_value(custom_value) |
| 1307 | value = custom_value.value |
||
| 1308 | 1115:433d4f72a19b | Chris | errs = [] |
| 1309 | if value.is_a?(Array) |
||
| 1310 | if !multiple? |
||
| 1311 | errs << ::I18n.t('activerecord.errors.messages.invalid')
|
||
| 1312 | end |
||
| 1313 | if is_required? && value.detect(&:present?).nil? |
||
| 1314 | errs << ::I18n.t('activerecord.errors.messages.blank')
|
||
| 1315 | end |
||
| 1316 | else |
||
| 1317 | if is_required? && value.blank? |
||
| 1318 | errs << ::I18n.t('activerecord.errors.messages.blank')
|
||
| 1319 | end |
||
| 1320 | end |
||
| 1321 | 1517:dffacf8a6908 | Chris | errs += format.validate_custom_value(custom_value) |
| 1322 | 1115:433d4f72a19b | Chris | errs |
| 1323 | end |
||
| 1324 | |||
| 1325 | 1517:dffacf8a6908 | Chris | # Returns the error messages for the default custom field value |
| 1326 | def validate_field_value(value) |
||
| 1327 | validate_custom_value(CustomValue.new(:custom_field => self, :value => value)) |
||
| 1328 | end |
||
| 1329 | |||
| 1330 | 1115:433d4f72a19b | Chris | # Returns true if value is a valid value for the custom field |
| 1331 | def valid_field_value?(value) |
||
| 1332 | validate_field_value(value).empty? |
||
| 1333 | end |
||
| 1334 | |||
| 1335 | def format_in?(*args) |
||
| 1336 | args.include?(field_format) |
||
| 1337 | end |
||
| 1338 | |||
| 1339 | protected |
||
| 1340 | |||
| 1341 | 1464:261b3d9a4903 | Chris | # Removes multiple values for the custom field after setting the multiple attribute to false |
| 1342 | # We kepp the value with the highest id for each customized object |
||
| 1343 | def handle_multiplicity_change |
||
| 1344 | if !new_record? && multiple_was && !multiple |
||
| 1345 | ids = custom_values. |
||
| 1346 | where("EXISTS(SELECT 1 FROM #{CustomValue.table_name} cve WHERE cve.custom_field_id = #{CustomValue.table_name}.custom_field_id" +
|
||
| 1347 | " AND cve.customized_type = #{CustomValue.table_name}.customized_type AND cve.customized_id = #{CustomValue.table_name}.customized_id" +
|
||
| 1348 | " AND cve.id > #{CustomValue.table_name}.id)").
|
||
| 1349 | pluck(:id) |
||
| 1350 | |||
| 1351 | if ids.any? |
||
| 1352 | custom_values.where(:id => ids).delete_all |
||
| 1353 | end |
||
| 1354 | end |
||
| 1355 | end |
||
| 1356 | 0:513646585e45 | Chris | end |
| 1357 | 1517:dffacf8a6908 | Chris | |
| 1358 | require_dependency 'redmine/field_format' |
||
| 1359 | 1115:433d4f72a19b | Chris | # Redmine - project management software |
| 1360 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 1361 | 1115:433d4f72a19b | Chris | # |
| 1362 | # This program is free software; you can redistribute it and/or |
||
| 1363 | # modify it under the terms of the GNU General Public License |
||
| 1364 | # as published by the Free Software Foundation; either version 2 |
||
| 1365 | # of the License, or (at your option) any later version. |
||
| 1366 | # |
||
| 1367 | # This program is distributed in the hope that it will be useful, |
||
| 1368 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 1369 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 1370 | # GNU General Public License for more details. |
||
| 1371 | # |
||
| 1372 | # You should have received a copy of the GNU General Public License |
||
| 1373 | # along with this program; if not, write to the Free Software |
||
| 1374 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 1375 | |||
| 1376 | class CustomFieldValue |
||
| 1377 | 1517:dffacf8a6908 | Chris | attr_accessor :custom_field, :customized, :value, :value_was |
| 1378 | |||
| 1379 | def initialize(attributes={})
|
||
| 1380 | attributes.each do |name, v| |
||
| 1381 | send "#{name}=", v
|
||
| 1382 | end |
||
| 1383 | end |
||
| 1384 | 1115:433d4f72a19b | Chris | |
| 1385 | def custom_field_id |
||
| 1386 | custom_field.id |
||
| 1387 | end |
||
| 1388 | |||
| 1389 | def true? |
||
| 1390 | self.value == '1' |
||
| 1391 | end |
||
| 1392 | |||
| 1393 | def editable? |
||
| 1394 | custom_field.editable? |
||
| 1395 | end |
||
| 1396 | |||
| 1397 | def visible? |
||
| 1398 | custom_field.visible? |
||
| 1399 | end |
||
| 1400 | |||
| 1401 | def required? |
||
| 1402 | custom_field.is_required? |
||
| 1403 | end |
||
| 1404 | |||
| 1405 | def to_s |
||
| 1406 | value.to_s |
||
| 1407 | end |
||
| 1408 | |||
| 1409 | def validate_value |
||
| 1410 | 1517:dffacf8a6908 | Chris | custom_field.validate_custom_value(self).each do |message| |
| 1411 | 1115:433d4f72a19b | Chris | customized.errors.add(:base, custom_field.name + ' ' + message) |
| 1412 | end |
||
| 1413 | end |
||
| 1414 | end |
||
| 1415 | 909:cbb26bc654de | Chris | # Redmine - project management software |
| 1416 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 1417 | 0:513646585e45 | Chris | # |
| 1418 | # This program is free software; you can redistribute it and/or |
||
| 1419 | # modify it under the terms of the GNU General Public License |
||
| 1420 | # as published by the Free Software Foundation; either version 2 |
||
| 1421 | # of the License, or (at your option) any later version. |
||
| 1422 | 909:cbb26bc654de | Chris | # |
| 1423 | 0:513646585e45 | Chris | # This program is distributed in the hope that it will be useful, |
| 1424 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 1425 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 1426 | # GNU General Public License for more details. |
||
| 1427 | 909:cbb26bc654de | Chris | # |
| 1428 | 0:513646585e45 | Chris | # You should have received a copy of the GNU General Public License |
| 1429 | # along with this program; if not, write to the Free Software |
||
| 1430 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 1431 | |||
| 1432 | class CustomValue < ActiveRecord::Base |
||
| 1433 | belongs_to :custom_field |
||
| 1434 | belongs_to :customized, :polymorphic => true |
||
| 1435 | |||
| 1436 | 1115:433d4f72a19b | Chris | def initialize(attributes=nil, *args) |
| 1437 | super |
||
| 1438 | 0:513646585e45 | Chris | if new_record? && custom_field && (customized_type.blank? || (customized && customized.new_record?)) |
| 1439 | self.value ||= custom_field.default_value |
||
| 1440 | end |
||
| 1441 | end |
||
| 1442 | 909:cbb26bc654de | Chris | |
| 1443 | 0:513646585e45 | Chris | # Returns true if the boolean custom value is true |
| 1444 | def true? |
||
| 1445 | self.value == '1' |
||
| 1446 | end |
||
| 1447 | 909:cbb26bc654de | Chris | |
| 1448 | 0:513646585e45 | Chris | def editable? |
| 1449 | custom_field.editable? |
||
| 1450 | end |
||
| 1451 | 909:cbb26bc654de | Chris | |
| 1452 | 37:94944d00e43c | chris | def visible? |
| 1453 | custom_field.visible? |
||
| 1454 | end |
||
| 1455 | 909:cbb26bc654de | Chris | |
| 1456 | 0:513646585e45 | Chris | def required? |
| 1457 | custom_field.is_required? |
||
| 1458 | end |
||
| 1459 | 909:cbb26bc654de | Chris | |
| 1460 | 0:513646585e45 | Chris | def to_s |
| 1461 | value.to_s |
||
| 1462 | end |
||
| 1463 | end |
||
| 1464 | 1115:433d4f72a19b | Chris | # Redmine - project management software |
| 1465 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 1466 | 0:513646585e45 | Chris | # |
| 1467 | # This program is free software; you can redistribute it and/or |
||
| 1468 | # modify it under the terms of the GNU General Public License |
||
| 1469 | # as published by the Free Software Foundation; either version 2 |
||
| 1470 | # of the License, or (at your option) any later version. |
||
| 1471 | 441:cbce1fd3b1b7 | Chris | # |
| 1472 | 0:513646585e45 | Chris | # This program is distributed in the hope that it will be useful, |
| 1473 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 1474 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 1475 | # GNU General Public License for more details. |
||
| 1476 | 441:cbce1fd3b1b7 | Chris | # |
| 1477 | 0:513646585e45 | Chris | # You should have received a copy of the GNU General Public License |
| 1478 | # along with this program; if not, write to the Free Software |
||
| 1479 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 1480 | |||
| 1481 | class Document < ActiveRecord::Base |
||
| 1482 | 929:5f33065ddc4b | Chris | include Redmine::SafeAttributes |
| 1483 | 0:513646585e45 | Chris | belongs_to :project |
| 1484 | belongs_to :category, :class_name => "DocumentCategory", :foreign_key => "category_id" |
||
| 1485 | 1464:261b3d9a4903 | Chris | acts_as_attachable :delete_permission => :delete_documents |
| 1486 | 0:513646585e45 | Chris | |
| 1487 | acts_as_searchable :columns => ['title', "#{table_name}.description"], :include => :project
|
||
| 1488 | acts_as_event :title => Proc.new {|o| "#{l(:label_document)}: #{o.title}"},
|
||
| 1489 | 1464:261b3d9a4903 | Chris | :author => Proc.new {|o| o.attachments.reorder("#{Attachment.table_name}.created_on ASC").first.try(:author) },
|
| 1490 | 0:513646585e45 | Chris | :url => Proc.new {|o| {:controller => 'documents', :action => 'show', :id => o.id}}
|
| 1491 | acts_as_activity_provider :find_options => {:include => :project}
|
||
| 1492 | 441:cbce1fd3b1b7 | Chris | |
| 1493 | 0:513646585e45 | Chris | validates_presence_of :project, :title, :category |
| 1494 | validates_length_of :title, :maximum => 60 |
||
| 1495 | 441:cbce1fd3b1b7 | Chris | |
| 1496 | 1464:261b3d9a4903 | Chris | after_create :send_notification |
| 1497 | |||
| 1498 | scope :visible, lambda {|*args|
|
||
| 1499 | includes(:project).where(Project.allowed_to_condition(args.shift || User.current, :view_documents, *args)) |
||
| 1500 | } |
||
| 1501 | 441:cbce1fd3b1b7 | Chris | |
| 1502 | 929:5f33065ddc4b | Chris | safe_attributes 'category_id', 'title', 'description' |
| 1503 | |||
| 1504 | 0:513646585e45 | Chris | def visible?(user=User.current) |
| 1505 | !user.nil? && user.allowed_to?(:view_documents, project) |
||
| 1506 | end |
||
| 1507 | 441:cbce1fd3b1b7 | Chris | |
| 1508 | 1115:433d4f72a19b | Chris | def initialize(attributes=nil, *args) |
| 1509 | super |
||
| 1510 | 0:513646585e45 | Chris | if new_record? |
| 1511 | self.category ||= DocumentCategory.default |
||
| 1512 | end |
||
| 1513 | end |
||
| 1514 | 441:cbce1fd3b1b7 | Chris | |
| 1515 | 0:513646585e45 | Chris | def updated_on |
| 1516 | unless @updated_on |
||
| 1517 | 1115:433d4f72a19b | Chris | a = attachments.last |
| 1518 | 0:513646585e45 | Chris | @updated_on = (a && a.created_on) || created_on |
| 1519 | end |
||
| 1520 | @updated_on |
||
| 1521 | end |
||
| 1522 | 1464:261b3d9a4903 | Chris | |
| 1523 | private |
||
| 1524 | |||
| 1525 | def send_notification |
||
| 1526 | if Setting.notified_events.include?('document_added')
|
||
| 1527 | Mailer.document_added(self).deliver |
||
| 1528 | end |
||
| 1529 | end |
||
| 1530 | 0:513646585e45 | Chris | end |
| 1531 | 441:cbce1fd3b1b7 | Chris | # Redmine - project management software |
| 1532 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 1533 | 0:513646585e45 | Chris | # |
| 1534 | # This program is free software; you can redistribute it and/or |
||
| 1535 | # modify it under the terms of the GNU General Public License |
||
| 1536 | # as published by the Free Software Foundation; either version 2 |
||
| 1537 | # of the License, or (at your option) any later version. |
||
| 1538 | 441:cbce1fd3b1b7 | Chris | # |
| 1539 | 0:513646585e45 | Chris | # This program is distributed in the hope that it will be useful, |
| 1540 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 1541 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 1542 | # GNU General Public License for more details. |
||
| 1543 | 441:cbce1fd3b1b7 | Chris | # |
| 1544 | 0:513646585e45 | Chris | # You should have received a copy of the GNU General Public License |
| 1545 | # along with this program; if not, write to the Free Software |
||
| 1546 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 1547 | |||
| 1548 | class DocumentCategory < Enumeration |
||
| 1549 | has_many :documents, :foreign_key => 'category_id' |
||
| 1550 | |||
| 1551 | OptionName = :enumeration_doc_categories |
||
| 1552 | |||
| 1553 | def option_name |
||
| 1554 | OptionName |
||
| 1555 | end |
||
| 1556 | |||
| 1557 | def objects_count |
||
| 1558 | documents.count |
||
| 1559 | end |
||
| 1560 | |||
| 1561 | def transfer_relations(to) |
||
| 1562 | documents.update_all("category_id = #{to.id}")
|
||
| 1563 | end |
||
| 1564 | 1115:433d4f72a19b | Chris | |
| 1565 | def self.default |
||
| 1566 | d = super |
||
| 1567 | d = first if d.nil? |
||
| 1568 | d |
||
| 1569 | end |
||
| 1570 | 0:513646585e45 | Chris | end |
| 1571 | 441:cbce1fd3b1b7 | Chris | # Redmine - project management software |
| 1572 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 1573 | 0:513646585e45 | Chris | # |
| 1574 | # This program is free software; you can redistribute it and/or |
||
| 1575 | # modify it under the terms of the GNU General Public License |
||
| 1576 | # as published by the Free Software Foundation; either version 2 |
||
| 1577 | # of the License, or (at your option) any later version. |
||
| 1578 | 441:cbce1fd3b1b7 | Chris | # |
| 1579 | 0:513646585e45 | Chris | # This program is distributed in the hope that it will be useful, |
| 1580 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 1581 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 1582 | # GNU General Public License for more details. |
||
| 1583 | 441:cbce1fd3b1b7 | Chris | # |
| 1584 | 0:513646585e45 | Chris | # You should have received a copy of the GNU General Public License |
| 1585 | # along with this program; if not, write to the Free Software |
||
| 1586 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 1587 | |||
| 1588 | class DocumentCategoryCustomField < CustomField |
||
| 1589 | def type_name |
||
| 1590 | :enumeration_doc_categories |
||
| 1591 | end |
||
| 1592 | end |
||
| 1593 | # Redmine - project management software |
||
| 1594 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 1595 | 0:513646585e45 | Chris | # |
| 1596 | # This program is free software; you can redistribute it and/or |
||
| 1597 | # modify it under the terms of the GNU General Public License |
||
| 1598 | # as published by the Free Software Foundation; either version 2 |
||
| 1599 | # of the License, or (at your option) any later version. |
||
| 1600 | 441:cbce1fd3b1b7 | Chris | # |
| 1601 | 0:513646585e45 | Chris | # This program is distributed in the hope that it will be useful, |
| 1602 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 1603 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 1604 | # GNU General Public License for more details. |
||
| 1605 | 441:cbce1fd3b1b7 | Chris | # |
| 1606 | 0:513646585e45 | Chris | # You should have received a copy of the GNU General Public License |
| 1607 | # along with this program; if not, write to the Free Software |
||
| 1608 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 1609 | |||
| 1610 | class EnabledModule < ActiveRecord::Base |
||
| 1611 | belongs_to :project |
||
| 1612 | 1517:dffacf8a6908 | Chris | acts_as_watchable |
| 1613 | 441:cbce1fd3b1b7 | Chris | |
| 1614 | 0:513646585e45 | Chris | validates_presence_of :name |
| 1615 | validates_uniqueness_of :name, :scope => :project_id |
||
| 1616 | 441:cbce1fd3b1b7 | Chris | |
| 1617 | 0:513646585e45 | Chris | after_create :module_enabled |
| 1618 | 441:cbce1fd3b1b7 | Chris | |
| 1619 | 0:513646585e45 | Chris | private |
| 1620 | 441:cbce1fd3b1b7 | Chris | |
| 1621 | 0:513646585e45 | Chris | # after_create callback used to do things when a module is enabled |
| 1622 | def module_enabled |
||
| 1623 | case name |
||
| 1624 | when 'wiki' |
||
| 1625 | # Create a wiki with a default start page |
||
| 1626 | if project && project.wiki.nil? |
||
| 1627 | Wiki.create(:project => project, :start_page => 'Wiki') |
||
| 1628 | end |
||
| 1629 | end |
||
| 1630 | end |
||
| 1631 | end |
||
| 1632 | 909:cbb26bc654de | Chris | # Redmine - project management software |
| 1633 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 1634 | 0:513646585e45 | Chris | # |
| 1635 | # This program is free software; you can redistribute it and/or |
||
| 1636 | # modify it under the terms of the GNU General Public License |
||
| 1637 | # as published by the Free Software Foundation; either version 2 |
||
| 1638 | # of the License, or (at your option) any later version. |
||
| 1639 | 909:cbb26bc654de | Chris | # |
| 1640 | 0:513646585e45 | Chris | # This program is distributed in the hope that it will be useful, |
| 1641 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 1642 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 1643 | # GNU General Public License for more details. |
||
| 1644 | 909:cbb26bc654de | Chris | # |
| 1645 | 0:513646585e45 | Chris | # You should have received a copy of the GNU General Public License |
| 1646 | # along with this program; if not, write to the Free Software |
||
| 1647 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 1648 | |||
| 1649 | class Enumeration < ActiveRecord::Base |
||
| 1650 | 1115:433d4f72a19b | Chris | include Redmine::SubclassFactory |
| 1651 | |||
| 1652 | 0:513646585e45 | Chris | default_scope :order => "#{Enumeration.table_name}.position ASC"
|
| 1653 | 909:cbb26bc654de | Chris | |
| 1654 | 0:513646585e45 | Chris | belongs_to :project |
| 1655 | 909:cbb26bc654de | Chris | |
| 1656 | 0:513646585e45 | Chris | acts_as_list :scope => 'type = \'#{type}\''
|
| 1657 | acts_as_customizable |
||
| 1658 | 1464:261b3d9a4903 | Chris | acts_as_tree :order => "#{Enumeration.table_name}.position ASC"
|
| 1659 | 0:513646585e45 | Chris | |
| 1660 | before_destroy :check_integrity |
||
| 1661 | 909:cbb26bc654de | Chris | before_save :check_default |
| 1662 | |||
| 1663 | 1115:433d4f72a19b | Chris | attr_protected :type |
| 1664 | |||
| 1665 | 0:513646585e45 | Chris | validates_presence_of :name |
| 1666 | validates_uniqueness_of :name, :scope => [:type, :project_id] |
||
| 1667 | validates_length_of :name, :maximum => 30 |
||
| 1668 | |||
| 1669 | 1464:261b3d9a4903 | Chris | scope :shared, lambda { where(:project_id => nil) }
|
| 1670 | scope :sorted, lambda { order("#{table_name}.position ASC") }
|
||
| 1671 | scope :active, lambda { where(:active => true) }
|
||
| 1672 | scope :system, lambda { where(:project_id => nil) }
|
||
| 1673 | 1115:433d4f72a19b | Chris | scope :named, lambda {|arg| where("LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip)}
|
| 1674 | 0:513646585e45 | Chris | |
| 1675 | def self.default |
||
| 1676 | # Creates a fake default scope so Enumeration.default will check |
||
| 1677 | # it's type. STI subclasses will automatically add their own |
||
| 1678 | # types to the finder. |
||
| 1679 | if self.descends_from_active_record? |
||
| 1680 | 1115:433d4f72a19b | Chris | where(:is_default => true, :type => 'Enumeration').first |
| 1681 | 0:513646585e45 | Chris | else |
| 1682 | # STI classes are |
||
| 1683 | 1115:433d4f72a19b | Chris | where(:is_default => true).first |
| 1684 | 0:513646585e45 | Chris | end |
| 1685 | end |
||
| 1686 | 909:cbb26bc654de | Chris | |
| 1687 | 0:513646585e45 | Chris | # Overloaded on concrete classes |
| 1688 | def option_name |
||
| 1689 | nil |
||
| 1690 | end |
||
| 1691 | |||
| 1692 | 909:cbb26bc654de | Chris | def check_default |
| 1693 | 0:513646585e45 | Chris | if is_default? && is_default_changed? |
| 1694 | 1517:dffacf8a6908 | Chris | Enumeration.where({:type => type}).update_all({:is_default => false})
|
| 1695 | 0:513646585e45 | Chris | end |
| 1696 | end |
||
| 1697 | 909:cbb26bc654de | Chris | |
| 1698 | 0:513646585e45 | Chris | # Overloaded on concrete classes |
| 1699 | def objects_count |
||
| 1700 | 0 |
||
| 1701 | end |
||
| 1702 | 909:cbb26bc654de | Chris | |
| 1703 | 0:513646585e45 | Chris | def in_use? |
| 1704 | self.objects_count != 0 |
||
| 1705 | end |
||
| 1706 | |||
| 1707 | # Is this enumeration overiding a system level enumeration? |
||
| 1708 | def is_override? |
||
| 1709 | !self.parent.nil? |
||
| 1710 | end |
||
| 1711 | 909:cbb26bc654de | Chris | |
| 1712 | 0:513646585e45 | Chris | alias :destroy_without_reassign :destroy |
| 1713 | 909:cbb26bc654de | Chris | |
| 1714 | 0:513646585e45 | Chris | # Destroy the enumeration |
| 1715 | # If a enumeration is specified, objects are reassigned |
||
| 1716 | def destroy(reassign_to = nil) |
||
| 1717 | if reassign_to && reassign_to.is_a?(Enumeration) |
||
| 1718 | self.transfer_relations(reassign_to) |
||
| 1719 | end |
||
| 1720 | destroy_without_reassign |
||
| 1721 | end |
||
| 1722 | 909:cbb26bc654de | Chris | |
| 1723 | 0:513646585e45 | Chris | def <=>(enumeration) |
| 1724 | position <=> enumeration.position |
||
| 1725 | end |
||
| 1726 | 909:cbb26bc654de | Chris | |
| 1727 | 0:513646585e45 | Chris | def to_s; name end |
| 1728 | |||
| 1729 | # Returns the Subclasses of Enumeration. Each Subclass needs to be |
||
| 1730 | # required in development mode. |
||
| 1731 | # |
||
| 1732 | # Note: subclasses is protected in ActiveRecord |
||
| 1733 | def self.get_subclasses |
||
| 1734 | 1115:433d4f72a19b | Chris | subclasses |
| 1735 | 0:513646585e45 | Chris | end |
| 1736 | |||
| 1737 | # Does the +new+ Hash override the previous Enumeration? |
||
| 1738 | def self.overridding_change?(new, previous) |
||
| 1739 | if (same_active_state?(new['active'], previous.active)) && same_custom_values?(new,previous) |
||
| 1740 | return false |
||
| 1741 | else |
||
| 1742 | return true |
||
| 1743 | end |
||
| 1744 | end |
||
| 1745 | |||
| 1746 | # Does the +new+ Hash have the same custom values as the previous Enumeration? |
||
| 1747 | def self.same_custom_values?(new, previous) |
||
| 1748 | previous.custom_field_values.each do |custom_value| |
||
| 1749 | if custom_value.value != new["custom_field_values"][custom_value.custom_field_id.to_s] |
||
| 1750 | return false |
||
| 1751 | end |
||
| 1752 | end |
||
| 1753 | |||
| 1754 | return true |
||
| 1755 | end |
||
| 1756 | 909:cbb26bc654de | Chris | |
| 1757 | 0:513646585e45 | Chris | # Are the new and previous fields equal? |
| 1758 | def self.same_active_state?(new, previous) |
||
| 1759 | new = (new == "1" ? true : false) |
||
| 1760 | return new == previous |
||
| 1761 | end |
||
| 1762 | 909:cbb26bc654de | Chris | |
| 1763 | 0:513646585e45 | Chris | private |
| 1764 | def check_integrity |
||
| 1765 | raise "Can't delete enumeration" if self.in_use? |
||
| 1766 | end |
||
| 1767 | |||
| 1768 | end |
||
| 1769 | |||
| 1770 | # Force load the subclasses in development mode |
||
| 1771 | require_dependency 'time_entry_activity' |
||
| 1772 | require_dependency 'document_category' |
||
| 1773 | require_dependency 'issue_priority' |
||
| 1774 | # Redmine - project management software |
||
| 1775 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 1776 | 0:513646585e45 | Chris | # |
| 1777 | # This program is free software; you can redistribute it and/or |
||
| 1778 | # modify it under the terms of the GNU General Public License |
||
| 1779 | # as published by the Free Software Foundation; either version 2 |
||
| 1780 | # of the License, or (at your option) any later version. |
||
| 1781 | 909:cbb26bc654de | Chris | # |
| 1782 | 0:513646585e45 | Chris | # This program is distributed in the hope that it will be useful, |
| 1783 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 1784 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 1785 | # GNU General Public License for more details. |
||
| 1786 | 909:cbb26bc654de | Chris | # |
| 1787 | 0:513646585e45 | Chris | # You should have received a copy of the GNU General Public License |
| 1788 | # along with this program; if not, write to the Free Software |
||
| 1789 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 1790 | |||
| 1791 | class Group < Principal |
||
| 1792 | 1115:433d4f72a19b | Chris | include Redmine::SafeAttributes |
| 1793 | |||
| 1794 | 1517:dffacf8a6908 | Chris | has_and_belongs_to_many :users, |
| 1795 | :join_table => "#{table_name_prefix}groups_users#{table_name_suffix}",
|
||
| 1796 | :after_add => :user_added, |
||
| 1797 | :after_remove => :user_removed |
||
| 1798 | 909:cbb26bc654de | Chris | |
| 1799 | 0:513646585e45 | Chris | acts_as_customizable |
| 1800 | 909:cbb26bc654de | Chris | |
| 1801 | 0:513646585e45 | Chris | validates_presence_of :lastname |
| 1802 | validates_uniqueness_of :lastname, :case_sensitive => false |
||
| 1803 | 1464:261b3d9a4903 | Chris | validates_length_of :lastname, :maximum => 255 |
| 1804 | 909:cbb26bc654de | Chris | |
| 1805 | before_destroy :remove_references_before_destroy |
||
| 1806 | |||
| 1807 | 1464:261b3d9a4903 | Chris | scope :sorted, lambda { order("#{table_name}.lastname ASC") }
|
| 1808 | scope :named, lambda {|arg| where("LOWER(#{table_name}.lastname) = LOWER(?)", arg.to_s.strip)}
|
||
| 1809 | 1115:433d4f72a19b | Chris | |
| 1810 | safe_attributes 'name', |
||
| 1811 | 'user_ids', |
||
| 1812 | 'custom_field_values', |
||
| 1813 | 'custom_fields', |
||
| 1814 | :if => lambda {|group, user| user.admin?}
|
||
| 1815 | |||
| 1816 | 0:513646585e45 | Chris | def to_s |
| 1817 | lastname.to_s |
||
| 1818 | end |
||
| 1819 | 909:cbb26bc654de | Chris | |
| 1820 | 1115:433d4f72a19b | Chris | def name |
| 1821 | lastname |
||
| 1822 | end |
||
| 1823 | |||
| 1824 | def name=(arg) |
||
| 1825 | self.lastname = arg |
||
| 1826 | end |
||
| 1827 | 909:cbb26bc654de | Chris | |
| 1828 | 0:513646585e45 | Chris | def user_added(user) |
| 1829 | members.each do |member| |
||
| 1830 | 119:8661b858af72 | Chris | next if member.project.nil? |
| 1831 | 0:513646585e45 | Chris | user_member = Member.find_by_project_id_and_user_id(member.project_id, user.id) || Member.new(:project_id => member.project_id, :user_id => user.id) |
| 1832 | member.member_roles.each do |member_role| |
||
| 1833 | user_member.member_roles << MemberRole.new(:role => member_role.role, :inherited_from => member_role.id) |
||
| 1834 | end |
||
| 1835 | user_member.save! |
||
| 1836 | end |
||
| 1837 | end |
||
| 1838 | 909:cbb26bc654de | Chris | |
| 1839 | 0:513646585e45 | Chris | def user_removed(user) |
| 1840 | members.each do |member| |
||
| 1841 | 1464:261b3d9a4903 | Chris | MemberRole. |
| 1842 | includes(:member). |
||
| 1843 | where("#{Member.table_name}.user_id = ? AND #{MemberRole.table_name}.inherited_from IN (?)", user.id, member.member_role_ids).
|
||
| 1844 | each(&:destroy) |
||
| 1845 | 0:513646585e45 | Chris | end |
| 1846 | end |
||
| 1847 | 909:cbb26bc654de | Chris | |
| 1848 | 1115:433d4f72a19b | Chris | def self.human_attribute_name(attribute_key_name, *args) |
| 1849 | attr_name = attribute_key_name.to_s |
||
| 1850 | 909:cbb26bc654de | Chris | if attr_name == 'lastname' |
| 1851 | attr_name = "name" |
||
| 1852 | end |
||
| 1853 | 1115:433d4f72a19b | Chris | super(attr_name, *args) |
| 1854 | 909:cbb26bc654de | Chris | end |
| 1855 | |||
| 1856 | private |
||
| 1857 | |||
| 1858 | # Removes references that are not handled by associations |
||
| 1859 | def remove_references_before_destroy |
||
| 1860 | return if self.id.nil? |
||
| 1861 | |||
| 1862 | 1517:dffacf8a6908 | Chris | Issue.where(['assigned_to_id = ?', id]).update_all('assigned_to_id = NULL')
|
| 1863 | 909:cbb26bc654de | Chris | end |
| 1864 | 0:513646585e45 | Chris | end |
| 1865 | # Redmine - project management software |
||
| 1866 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 1867 | 0:513646585e45 | Chris | # |
| 1868 | # This program is free software; you can redistribute it and/or |
||
| 1869 | # modify it under the terms of the GNU General Public License |
||
| 1870 | # as published by the Free Software Foundation; either version 2 |
||
| 1871 | # of the License, or (at your option) any later version. |
||
| 1872 | 909:cbb26bc654de | Chris | # |
| 1873 | 0:513646585e45 | Chris | # This program is distributed in the hope that it will be useful, |
| 1874 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 1875 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 1876 | # GNU General Public License for more details. |
||
| 1877 | 909:cbb26bc654de | Chris | # |
| 1878 | 0:513646585e45 | Chris | # You should have received a copy of the GNU General Public License |
| 1879 | # along with this program; if not, write to the Free Software |
||
| 1880 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 1881 | |||
| 1882 | class GroupCustomField < CustomField |
||
| 1883 | def type_name |
||
| 1884 | :label_group_plural |
||
| 1885 | end |
||
| 1886 | end |
||
| 1887 | 98:596803cb34fc | luis | class Institution < ActiveRecord::Base |
| 1888 | end |
||
| 1889 | 245:051f544170fe | Chris | # Redmine - project management software |
| 1890 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 1891 | 0:513646585e45 | Chris | # |
| 1892 | # This program is free software; you can redistribute it and/or |
||
| 1893 | # modify it under the terms of the GNU General Public License |
||
| 1894 | # as published by the Free Software Foundation; either version 2 |
||
| 1895 | # of the License, or (at your option) any later version. |
||
| 1896 | 441:cbce1fd3b1b7 | Chris | # |
| 1897 | 0:513646585e45 | Chris | # This program is distributed in the hope that it will be useful, |
| 1898 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 1899 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 1900 | # GNU General Public License for more details. |
||
| 1901 | 441:cbce1fd3b1b7 | Chris | # |
| 1902 | 0:513646585e45 | Chris | # You should have received a copy of the GNU General Public License |
| 1903 | # along with this program; if not, write to the Free Software |
||
| 1904 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 1905 | |||
| 1906 | class Issue < ActiveRecord::Base |
||
| 1907 | 117:af80e5618e9b | Chris | include Redmine::SafeAttributes |
| 1908 | 1115:433d4f72a19b | Chris | include Redmine::Utils::DateCalculation |
| 1909 | 1464:261b3d9a4903 | Chris | include Redmine::I18n |
| 1910 | 441:cbce1fd3b1b7 | Chris | |
| 1911 | 0:513646585e45 | Chris | belongs_to :project |
| 1912 | belongs_to :tracker |
||
| 1913 | belongs_to :status, :class_name => 'IssueStatus', :foreign_key => 'status_id' |
||
| 1914 | belongs_to :author, :class_name => 'User', :foreign_key => 'author_id' |
||
| 1915 | 909:cbb26bc654de | Chris | belongs_to :assigned_to, :class_name => 'Principal', :foreign_key => 'assigned_to_id' |
| 1916 | 0:513646585e45 | Chris | belongs_to :fixed_version, :class_name => 'Version', :foreign_key => 'fixed_version_id' |
| 1917 | belongs_to :priority, :class_name => 'IssuePriority', :foreign_key => 'priority_id' |
||
| 1918 | belongs_to :category, :class_name => 'IssueCategory', :foreign_key => 'category_id' |
||
| 1919 | |||
| 1920 | has_many :journals, :as => :journalized, :dependent => :destroy |
||
| 1921 | 1115:433d4f72a19b | Chris | has_many :visible_journals, |
| 1922 | :class_name => 'Journal', |
||
| 1923 | :as => :journalized, |
||
| 1924 | :conditions => Proc.new {
|
||
| 1925 | ["(#{Journal.table_name}.private_notes = ? OR (#{Project.allowed_to_condition(User.current, :view_private_notes)}))", false]
|
||
| 1926 | }, |
||
| 1927 | :readonly => true |
||
| 1928 | |||
| 1929 | 1517:dffacf8a6908 | Chris | has_many :time_entries, :dependent => :destroy |
| 1930 | 0:513646585e45 | Chris | has_and_belongs_to_many :changesets, :order => "#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC"
|
| 1931 | 441:cbce1fd3b1b7 | Chris | |
| 1932 | 0:513646585e45 | Chris | has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all |
| 1933 | has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all |
||
| 1934 | 441:cbce1fd3b1b7 | Chris | |
| 1935 | 210:0579821a129a | Chris | acts_as_nested_set :scope => 'root_id', :dependent => :destroy |
| 1936 | 909:cbb26bc654de | Chris | acts_as_attachable :after_add => :attachment_added, :after_remove => :attachment_removed |
| 1937 | 0:513646585e45 | Chris | acts_as_customizable |
| 1938 | acts_as_watchable |
||
| 1939 | acts_as_searchable :columns => ['subject', "#{table_name}.description", "#{Journal.table_name}.notes"],
|
||
| 1940 | 1115:433d4f72a19b | Chris | :include => [:project, :visible_journals], |
| 1941 | 0:513646585e45 | Chris | # sort by id so that limited eager loading doesn't break with postgresql |
| 1942 | :order_column => "#{table_name}.id"
|
||
| 1943 | acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
|
||
| 1944 | :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
|
||
| 1945 | :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
|
||
| 1946 | 441:cbce1fd3b1b7 | Chris | |
| 1947 | 0:513646585e45 | Chris | acts_as_activity_provider :find_options => {:include => [:project, :author, :tracker]},
|
| 1948 | :author_key => :author_id |
||
| 1949 | |||
| 1950 | DONE_RATIO_OPTIONS = %w(issue_field issue_status) |
||
| 1951 | |||
| 1952 | attr_reader :current_journal |
||
| 1953 | 1115:433d4f72a19b | Chris | delegate :notes, :notes=, :private_notes, :private_notes=, :to => :current_journal, :allow_nil => true |
| 1954 | 0:513646585e45 | Chris | |
| 1955 | validates_presence_of :subject, :priority, :project, :tracker, :author, :status |
||
| 1956 | |||
| 1957 | validates_length_of :subject, :maximum => 255 |
||
| 1958 | validates_inclusion_of :done_ratio, :in => 0..100 |
||
| 1959 | 1464:261b3d9a4903 | Chris | validates :estimated_hours, :numericality => {:greater_than_or_equal_to => 0, :allow_nil => true, :message => :invalid}
|
| 1960 | validates :start_date, :date => true |
||
| 1961 | validates :due_date, :date => true |
||
| 1962 | 1115:433d4f72a19b | Chris | validate :validate_issue, :validate_required_fields |
| 1963 | 0:513646585e45 | Chris | |
| 1964 | 1464:261b3d9a4903 | Chris | scope :visible, lambda {|*args|
|
| 1965 | includes(:project).where(Issue.visible_condition(args.shift || User.current, *args)) |
||
| 1966 | } |
||
| 1967 | 441:cbce1fd3b1b7 | Chris | |
| 1968 | 1115:433d4f72a19b | Chris | scope :open, lambda {|*args|
|
| 1969 | is_closed = args.size > 0 ? !args.first : false |
||
| 1970 | 1464:261b3d9a4903 | Chris | includes(:status).where("#{IssueStatus.table_name}.is_closed = ?", is_closed)
|
| 1971 | 22:40f7cfd4df19 | chris | } |
| 1972 | |||
| 1973 | 1464:261b3d9a4903 | Chris | scope :recently_updated, lambda { order("#{Issue.table_name}.updated_on DESC") }
|
| 1974 | scope :on_active_project, lambda {
|
||
| 1975 | includes(:status, :project, :tracker).where("#{Project.table_name}.status = ?", Project::STATUS_ACTIVE)
|
||
| 1976 | } |
||
| 1977 | scope :fixed_version, lambda {|versions|
|
||
| 1978 | ids = [versions].flatten.compact.map {|v| v.is_a?(Version) ? v.id : v}
|
||
| 1979 | ids.any? ? where(:fixed_version_id => ids) : where('1=0')
|
||
| 1980 | } |
||
| 1981 | 0:513646585e45 | Chris | |
| 1982 | before_create :default_assign |
||
| 1983 | 1464:261b3d9a4903 | Chris | before_save :close_duplicates, :update_done_ratio_from_issue_status, |
| 1984 | :force_updated_on_change, :update_closed_on, :set_assigned_to_was |
||
| 1985 | 1115:433d4f72a19b | Chris | after_save {|issue| issue.send :after_project_change if !issue.id_changed? && issue.project_id_changed?}
|
| 1986 | 1464:261b3d9a4903 | Chris | after_save :reschedule_following_issues, :update_nested_set_attributes, |
| 1987 | :update_parent_attributes, :create_journal |
||
| 1988 | 1115:433d4f72a19b | Chris | # Should be after_create but would be called before previous after_save callbacks |
| 1989 | after_save :after_create_from_copy |
||
| 1990 | 0:513646585e45 | Chris | after_destroy :update_parent_attributes |
| 1991 | 1464:261b3d9a4903 | Chris | after_create :send_notification |
| 1992 | # Keep it at the end of after_save callbacks |
||
| 1993 | after_save :clear_assigned_to_was |
||
| 1994 | 441:cbce1fd3b1b7 | Chris | |
| 1995 | # Returns a SQL conditions string used to find all issues visible by the specified user |
||
| 1996 | def self.visible_condition(user, options={})
|
||
| 1997 | Project.allowed_to_condition(user, :view_issues, options) do |role, user| |
||
| 1998 | 1115:433d4f72a19b | Chris | if user.logged? |
| 1999 | case role.issues_visibility |
||
| 2000 | when 'all' |
||
| 2001 | nil |
||
| 2002 | when 'default' |
||
| 2003 | 1464:261b3d9a4903 | Chris | user_ids = [user.id] + user.groups.map(&:id).compact |
| 2004 | 1115:433d4f72a19b | Chris | "(#{table_name}.is_private = #{connection.quoted_false} OR #{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
|
| 2005 | when 'own' |
||
| 2006 | 1464:261b3d9a4903 | Chris | user_ids = [user.id] + user.groups.map(&:id).compact |
| 2007 | 1115:433d4f72a19b | Chris | "(#{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
|
| 2008 | else |
||
| 2009 | '1=0' |
||
| 2010 | end |
||
| 2011 | 441:cbce1fd3b1b7 | Chris | else |
| 2012 | 1115:433d4f72a19b | Chris | "(#{table_name}.is_private = #{connection.quoted_false})"
|
| 2013 | 441:cbce1fd3b1b7 | Chris | end |
| 2014 | end |
||
| 2015 | end |
||
| 2016 | |||
| 2017 | 0:513646585e45 | Chris | # Returns true if usr or current user is allowed to view the issue |
| 2018 | def visible?(usr=nil) |
||
| 2019 | 441:cbce1fd3b1b7 | Chris | (usr || User.current).allowed_to?(:view_issues, self.project) do |role, user| |
| 2020 | 1115:433d4f72a19b | Chris | if user.logged? |
| 2021 | case role.issues_visibility |
||
| 2022 | when 'all' |
||
| 2023 | true |
||
| 2024 | when 'default' |
||
| 2025 | !self.is_private? || (self.author == user || user.is_or_belongs_to?(assigned_to)) |
||
| 2026 | when 'own' |
||
| 2027 | self.author == user || user.is_or_belongs_to?(assigned_to) |
||
| 2028 | else |
||
| 2029 | false |
||
| 2030 | end |
||
| 2031 | 441:cbce1fd3b1b7 | Chris | else |
| 2032 | 1115:433d4f72a19b | Chris | !self.is_private? |
| 2033 | 441:cbce1fd3b1b7 | Chris | end |
| 2034 | end |
||
| 2035 | 0:513646585e45 | Chris | end |
| 2036 | 441:cbce1fd3b1b7 | Chris | |
| 2037 | 1464:261b3d9a4903 | Chris | # Returns true if user or current user is allowed to edit or add a note to the issue |
| 2038 | def editable?(user=User.current) |
||
| 2039 | user.allowed_to?(:edit_issues, project) || user.allowed_to?(:add_issue_notes, project) |
||
| 2040 | end |
||
| 2041 | |||
| 2042 | 1115:433d4f72a19b | Chris | def initialize(attributes=nil, *args) |
| 2043 | super |
||
| 2044 | 0:513646585e45 | Chris | if new_record? |
| 2045 | # set default values for new records only |
||
| 2046 | self.status ||= IssueStatus.default |
||
| 2047 | self.priority ||= IssuePriority.default |
||
| 2048 | 1115:433d4f72a19b | Chris | self.watcher_user_ids = [] |
| 2049 | 0:513646585e45 | Chris | end |
| 2050 | end |
||
| 2051 | 441:cbce1fd3b1b7 | Chris | |
| 2052 | 1464:261b3d9a4903 | Chris | def create_or_update |
| 2053 | super |
||
| 2054 | ensure |
||
| 2055 | @status_was = nil |
||
| 2056 | end |
||
| 2057 | private :create_or_update |
||
| 2058 | |||
| 2059 | 1115:433d4f72a19b | Chris | # AR#Persistence#destroy would raise and RecordNotFound exception |
| 2060 | # if the issue was already deleted or updated (non matching lock_version). |
||
| 2061 | # This is a problem when bulk deleting issues or deleting a project |
||
| 2062 | # (because an issue may already be deleted if its parent was deleted |
||
| 2063 | # first). |
||
| 2064 | # The issue is reloaded by the nested_set before being deleted so |
||
| 2065 | # the lock_version condition should not be an issue but we handle it. |
||
| 2066 | def destroy |
||
| 2067 | super |
||
| 2068 | rescue ActiveRecord::RecordNotFound |
||
| 2069 | # Stale or already deleted |
||
| 2070 | begin |
||
| 2071 | reload |
||
| 2072 | rescue ActiveRecord::RecordNotFound |
||
| 2073 | # The issue was actually already deleted |
||
| 2074 | @destroyed = true |
||
| 2075 | return freeze |
||
| 2076 | end |
||
| 2077 | # The issue was stale, retry to destroy |
||
| 2078 | super |
||
| 2079 | end |
||
| 2080 | |||
| 2081 | 1464:261b3d9a4903 | Chris | alias :base_reload :reload |
| 2082 | 1115:433d4f72a19b | Chris | def reload(*args) |
| 2083 | @workflow_rule_by_attribute = nil |
||
| 2084 | @assignable_versions = nil |
||
| 2085 | 1464:261b3d9a4903 | Chris | @relations = nil |
| 2086 | base_reload(*args) |
||
| 2087 | 1115:433d4f72a19b | Chris | end |
| 2088 | |||
| 2089 | 0:513646585e45 | Chris | # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields |
| 2090 | def available_custom_fields |
||
| 2091 | 1517:dffacf8a6908 | Chris | (project && tracker) ? (project.all_issue_custom_fields & tracker.custom_fields) : [] |
| 2092 | 0:513646585e45 | Chris | end |
| 2093 | 441:cbce1fd3b1b7 | Chris | |
| 2094 | 1464:261b3d9a4903 | Chris | def visible_custom_field_values(user=nil) |
| 2095 | user_real = user || User.current |
||
| 2096 | custom_field_values.select do |value| |
||
| 2097 | value.custom_field.visible_by?(project, user_real) |
||
| 2098 | end |
||
| 2099 | end |
||
| 2100 | |||
| 2101 | 1115:433d4f72a19b | Chris | # Copies attributes from another issue, arg can be an id or an Issue |
| 2102 | def copy_from(arg, options={})
|
||
| 2103 | 0:513646585e45 | Chris | issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg) |
| 2104 | self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
|
||
| 2105 | self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
|
||
| 2106 | self.status = issue.status |
||
| 2107 | 1115:433d4f72a19b | Chris | self.author = User.current |
| 2108 | unless options[:attachments] == false |
||
| 2109 | self.attachments = issue.attachments.map do |attachement| |
||
| 2110 | attachement.copy(:container => self) |
||
| 2111 | end |
||
| 2112 | end |
||
| 2113 | @copied_from = issue |
||
| 2114 | @copy_options = options |
||
| 2115 | 0:513646585e45 | Chris | self |
| 2116 | end |
||
| 2117 | 441:cbce1fd3b1b7 | Chris | |
| 2118 | 1115:433d4f72a19b | Chris | # Returns an unsaved copy of the issue |
| 2119 | def copy(attributes=nil, copy_options={})
|
||
| 2120 | copy = self.class.new.copy_from(self, copy_options) |
||
| 2121 | copy.attributes = attributes if attributes |
||
| 2122 | copy |
||
| 2123 | end |
||
| 2124 | |||
| 2125 | # Returns true if the issue is a copy |
||
| 2126 | def copy? |
||
| 2127 | @copied_from.present? |
||
| 2128 | end |
||
| 2129 | |||
| 2130 | 0:513646585e45 | Chris | # Moves/copies an issue to a new project and tracker |
| 2131 | # Returns the moved/copied issue on success, false on failure |
||
| 2132 | 1115:433d4f72a19b | Chris | def move_to_project(new_project, new_tracker=nil, options={})
|
| 2133 | ActiveSupport::Deprecation.warn "Issue#move_to_project is deprecated, use #project= instead." |
||
| 2134 | 441:cbce1fd3b1b7 | Chris | |
| 2135 | 1115:433d4f72a19b | Chris | if options[:copy] |
| 2136 | issue = self.copy |
||
| 2137 | else |
||
| 2138 | issue = self |
||
| 2139 | end |
||
| 2140 | 441:cbce1fd3b1b7 | Chris | |
| 2141 | 1115:433d4f72a19b | Chris | issue.init_journal(User.current, options[:notes]) |
| 2142 | |||
| 2143 | # Preserve previous behaviour |
||
| 2144 | # #move_to_project doesn't change tracker automatically |
||
| 2145 | issue.send :project=, new_project, true |
||
| 2146 | 0:513646585e45 | Chris | if new_tracker |
| 2147 | issue.tracker = new_tracker |
||
| 2148 | end |
||
| 2149 | # Allow bulk setting of attributes on the issue |
||
| 2150 | if options[:attributes] |
||
| 2151 | issue.attributes = options[:attributes] |
||
| 2152 | end |
||
| 2153 | 441:cbce1fd3b1b7 | Chris | |
| 2154 | 1115:433d4f72a19b | Chris | issue.save ? issue : false |
| 2155 | 0:513646585e45 | Chris | end |
| 2156 | |||
| 2157 | def status_id=(sid) |
||
| 2158 | self.status = nil |
||
| 2159 | 1115:433d4f72a19b | Chris | result = write_attribute(:status_id, sid) |
| 2160 | @workflow_rule_by_attribute = nil |
||
| 2161 | result |
||
| 2162 | 0:513646585e45 | Chris | end |
| 2163 | 441:cbce1fd3b1b7 | Chris | |
| 2164 | 0:513646585e45 | Chris | def priority_id=(pid) |
| 2165 | self.priority = nil |
||
| 2166 | write_attribute(:priority_id, pid) |
||
| 2167 | end |
||
| 2168 | |||
| 2169 | 1115:433d4f72a19b | Chris | def category_id=(cid) |
| 2170 | self.category = nil |
||
| 2171 | write_attribute(:category_id, cid) |
||
| 2172 | end |
||
| 2173 | |||
| 2174 | def fixed_version_id=(vid) |
||
| 2175 | self.fixed_version = nil |
||
| 2176 | write_attribute(:fixed_version_id, vid) |
||
| 2177 | end |
||
| 2178 | |||
| 2179 | 0:513646585e45 | Chris | def tracker_id=(tid) |
| 2180 | self.tracker = nil |
||
| 2181 | result = write_attribute(:tracker_id, tid) |
||
| 2182 | @custom_field_values = nil |
||
| 2183 | 1115:433d4f72a19b | Chris | @workflow_rule_by_attribute = nil |
| 2184 | 0:513646585e45 | Chris | result |
| 2185 | end |
||
| 2186 | 909:cbb26bc654de | Chris | |
| 2187 | 1115:433d4f72a19b | Chris | def project_id=(project_id) |
| 2188 | if project_id.to_s != self.project_id.to_s |
||
| 2189 | self.project = (project_id.present? ? Project.find_by_id(project_id) : nil) |
||
| 2190 | end |
||
| 2191 | end |
||
| 2192 | |||
| 2193 | def project=(project, keep_tracker=false) |
||
| 2194 | project_was = self.project |
||
| 2195 | write_attribute(:project_id, project ? project.id : nil) |
||
| 2196 | association_instance_set('project', project)
|
||
| 2197 | if project_was && project && project_was != project |
||
| 2198 | @assignable_versions = nil |
||
| 2199 | |||
| 2200 | unless keep_tracker || project.trackers.include?(tracker) |
||
| 2201 | self.tracker = project.trackers.first |
||
| 2202 | end |
||
| 2203 | # Reassign to the category with same name if any |
||
| 2204 | if category |
||
| 2205 | self.category = project.issue_categories.find_by_name(category.name) |
||
| 2206 | end |
||
| 2207 | # Keep the fixed_version if it's still valid in the new_project |
||
| 2208 | if fixed_version && fixed_version.project != project && !project.shared_versions.include?(fixed_version) |
||
| 2209 | self.fixed_version = nil |
||
| 2210 | end |
||
| 2211 | # Clear the parent task if it's no longer valid |
||
| 2212 | unless valid_parent_project? |
||
| 2213 | self.parent_issue_id = nil |
||
| 2214 | end |
||
| 2215 | @custom_field_values = nil |
||
| 2216 | end |
||
| 2217 | end |
||
| 2218 | |||
| 2219 | 507:0c939c159af4 | Chris | def description=(arg) |
| 2220 | if arg.is_a?(String) |
||
| 2221 | arg = arg.gsub(/(\r\n|\n|\r)/, "\r\n") |
||
| 2222 | end |
||
| 2223 | write_attribute(:description, arg) |
||
| 2224 | end |
||
| 2225 | 441:cbce1fd3b1b7 | Chris | |
| 2226 | 1115:433d4f72a19b | Chris | # Overrides assign_attributes so that project and tracker get assigned first |
| 2227 | def assign_attributes_with_project_and_tracker_first(new_attributes, *args) |
||
| 2228 | 0:513646585e45 | Chris | return if new_attributes.nil? |
| 2229 | 1115:433d4f72a19b | Chris | attrs = new_attributes.dup |
| 2230 | attrs.stringify_keys! |
||
| 2231 | |||
| 2232 | %w(project project_id tracker tracker_id).each do |attr| |
||
| 2233 | if attrs.has_key?(attr) |
||
| 2234 | send "#{attr}=", attrs.delete(attr)
|
||
| 2235 | end |
||
| 2236 | 0:513646585e45 | Chris | end |
| 2237 | 1115:433d4f72a19b | Chris | send :assign_attributes_without_project_and_tracker_first, attrs, *args |
| 2238 | 0:513646585e45 | Chris | end |
| 2239 | # Do not redefine alias chain on reload (see #4838) |
||
| 2240 | 1115:433d4f72a19b | Chris | alias_method_chain(:assign_attributes, :project_and_tracker_first) unless method_defined?(:assign_attributes_without_project_and_tracker_first) |
| 2241 | 441:cbce1fd3b1b7 | Chris | |
| 2242 | 0:513646585e45 | Chris | def estimated_hours=(h) |
| 2243 | write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h) |
||
| 2244 | end |
||
| 2245 | 441:cbce1fd3b1b7 | Chris | |
| 2246 | 1115:433d4f72a19b | Chris | safe_attributes 'project_id', |
| 2247 | :if => lambda {|issue, user|
|
||
| 2248 | if issue.new_record? |
||
| 2249 | issue.copy? |
||
| 2250 | elsif user.allowed_to?(:move_issues, issue.project) |
||
| 2251 | 1464:261b3d9a4903 | Chris | Issue.allowed_target_projects_on_move.count > 1 |
| 2252 | 1115:433d4f72a19b | Chris | end |
| 2253 | } |
||
| 2254 | |||
| 2255 | 117:af80e5618e9b | Chris | safe_attributes 'tracker_id', |
| 2256 | 'status_id', |
||
| 2257 | 'category_id', |
||
| 2258 | 'assigned_to_id', |
||
| 2259 | 'priority_id', |
||
| 2260 | 'fixed_version_id', |
||
| 2261 | 'subject', |
||
| 2262 | 'description', |
||
| 2263 | 'start_date', |
||
| 2264 | 'due_date', |
||
| 2265 | 'done_ratio', |
||
| 2266 | 'estimated_hours', |
||
| 2267 | 'custom_field_values', |
||
| 2268 | 'custom_fields', |
||
| 2269 | 'lock_version', |
||
| 2270 | 1115:433d4f72a19b | Chris | 'notes', |
| 2271 | 117:af80e5618e9b | Chris | :if => lambda {|issue, user| issue.new_record? || user.allowed_to?(:edit_issues, issue.project) }
|
| 2272 | 441:cbce1fd3b1b7 | Chris | |
| 2273 | 117:af80e5618e9b | Chris | safe_attributes 'status_id', |
| 2274 | 'assigned_to_id', |
||
| 2275 | 'fixed_version_id', |
||
| 2276 | 'done_ratio', |
||
| 2277 | 1115:433d4f72a19b | Chris | 'lock_version', |
| 2278 | 'notes', |
||
| 2279 | 117:af80e5618e9b | Chris | :if => lambda {|issue, user| issue.new_statuses_allowed_to(user).any? }
|
| 2280 | 37:94944d00e43c | chris | |
| 2281 | 1115:433d4f72a19b | Chris | safe_attributes 'notes', |
| 2282 | :if => lambda {|issue, user| user.allowed_to?(:add_issue_notes, issue.project)}
|
||
| 2283 | |||
| 2284 | safe_attributes 'private_notes', |
||
| 2285 | :if => lambda {|issue, user| !issue.new_record? && user.allowed_to?(:set_notes_private, issue.project)}
|
||
| 2286 | |||
| 2287 | safe_attributes 'watcher_user_ids', |
||
| 2288 | :if => lambda {|issue, user| issue.new_record? && user.allowed_to?(:add_issue_watchers, issue.project)}
|
||
| 2289 | |||
| 2290 | 441:cbce1fd3b1b7 | Chris | safe_attributes 'is_private', |
| 2291 | :if => lambda {|issue, user|
|
||
| 2292 | user.allowed_to?(:set_issues_private, issue.project) || |
||
| 2293 | (issue.author == user && user.allowed_to?(:set_own_issues_private, issue.project)) |
||
| 2294 | } |
||
| 2295 | |||
| 2296 | 1115:433d4f72a19b | Chris | safe_attributes 'parent_issue_id', |
| 2297 | :if => lambda {|issue, user| (issue.new_record? || user.allowed_to?(:edit_issues, issue.project)) &&
|
||
| 2298 | user.allowed_to?(:manage_subtasks, issue.project)} |
||
| 2299 | |||
| 2300 | def safe_attribute_names(user=nil) |
||
| 2301 | names = super |
||
| 2302 | names -= disabled_core_fields |
||
| 2303 | names -= read_only_attribute_names(user) |
||
| 2304 | names |
||
| 2305 | end |
||
| 2306 | |||
| 2307 | 0:513646585e45 | Chris | # Safely sets attributes |
| 2308 | # Should be called from controllers instead of #attributes= |
||
| 2309 | # attr_accessible is too rough because we still want things like |
||
| 2310 | # Issue.new(:project => foo) to work |
||
| 2311 | def safe_attributes=(attrs, user=User.current) |
||
| 2312 | 37:94944d00e43c | chris | return unless attrs.is_a?(Hash) |
| 2313 | 441:cbce1fd3b1b7 | Chris | |
| 2314 | 1115:433d4f72a19b | Chris | attrs = attrs.dup |
| 2315 | 441:cbce1fd3b1b7 | Chris | |
| 2316 | 1115:433d4f72a19b | Chris | # Project and Tracker must be set before since new_statuses_allowed_to depends on it. |
| 2317 | if (p = attrs.delete('project_id')) && safe_attribute?('project_id')
|
||
| 2318 | 1464:261b3d9a4903 | Chris | if allowed_target_projects(user).where(:id => p.to_i).exists? |
| 2319 | 1115:433d4f72a19b | Chris | self.project_id = p |
| 2320 | end |
||
| 2321 | end |
||
| 2322 | |||
| 2323 | if (t = attrs.delete('tracker_id')) && safe_attribute?('tracker_id')
|
||
| 2324 | 37:94944d00e43c | chris | self.tracker_id = t |
| 2325 | end |
||
| 2326 | 441:cbce1fd3b1b7 | Chris | |
| 2327 | 1115:433d4f72a19b | Chris | if (s = attrs.delete('status_id')) && safe_attribute?('status_id')
|
| 2328 | if new_statuses_allowed_to(user).collect(&:id).include?(s.to_i) |
||
| 2329 | self.status_id = s |
||
| 2330 | 0:513646585e45 | Chris | end |
| 2331 | end |
||
| 2332 | 441:cbce1fd3b1b7 | Chris | |
| 2333 | 1115:433d4f72a19b | Chris | attrs = delete_unsafe_attributes(attrs, user) |
| 2334 | return if attrs.empty? |
||
| 2335 | |||
| 2336 | 0:513646585e45 | Chris | unless leaf? |
| 2337 | attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
|
||
| 2338 | end |
||
| 2339 | 441:cbce1fd3b1b7 | Chris | |
| 2340 | 1115:433d4f72a19b | Chris | if attrs['parent_issue_id'].present? |
| 2341 | s = attrs['parent_issue_id'].to_s |
||
| 2342 | 1294:3e4c3460b6ca | Chris | unless (m = s.match(%r{\A#?(\d+)\z})) && (m[1] == parent_id.to_s || Issue.visible(user).exists?(m[1]))
|
| 2343 | 1115:433d4f72a19b | Chris | @invalid_parent_issue_id = attrs.delete('parent_issue_id')
|
| 2344 | 0:513646585e45 | Chris | end |
| 2345 | end |
||
| 2346 | 441:cbce1fd3b1b7 | Chris | |
| 2347 | 1115:433d4f72a19b | Chris | if attrs['custom_field_values'].present? |
| 2348 | 1464:261b3d9a4903 | Chris | editable_custom_field_ids = editable_custom_field_values(user).map {|v| v.custom_field_id.to_s}
|
| 2349 | # TODO: use #select when ruby1.8 support is dropped |
||
| 2350 | attrs['custom_field_values'] = attrs['custom_field_values'].reject {|k, v| !editable_custom_field_ids.include?(k.to_s)}
|
||
| 2351 | 1115:433d4f72a19b | Chris | end |
| 2352 | |||
| 2353 | if attrs['custom_fields'].present? |
||
| 2354 | 1464:261b3d9a4903 | Chris | editable_custom_field_ids = editable_custom_field_values(user).map {|v| v.custom_field_id.to_s}
|
| 2355 | # TODO: use #select when ruby1.8 support is dropped |
||
| 2356 | attrs['custom_fields'] = attrs['custom_fields'].reject {|c| !editable_custom_field_ids.include?(c['id'].to_s)}
|
||
| 2357 | 1115:433d4f72a19b | Chris | end |
| 2358 | |||
| 2359 | # mass-assignment security bypass |
||
| 2360 | assign_attributes attrs, :without_protection => true |
||
| 2361 | 0:513646585e45 | Chris | end |
| 2362 | 441:cbce1fd3b1b7 | Chris | |
| 2363 | 1115:433d4f72a19b | Chris | def disabled_core_fields |
| 2364 | tracker ? tracker.disabled_core_fields : [] |
||
| 2365 | end |
||
| 2366 | |||
| 2367 | # Returns the custom_field_values that can be edited by the given user |
||
| 2368 | def editable_custom_field_values(user=nil) |
||
| 2369 | 1464:261b3d9a4903 | Chris | visible_custom_field_values(user).reject do |value| |
| 2370 | 1115:433d4f72a19b | Chris | read_only_attribute_names(user).include?(value.custom_field_id.to_s) |
| 2371 | end |
||
| 2372 | end |
||
| 2373 | |||
| 2374 | 1517:dffacf8a6908 | Chris | # Returns the custom fields that can be edited by the given user |
| 2375 | def editable_custom_fields(user=nil) |
||
| 2376 | editable_custom_field_values(user).map(&:custom_field).uniq |
||
| 2377 | end |
||
| 2378 | |||
| 2379 | 1115:433d4f72a19b | Chris | # Returns the names of attributes that are read-only for user or the current user |
| 2380 | # For users with multiple roles, the read-only fields are the intersection of |
||
| 2381 | # read-only fields of each role |
||
| 2382 | # The result is an array of strings where sustom fields are represented with their ids |
||
| 2383 | # |
||
| 2384 | # Examples: |
||
| 2385 | # issue.read_only_attribute_names # => ['due_date', '2'] |
||
| 2386 | # issue.read_only_attribute_names(user) # => [] |
||
| 2387 | def read_only_attribute_names(user=nil) |
||
| 2388 | workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'readonly'}.keys
|
||
| 2389 | end |
||
| 2390 | |||
| 2391 | # Returns the names of required attributes for user or the current user |
||
| 2392 | # For users with multiple roles, the required fields are the intersection of |
||
| 2393 | # required fields of each role |
||
| 2394 | # The result is an array of strings where sustom fields are represented with their ids |
||
| 2395 | # |
||
| 2396 | # Examples: |
||
| 2397 | # issue.required_attribute_names # => ['due_date', '2'] |
||
| 2398 | # issue.required_attribute_names(user) # => [] |
||
| 2399 | def required_attribute_names(user=nil) |
||
| 2400 | workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'required'}.keys
|
||
| 2401 | end |
||
| 2402 | |||
| 2403 | # Returns true if the attribute is required for user |
||
| 2404 | def required_attribute?(name, user=nil) |
||
| 2405 | required_attribute_names(user).include?(name.to_s) |
||
| 2406 | end |
||
| 2407 | |||
| 2408 | # Returns a hash of the workflow rule by attribute for the given user |
||
| 2409 | # |
||
| 2410 | # Examples: |
||
| 2411 | # issue.workflow_rule_by_attribute # => {'due_date' => 'required', 'start_date' => 'readonly'}
|
||
| 2412 | def workflow_rule_by_attribute(user=nil) |
||
| 2413 | return @workflow_rule_by_attribute if @workflow_rule_by_attribute && user.nil? |
||
| 2414 | |||
| 2415 | user_real = user || User.current |
||
| 2416 | roles = user_real.admin ? Role.all : user_real.roles_for_project(project) |
||
| 2417 | return {} if roles.empty?
|
||
| 2418 | |||
| 2419 | result = {}
|
||
| 2420 | workflow_permissions = WorkflowPermission.where(:tracker_id => tracker_id, :old_status_id => status_id, :role_id => roles.map(&:id)).all |
||
| 2421 | if workflow_permissions.any? |
||
| 2422 | workflow_rules = workflow_permissions.inject({}) do |h, wp|
|
||
| 2423 | h[wp.field_name] ||= [] |
||
| 2424 | h[wp.field_name] << wp.rule |
||
| 2425 | h |
||
| 2426 | end |
||
| 2427 | workflow_rules.each do |attr, rules| |
||
| 2428 | next if rules.size < roles.size |
||
| 2429 | uniq_rules = rules.uniq |
||
| 2430 | if uniq_rules.size == 1 |
||
| 2431 | result[attr] = uniq_rules.first |
||
| 2432 | else |
||
| 2433 | result[attr] = 'required' |
||
| 2434 | end |
||
| 2435 | end |
||
| 2436 | end |
||
| 2437 | @workflow_rule_by_attribute = result if user.nil? |
||
| 2438 | result |
||
| 2439 | end |
||
| 2440 | private :workflow_rule_by_attribute |
||
| 2441 | |||
| 2442 | 0:513646585e45 | Chris | def done_ratio |
| 2443 | 37:94944d00e43c | chris | if Issue.use_status_for_done_ratio? && status && status.default_done_ratio |
| 2444 | 0:513646585e45 | Chris | status.default_done_ratio |
| 2445 | else |
||
| 2446 | read_attribute(:done_ratio) |
||
| 2447 | end |
||
| 2448 | end |
||
| 2449 | |||
| 2450 | def self.use_status_for_done_ratio? |
||
| 2451 | Setting.issue_done_ratio == 'issue_status' |
||
| 2452 | end |
||
| 2453 | |||
| 2454 | def self.use_field_for_done_ratio? |
||
| 2455 | Setting.issue_done_ratio == 'issue_field' |
||
| 2456 | end |
||
| 2457 | 441:cbce1fd3b1b7 | Chris | |
| 2458 | 909:cbb26bc654de | Chris | def validate_issue |
| 2459 | 1464:261b3d9a4903 | Chris | if due_date && start_date && (start_date_changed? || due_date_changed?) && due_date < start_date |
| 2460 | 0:513646585e45 | Chris | errors.add :due_date, :greater_than_start_date |
| 2461 | end |
||
| 2462 | 441:cbce1fd3b1b7 | Chris | |
| 2463 | 1464:261b3d9a4903 | Chris | if start_date && start_date_changed? && soonest_start && start_date < soonest_start |
| 2464 | errors.add :start_date, :earlier_than_minimum_start_date, :date => format_date(soonest_start) |
||
| 2465 | 0:513646585e45 | Chris | end |
| 2466 | 441:cbce1fd3b1b7 | Chris | |
| 2467 | 0:513646585e45 | Chris | if fixed_version |
| 2468 | if !assignable_versions.include?(fixed_version) |
||
| 2469 | errors.add :fixed_version_id, :inclusion |
||
| 2470 | elsif reopened? && fixed_version.closed? |
||
| 2471 | 909:cbb26bc654de | Chris | errors.add :base, I18n.t(:error_can_not_reopen_issue_on_closed_version) |
| 2472 | 0:513646585e45 | Chris | end |
| 2473 | end |
||
| 2474 | 441:cbce1fd3b1b7 | Chris | |
| 2475 | 0:513646585e45 | Chris | # Checks that the issue can not be added/moved to a disabled tracker |
| 2476 | if project && (tracker_id_changed? || project_id_changed?) |
||
| 2477 | unless project.trackers.include?(tracker) |
||
| 2478 | errors.add :tracker_id, :inclusion |
||
| 2479 | end |
||
| 2480 | end |
||
| 2481 | 441:cbce1fd3b1b7 | Chris | |
| 2482 | 0:513646585e45 | Chris | # Checks parent issue assignment |
| 2483 | 1115:433d4f72a19b | Chris | if @invalid_parent_issue_id.present? |
| 2484 | errors.add :parent_issue_id, :invalid |
||
| 2485 | elsif @parent_issue |
||
| 2486 | if !valid_parent_project?(@parent_issue) |
||
| 2487 | errors.add :parent_issue_id, :invalid |
||
| 2488 | 1464:261b3d9a4903 | Chris | elsif (@parent_issue != parent) && (all_dependent_issues.include?(@parent_issue) || @parent_issue.all_dependent_issues.include?(self)) |
| 2489 | errors.add :parent_issue_id, :invalid |
||
| 2490 | 0:513646585e45 | Chris | elsif !new_record? |
| 2491 | # moving an existing issue |
||
| 2492 | if @parent_issue.root_id != root_id |
||
| 2493 | # we can always move to another tree |
||
| 2494 | elsif move_possible?(@parent_issue) |
||
| 2495 | # move accepted inside tree |
||
| 2496 | else |
||
| 2497 | 1115:433d4f72a19b | Chris | errors.add :parent_issue_id, :invalid |
| 2498 | end |
||
| 2499 | end |
||
| 2500 | end |
||
| 2501 | end |
||
| 2502 | |||
| 2503 | # Validates the issue against additional workflow requirements |
||
| 2504 | def validate_required_fields |
||
| 2505 | user = new_record? ? author : current_journal.try(:user) |
||
| 2506 | |||
| 2507 | required_attribute_names(user).each do |attribute| |
||
| 2508 | if attribute =~ /^\d+$/ |
||
| 2509 | attribute = attribute.to_i |
||
| 2510 | v = custom_field_values.detect {|v| v.custom_field_id == attribute }
|
||
| 2511 | if v && v.value.blank? |
||
| 2512 | errors.add :base, v.custom_field.name + ' ' + l('activerecord.errors.messages.blank')
|
||
| 2513 | end |
||
| 2514 | else |
||
| 2515 | if respond_to?(attribute) && send(attribute).blank? |
||
| 2516 | errors.add attribute, :blank |
||
| 2517 | 0:513646585e45 | Chris | end |
| 2518 | end |
||
| 2519 | end |
||
| 2520 | end |
||
| 2521 | 441:cbce1fd3b1b7 | Chris | |
| 2522 | 0:513646585e45 | Chris | # Set the done_ratio using the status if that setting is set. This will keep the done_ratios |
| 2523 | # even if the user turns off the setting later |
||
| 2524 | def update_done_ratio_from_issue_status |
||
| 2525 | 37:94944d00e43c | chris | if Issue.use_status_for_done_ratio? && status && status.default_done_ratio |
| 2526 | 0:513646585e45 | Chris | self.done_ratio = status.default_done_ratio |
| 2527 | end |
||
| 2528 | end |
||
| 2529 | 441:cbce1fd3b1b7 | Chris | |
| 2530 | 0:513646585e45 | Chris | def init_journal(user, notes = "") |
| 2531 | @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes) |
||
| 2532 | 1115:433d4f72a19b | Chris | if new_record? |
| 2533 | @current_journal.notify = false |
||
| 2534 | else |
||
| 2535 | @attributes_before_change = attributes.dup |
||
| 2536 | @custom_values_before_change = {}
|
||
| 2537 | self.custom_field_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
|
||
| 2538 | end |
||
| 2539 | 0:513646585e45 | Chris | @current_journal |
| 2540 | end |
||
| 2541 | 441:cbce1fd3b1b7 | Chris | |
| 2542 | 1115:433d4f72a19b | Chris | # Returns the id of the last journal or nil |
| 2543 | def last_journal_id |
||
| 2544 | if new_record? |
||
| 2545 | nil |
||
| 2546 | else |
||
| 2547 | journals.maximum(:id) |
||
| 2548 | end |
||
| 2549 | end |
||
| 2550 | |||
| 2551 | # Returns a scope for journals that have an id greater than journal_id |
||
| 2552 | def journals_after(journal_id) |
||
| 2553 | scope = journals.reorder("#{Journal.table_name}.id ASC")
|
||
| 2554 | if journal_id.present? |
||
| 2555 | scope = scope.where("#{Journal.table_name}.id > ?", journal_id.to_i)
|
||
| 2556 | end |
||
| 2557 | scope |
||
| 2558 | end |
||
| 2559 | |||
| 2560 | 1464:261b3d9a4903 | Chris | # Returns the initial status of the issue |
| 2561 | # Returns nil for a new issue |
||
| 2562 | def status_was |
||
| 2563 | if status_id_was && status_id_was.to_i > 0 |
||
| 2564 | @status_was ||= IssueStatus.find_by_id(status_id_was) |
||
| 2565 | end |
||
| 2566 | end |
||
| 2567 | |||
| 2568 | 0:513646585e45 | Chris | # Return true if the issue is closed, otherwise false |
| 2569 | def closed? |
||
| 2570 | self.status.is_closed? |
||
| 2571 | end |
||
| 2572 | 441:cbce1fd3b1b7 | Chris | |
| 2573 | 0:513646585e45 | Chris | # Return true if the issue is being reopened |
| 2574 | def reopened? |
||
| 2575 | if !new_record? && status_id_changed? |
||
| 2576 | status_was = IssueStatus.find_by_id(status_id_was) |
||
| 2577 | status_new = IssueStatus.find_by_id(status_id) |
||
| 2578 | if status_was && status_new && status_was.is_closed? && !status_new.is_closed? |
||
| 2579 | return true |
||
| 2580 | end |
||
| 2581 | end |
||
| 2582 | false |
||
| 2583 | end |
||
| 2584 | |||
| 2585 | # Return true if the issue is being closed |
||
| 2586 | def closing? |
||
| 2587 | if !new_record? && status_id_changed? |
||
| 2588 | 1464:261b3d9a4903 | Chris | if status_was && status && !status_was.is_closed? && status.is_closed? |
| 2589 | 0:513646585e45 | Chris | return true |
| 2590 | end |
||
| 2591 | end |
||
| 2592 | false |
||
| 2593 | end |
||
| 2594 | 441:cbce1fd3b1b7 | Chris | |
| 2595 | 0:513646585e45 | Chris | # Returns true if the issue is overdue |
| 2596 | def overdue? |
||
| 2597 | !due_date.nil? && (due_date < Date.today) && !status.is_closed? |
||
| 2598 | end |
||
| 2599 | 22:40f7cfd4df19 | chris | |
| 2600 | # Is the amount of work done less than it should for the due date |
||
| 2601 | def behind_schedule? |
||
| 2602 | return false if start_date.nil? || due_date.nil? |
||
| 2603 | 1464:261b3d9a4903 | Chris | done_date = start_date + ((due_date - start_date + 1) * done_ratio / 100).floor |
| 2604 | 22:40f7cfd4df19 | chris | return done_date <= Date.today |
| 2605 | end |
||
| 2606 | |||
| 2607 | # Does this issue have children? |
||
| 2608 | def children? |
||
| 2609 | !leaf? |
||
| 2610 | end |
||
| 2611 | 441:cbce1fd3b1b7 | Chris | |
| 2612 | 0:513646585e45 | Chris | # Users the issue can be assigned to |
| 2613 | def assignable_users |
||
| 2614 | 37:94944d00e43c | chris | users = project.assignable_users |
| 2615 | users << author if author |
||
| 2616 | 909:cbb26bc654de | Chris | users << assigned_to if assigned_to |
| 2617 | 37:94944d00e43c | chris | users.uniq.sort |
| 2618 | 0:513646585e45 | Chris | end |
| 2619 | 441:cbce1fd3b1b7 | Chris | |
| 2620 | 0:513646585e45 | Chris | # Versions that the issue can be assigned to |
| 2621 | def assignable_versions |
||
| 2622 | 1115:433d4f72a19b | Chris | return @assignable_versions if @assignable_versions |
| 2623 | |||
| 2624 | versions = project.shared_versions.open.all |
||
| 2625 | if fixed_version |
||
| 2626 | if fixed_version_id_changed? |
||
| 2627 | # nothing to do |
||
| 2628 | elsif project_id_changed? |
||
| 2629 | if project.shared_versions.include?(fixed_version) |
||
| 2630 | versions << fixed_version |
||
| 2631 | end |
||
| 2632 | else |
||
| 2633 | versions << fixed_version |
||
| 2634 | end |
||
| 2635 | end |
||
| 2636 | @assignable_versions = versions.uniq.sort |
||
| 2637 | 0:513646585e45 | Chris | end |
| 2638 | 441:cbce1fd3b1b7 | Chris | |
| 2639 | 0:513646585e45 | Chris | # Returns true if this issue is blocked by another issue that is still open |
| 2640 | def blocked? |
||
| 2641 | !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
|
||
| 2642 | end |
||
| 2643 | 441:cbce1fd3b1b7 | Chris | |
| 2644 | 1115:433d4f72a19b | Chris | # Returns an array of statuses that user is able to apply |
| 2645 | def new_statuses_allowed_to(user=User.current, include_default=false) |
||
| 2646 | if new_record? && @copied_from |
||
| 2647 | [IssueStatus.default, @copied_from.status].compact.uniq.sort |
||
| 2648 | else |
||
| 2649 | initial_status = nil |
||
| 2650 | if new_record? |
||
| 2651 | initial_status = IssueStatus.default |
||
| 2652 | elsif status_id_was |
||
| 2653 | initial_status = IssueStatus.find_by_id(status_id_was) |
||
| 2654 | end |
||
| 2655 | initial_status ||= status |
||
| 2656 | 1464:261b3d9a4903 | Chris | |
| 2657 | initial_assigned_to_id = assigned_to_id_changed? ? assigned_to_id_was : assigned_to_id |
||
| 2658 | assignee_transitions_allowed = initial_assigned_to_id.present? && |
||
| 2659 | (user.id == initial_assigned_to_id || user.group_ids.include?(initial_assigned_to_id)) |
||
| 2660 | |||
| 2661 | 1115:433d4f72a19b | Chris | statuses = initial_status.find_new_statuses_allowed_to( |
| 2662 | user.admin ? Role.all : user.roles_for_project(project), |
||
| 2663 | tracker, |
||
| 2664 | author == user, |
||
| 2665 | 1464:261b3d9a4903 | Chris | assignee_transitions_allowed |
| 2666 | 1115:433d4f72a19b | Chris | ) |
| 2667 | statuses << initial_status unless statuses.empty? |
||
| 2668 | statuses << IssueStatus.default if include_default |
||
| 2669 | statuses = statuses.compact.uniq.sort |
||
| 2670 | blocked? ? statuses.reject {|s| s.is_closed?} : statuses
|
||
| 2671 | end |
||
| 2672 | 0:513646585e45 | Chris | end |
| 2673 | 441:cbce1fd3b1b7 | Chris | |
| 2674 | 1464:261b3d9a4903 | Chris | # Returns the previous assignee if changed |
| 2675 | 1115:433d4f72a19b | Chris | def assigned_to_was |
| 2676 | 1464:261b3d9a4903 | Chris | # assigned_to_id_was is reset before after_save callbacks |
| 2677 | user_id = @previous_assigned_to_id || assigned_to_id_was |
||
| 2678 | if user_id && user_id != assigned_to_id |
||
| 2679 | @assigned_to_was ||= User.find_by_id(user_id) |
||
| 2680 | 1115:433d4f72a19b | Chris | end |
| 2681 | end |
||
| 2682 | |||
| 2683 | # Returns the users that should be notified |
||
| 2684 | def notified_users |
||
| 2685 | notified = [] |
||
| 2686 | 37:94944d00e43c | chris | # Author and assignee are always notified unless they have been |
| 2687 | # locked or don't want to be notified |
||
| 2688 | 1115:433d4f72a19b | Chris | notified << author if author |
| 2689 | 909:cbb26bc654de | Chris | if assigned_to |
| 2690 | 1115:433d4f72a19b | Chris | notified += (assigned_to.is_a?(Group) ? assigned_to.users : [assigned_to]) |
| 2691 | 909:cbb26bc654de | Chris | end |
| 2692 | 1115:433d4f72a19b | Chris | if assigned_to_was |
| 2693 | notified += (assigned_to_was.is_a?(Group) ? assigned_to_was.users : [assigned_to_was]) |
||
| 2694 | end |
||
| 2695 | notified = notified.select {|u| u.active? && u.notify_about?(self)}
|
||
| 2696 | |||
| 2697 | notified += project.notified_users |
||
| 2698 | 0:513646585e45 | Chris | notified.uniq! |
| 2699 | # Remove users that can not view the issue |
||
| 2700 | notified.reject! {|user| !visible?(user)}
|
||
| 2701 | 1115:433d4f72a19b | Chris | notified |
| 2702 | end |
||
| 2703 | |||
| 2704 | # Returns the email addresses that should be notified |
||
| 2705 | def recipients |
||
| 2706 | notified_users.collect(&:mail) |
||
| 2707 | end |
||
| 2708 | |||
| 2709 | 1464:261b3d9a4903 | Chris | def each_notification(users, &block) |
| 2710 | if users.any? |
||
| 2711 | if custom_field_values.detect {|value| !value.custom_field.visible?}
|
||
| 2712 | users_by_custom_field_visibility = users.group_by do |user| |
||
| 2713 | visible_custom_field_values(user).map(&:custom_field_id).sort |
||
| 2714 | end |
||
| 2715 | users_by_custom_field_visibility.values.each do |users| |
||
| 2716 | yield(users) |
||
| 2717 | end |
||
| 2718 | else |
||
| 2719 | yield(users) |
||
| 2720 | end |
||
| 2721 | end |
||
| 2722 | end |
||
| 2723 | |||
| 2724 | 1115:433d4f72a19b | Chris | # Returns the number of hours spent on this issue |
| 2725 | def spent_hours |
||
| 2726 | @spent_hours ||= time_entries.sum(:hours) || 0 |
||
| 2727 | 0:513646585e45 | Chris | end |
| 2728 | 441:cbce1fd3b1b7 | Chris | |
| 2729 | 0:513646585e45 | Chris | # Returns the total number of hours spent on this issue and its descendants |
| 2730 | # |
||
| 2731 | # Example: |
||
| 2732 | # spent_hours => 0.0 |
||
| 2733 | # spent_hours => 50.2 |
||
| 2734 | 1115:433d4f72a19b | Chris | def total_spent_hours |
| 2735 | 1517:dffacf8a6908 | Chris | @total_spent_hours ||= |
| 2736 | self_and_descendants. |
||
| 2737 | joins("LEFT JOIN #{TimeEntry.table_name} ON #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id").
|
||
| 2738 | sum("#{TimeEntry.table_name}.hours").to_f || 0.0
|
||
| 2739 | 0:513646585e45 | Chris | end |
| 2740 | 441:cbce1fd3b1b7 | Chris | |
| 2741 | 0:513646585e45 | Chris | def relations |
| 2742 | 1464:261b3d9a4903 | Chris | @relations ||= IssueRelation::Relations.new(self, (relations_from + relations_to).sort) |
| 2743 | 909:cbb26bc654de | Chris | end |
| 2744 | |||
| 2745 | # Preloads relations for a collection of issues |
||
| 2746 | def self.load_relations(issues) |
||
| 2747 | if issues.any? |
||
| 2748 | 1464:261b3d9a4903 | Chris | relations = IssueRelation.where("issue_from_id IN (:ids) OR issue_to_id IN (:ids)", :ids => issues.map(&:id)).all
|
| 2749 | 909:cbb26bc654de | Chris | issues.each do |issue| |
| 2750 | issue.instance_variable_set "@relations", relations.select {|r| r.issue_from_id == issue.id || r.issue_to_id == issue.id}
|
||
| 2751 | end |
||
| 2752 | end |
||
| 2753 | end |
||
| 2754 | |||
| 2755 | 1115:433d4f72a19b | Chris | # Preloads visible spent time for a collection of issues |
| 2756 | def self.load_visible_spent_hours(issues, user=User.current) |
||
| 2757 | if issues.any? |
||
| 2758 | 1464:261b3d9a4903 | Chris | hours_by_issue_id = TimeEntry.visible(user).group(:issue_id).sum(:hours) |
| 2759 | 1115:433d4f72a19b | Chris | issues.each do |issue| |
| 2760 | issue.instance_variable_set "@spent_hours", (hours_by_issue_id[issue.id] || 0) |
||
| 2761 | end |
||
| 2762 | end |
||
| 2763 | end |
||
| 2764 | |||
| 2765 | # Preloads visible relations for a collection of issues |
||
| 2766 | def self.load_visible_relations(issues, user=User.current) |
||
| 2767 | if issues.any? |
||
| 2768 | issue_ids = issues.map(&:id) |
||
| 2769 | # Relations with issue_from in given issues and visible issue_to |
||
| 2770 | relations_from = IssueRelation.includes(:issue_to => [:status, :project]).where(visible_condition(user)).where(:issue_from_id => issue_ids).all |
||
| 2771 | # Relations with issue_to in given issues and visible issue_from |
||
| 2772 | relations_to = IssueRelation.includes(:issue_from => [:status, :project]).where(visible_condition(user)).where(:issue_to_id => issue_ids).all |
||
| 2773 | |||
| 2774 | issues.each do |issue| |
||
| 2775 | relations = |
||
| 2776 | relations_from.select {|relation| relation.issue_from_id == issue.id} +
|
||
| 2777 | relations_to.select {|relation| relation.issue_to_id == issue.id}
|
||
| 2778 | |||
| 2779 | 1464:261b3d9a4903 | Chris | issue.instance_variable_set "@relations", IssueRelation::Relations.new(issue, relations.sort) |
| 2780 | 1115:433d4f72a19b | Chris | end |
| 2781 | end |
||
| 2782 | end |
||
| 2783 | |||
| 2784 | 909:cbb26bc654de | Chris | # Finds an issue relation given its id. |
| 2785 | def find_relation(relation_id) |
||
| 2786 | 1464:261b3d9a4903 | Chris | IssueRelation.where("issue_to_id = ? OR issue_from_id = ?", id, id).find(relation_id)
|
| 2787 | 0:513646585e45 | Chris | end |
| 2788 | 441:cbce1fd3b1b7 | Chris | |
| 2789 | 1464:261b3d9a4903 | Chris | # Returns all the other issues that depend on the issue |
| 2790 | # The algorithm is a modified breadth first search (bfs) |
||
| 2791 | 441:cbce1fd3b1b7 | Chris | def all_dependent_issues(except=[]) |
| 2792 | 1464:261b3d9a4903 | Chris | # The found dependencies |
| 2793 | 0:513646585e45 | Chris | dependencies = [] |
| 2794 | 1464:261b3d9a4903 | Chris | |
| 2795 | # The visited flag for every node (issue) used by the breadth first search |
||
| 2796 | eNOT_DISCOVERED = 0 # The issue is "new" to the algorithm, it has not seen it before. |
||
| 2797 | |||
| 2798 | ePROCESS_ALL = 1 # The issue is added to the queue. Process both children and relations of |
||
| 2799 | # the issue when it is processed. |
||
| 2800 | |||
| 2801 | ePROCESS_RELATIONS_ONLY = 2 # The issue was added to the queue and will be output as dependent issue, |
||
| 2802 | # but its children will not be added to the queue when it is processed. |
||
| 2803 | |||
| 2804 | eRELATIONS_PROCESSED = 3 # The related issues, the parent issue and the issue itself have been added to |
||
| 2805 | # the queue, but its children have not been added. |
||
| 2806 | |||
| 2807 | ePROCESS_CHILDREN_ONLY = 4 # The relations and the parent of the issue have been added to the queue, but |
||
| 2808 | # the children still need to be processed. |
||
| 2809 | |||
| 2810 | eALL_PROCESSED = 5 # The issue and all its children, its parent and its related issues have been |
||
| 2811 | # added as dependent issues. It needs no further processing. |
||
| 2812 | |||
| 2813 | issue_status = Hash.new(eNOT_DISCOVERED) |
||
| 2814 | |||
| 2815 | # The queue |
||
| 2816 | queue = [] |
||
| 2817 | |||
| 2818 | # Initialize the bfs, add start node (self) to the queue |
||
| 2819 | queue << self |
||
| 2820 | issue_status[self] = ePROCESS_ALL |
||
| 2821 | |||
| 2822 | while (!queue.empty?) do |
||
| 2823 | current_issue = queue.shift |
||
| 2824 | current_issue_status = issue_status[current_issue] |
||
| 2825 | dependencies << current_issue |
||
| 2826 | |||
| 2827 | # Add parent to queue, if not already in it. |
||
| 2828 | parent = current_issue.parent |
||
| 2829 | parent_status = issue_status[parent] |
||
| 2830 | |||
| 2831 | if parent && (parent_status == eNOT_DISCOVERED) && !except.include?(parent) |
||
| 2832 | queue << parent |
||
| 2833 | issue_status[parent] = ePROCESS_RELATIONS_ONLY |
||
| 2834 | 128:07fa8a8b56a8 | Chris | end |
| 2835 | 1464:261b3d9a4903 | Chris | |
| 2836 | # Add children to queue, but only if they are not already in it and |
||
| 2837 | # the children of the current node need to be processed. |
||
| 2838 | if (current_issue_status == ePROCESS_CHILDREN_ONLY || current_issue_status == ePROCESS_ALL) |
||
| 2839 | current_issue.children.each do |child| |
||
| 2840 | next if except.include?(child) |
||
| 2841 | |||
| 2842 | if (issue_status[child] == eNOT_DISCOVERED) |
||
| 2843 | queue << child |
||
| 2844 | issue_status[child] = ePROCESS_ALL |
||
| 2845 | elsif (issue_status[child] == eRELATIONS_PROCESSED) |
||
| 2846 | queue << child |
||
| 2847 | issue_status[child] = ePROCESS_CHILDREN_ONLY |
||
| 2848 | elsif (issue_status[child] == ePROCESS_RELATIONS_ONLY) |
||
| 2849 | queue << child |
||
| 2850 | issue_status[child] = ePROCESS_ALL |
||
| 2851 | end |
||
| 2852 | end |
||
| 2853 | end |
||
| 2854 | |||
| 2855 | # Add related issues to the queue, if they are not already in it. |
||
| 2856 | current_issue.relations_from.map(&:issue_to).each do |related_issue| |
||
| 2857 | next if except.include?(related_issue) |
||
| 2858 | |||
| 2859 | if (issue_status[related_issue] == eNOT_DISCOVERED) |
||
| 2860 | queue << related_issue |
||
| 2861 | issue_status[related_issue] = ePROCESS_ALL |
||
| 2862 | elsif (issue_status[related_issue] == eRELATIONS_PROCESSED) |
||
| 2863 | queue << related_issue |
||
| 2864 | issue_status[related_issue] = ePROCESS_CHILDREN_ONLY |
||
| 2865 | elsif (issue_status[related_issue] == ePROCESS_RELATIONS_ONLY) |
||
| 2866 | queue << related_issue |
||
| 2867 | issue_status[related_issue] = ePROCESS_ALL |
||
| 2868 | end |
||
| 2869 | end |
||
| 2870 | |||
| 2871 | # Set new status for current issue |
||
| 2872 | if (current_issue_status == ePROCESS_ALL) || (current_issue_status == ePROCESS_CHILDREN_ONLY) |
||
| 2873 | issue_status[current_issue] = eALL_PROCESSED |
||
| 2874 | elsif (current_issue_status == ePROCESS_RELATIONS_ONLY) |
||
| 2875 | issue_status[current_issue] = eRELATIONS_PROCESSED |
||
| 2876 | end |
||
| 2877 | end # while |
||
| 2878 | |||
| 2879 | # Remove the issues from the "except" parameter from the result array |
||
| 2880 | dependencies -= except |
||
| 2881 | dependencies.delete(self) |
||
| 2882 | |||
| 2883 | 0:513646585e45 | Chris | dependencies |
| 2884 | end |
||
| 2885 | 441:cbce1fd3b1b7 | Chris | |
| 2886 | 0:513646585e45 | Chris | # Returns an array of issues that duplicate this one |
| 2887 | def duplicates |
||
| 2888 | relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
|
||
| 2889 | end |
||
| 2890 | 441:cbce1fd3b1b7 | Chris | |
| 2891 | 0:513646585e45 | Chris | # Returns the due date or the target due date if any |
| 2892 | # Used on gantt chart |
||
| 2893 | def due_before |
||
| 2894 | due_date || (fixed_version ? fixed_version.effective_date : nil) |
||
| 2895 | end |
||
| 2896 | 441:cbce1fd3b1b7 | Chris | |
| 2897 | 0:513646585e45 | Chris | # Returns the time scheduled for this issue. |
| 2898 | 441:cbce1fd3b1b7 | Chris | # |
| 2899 | 0:513646585e45 | Chris | # Example: |
| 2900 | # Start Date: 2/26/09, End Date: 3/04/09 |
||
| 2901 | # duration => 6 |
||
| 2902 | def duration |
||
| 2903 | (start_date && due_date) ? due_date - start_date : 0 |
||
| 2904 | end |
||
| 2905 | 441:cbce1fd3b1b7 | Chris | |
| 2906 | 1115:433d4f72a19b | Chris | # Returns the duration in working days |
| 2907 | def working_duration |
||
| 2908 | (start_date && due_date) ? working_days(start_date, due_date) : 0 |
||
| 2909 | end |
||
| 2910 | |||
| 2911 | def soonest_start(reload=false) |
||
| 2912 | @soonest_start = nil if reload |
||
| 2913 | 0:513646585e45 | Chris | @soonest_start ||= ( |
| 2914 | 1115:433d4f72a19b | Chris | relations_to(reload).collect{|relation| relation.successor_soonest_start} +
|
| 2915 | 1464:261b3d9a4903 | Chris | [(@parent_issue || parent).try(:soonest_start)] |
| 2916 | 0:513646585e45 | Chris | ).compact.max |
| 2917 | end |
||
| 2918 | 441:cbce1fd3b1b7 | Chris | |
| 2919 | 1115:433d4f72a19b | Chris | # Sets start_date on the given date or the next working day |
| 2920 | # and changes due_date to keep the same working duration. |
||
| 2921 | def reschedule_on(date) |
||
| 2922 | wd = working_duration |
||
| 2923 | date = next_working_date(date) |
||
| 2924 | self.start_date = date |
||
| 2925 | self.due_date = add_working_days(date, wd) |
||
| 2926 | end |
||
| 2927 | |||
| 2928 | # Reschedules the issue on the given date or the next working day and saves the record. |
||
| 2929 | # If the issue is a parent task, this is done by rescheduling its subtasks. |
||
| 2930 | def reschedule_on!(date) |
||
| 2931 | 0:513646585e45 | Chris | return if date.nil? |
| 2932 | if leaf? |
||
| 2933 | 1115:433d4f72a19b | Chris | if start_date.nil? || start_date != date |
| 2934 | if start_date && start_date > date |
||
| 2935 | # Issue can not be moved earlier than its soonest start date |
||
| 2936 | date = [soonest_start(true), date].compact.max |
||
| 2937 | end |
||
| 2938 | reschedule_on(date) |
||
| 2939 | begin |
||
| 2940 | save |
||
| 2941 | rescue ActiveRecord::StaleObjectError |
||
| 2942 | reload |
||
| 2943 | reschedule_on(date) |
||
| 2944 | save |
||
| 2945 | end |
||
| 2946 | 0:513646585e45 | Chris | end |
| 2947 | else |
||
| 2948 | leaves.each do |leaf| |
||
| 2949 | 1115:433d4f72a19b | Chris | if leaf.start_date |
| 2950 | # Only move subtask if it starts at the same date as the parent |
||
| 2951 | # or if it starts before the given date |
||
| 2952 | if start_date == leaf.start_date || date > leaf.start_date |
||
| 2953 | leaf.reschedule_on!(date) |
||
| 2954 | end |
||
| 2955 | else |
||
| 2956 | leaf.reschedule_on!(date) |
||
| 2957 | end |
||
| 2958 | 0:513646585e45 | Chris | end |
| 2959 | end |
||
| 2960 | end |
||
| 2961 | 441:cbce1fd3b1b7 | Chris | |
| 2962 | 0:513646585e45 | Chris | def <=>(issue) |
| 2963 | if issue.nil? |
||
| 2964 | -1 |
||
| 2965 | elsif root_id != issue.root_id |
||
| 2966 | (root_id || 0) <=> (issue.root_id || 0) |
||
| 2967 | else |
||
| 2968 | (lft || 0) <=> (issue.lft || 0) |
||
| 2969 | end |
||
| 2970 | end |
||
| 2971 | 441:cbce1fd3b1b7 | Chris | |
| 2972 | 0:513646585e45 | Chris | def to_s |
| 2973 | "#{tracker} ##{id}: #{subject}"
|
||
| 2974 | end |
||
| 2975 | 441:cbce1fd3b1b7 | Chris | |
| 2976 | 0:513646585e45 | Chris | # Returns a string of css classes that apply to the issue |
| 2977 | 1464:261b3d9a4903 | Chris | def css_classes(user=User.current) |
| 2978 | s = "issue tracker-#{tracker_id} status-#{status_id} #{priority.try(:css_classes)}"
|
||
| 2979 | 0:513646585e45 | Chris | s << ' closed' if closed? |
| 2980 | s << ' overdue' if overdue? |
||
| 2981 | 441:cbce1fd3b1b7 | Chris | s << ' child' if child? |
| 2982 | s << ' parent' unless leaf? |
||
| 2983 | s << ' private' if is_private? |
||
| 2984 | 1464:261b3d9a4903 | Chris | if user.logged? |
| 2985 | s << ' created-by-me' if author_id == user.id |
||
| 2986 | s << ' assigned-to-me' if assigned_to_id == user.id |
||
| 2987 | 1494:e248c7af89ec | Chris | s << ' assigned-to-my-group' if user.groups.any? {|g| g.id == assigned_to_id}
|
| 2988 | 1464:261b3d9a4903 | Chris | end |
| 2989 | 0:513646585e45 | Chris | s |
| 2990 | end |
||
| 2991 | |||
| 2992 | # Unassigns issues from +version+ if it's no longer shared with issue's project |
||
| 2993 | def self.update_versions_from_sharing_change(version) |
||
| 2994 | # Update issues assigned to the version |
||
| 2995 | update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
|
||
| 2996 | end |
||
| 2997 | 441:cbce1fd3b1b7 | Chris | |
| 2998 | 0:513646585e45 | Chris | # Unassigns issues from versions that are no longer shared |
| 2999 | # after +project+ was moved |
||
| 3000 | def self.update_versions_from_hierarchy_change(project) |
||
| 3001 | moved_project_ids = project.self_and_descendants.reload.collect(&:id) |
||
| 3002 | # Update issues of the moved projects and issues assigned to a version of a moved project |
||
| 3003 | Issue.update_versions(["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)", moved_project_ids, moved_project_ids])
|
||
| 3004 | end |
||
| 3005 | |||
| 3006 | def parent_issue_id=(arg) |
||
| 3007 | 1115:433d4f72a19b | Chris | s = arg.to_s.strip.presence |
| 3008 | if s && (m = s.match(%r{\A#?(\d+)\z})) && (@parent_issue = Issue.find_by_id(m[1]))
|
||
| 3009 | 0:513646585e45 | Chris | @parent_issue.id |
| 3010 | 1464:261b3d9a4903 | Chris | @invalid_parent_issue_id = nil |
| 3011 | elsif s.blank? |
||
| 3012 | @parent_issue = nil |
||
| 3013 | @invalid_parent_issue_id = nil |
||
| 3014 | 0:513646585e45 | Chris | else |
| 3015 | @parent_issue = nil |
||
| 3016 | 1115:433d4f72a19b | Chris | @invalid_parent_issue_id = arg |
| 3017 | 0:513646585e45 | Chris | end |
| 3018 | end |
||
| 3019 | 441:cbce1fd3b1b7 | Chris | |
| 3020 | 0:513646585e45 | Chris | def parent_issue_id |
| 3021 | 1115:433d4f72a19b | Chris | if @invalid_parent_issue_id |
| 3022 | @invalid_parent_issue_id |
||
| 3023 | elsif instance_variable_defined? :@parent_issue |
||
| 3024 | 0:513646585e45 | Chris | @parent_issue.nil? ? nil : @parent_issue.id |
| 3025 | else |
||
| 3026 | parent_id |
||
| 3027 | end |
||
| 3028 | end |
||
| 3029 | |||
| 3030 | 1464:261b3d9a4903 | Chris | # Returns true if issue's project is a valid |
| 3031 | # parent issue project |
||
| 3032 | 1115:433d4f72a19b | Chris | def valid_parent_project?(issue=parent) |
| 3033 | return true if issue.nil? || issue.project_id == project_id |
||
| 3034 | |||
| 3035 | case Setting.cross_project_subtasks |
||
| 3036 | when 'system' |
||
| 3037 | true |
||
| 3038 | when 'tree' |
||
| 3039 | issue.project.root == project.root |
||
| 3040 | when 'hierarchy' |
||
| 3041 | issue.project.is_or_is_ancestor_of?(project) || issue.project.is_descendant_of?(project) |
||
| 3042 | when 'descendants' |
||
| 3043 | issue.project.is_or_is_ancestor_of?(project) |
||
| 3044 | else |
||
| 3045 | false |
||
| 3046 | end |
||
| 3047 | end |
||
| 3048 | |||
| 3049 | 0:513646585e45 | Chris | # Extracted from the ReportsController. |
| 3050 | def self.by_tracker(project) |
||
| 3051 | count_and_group_by(:project => project, |
||
| 3052 | :field => 'tracker_id', |
||
| 3053 | :joins => Tracker.table_name) |
||
| 3054 | end |
||
| 3055 | |||
| 3056 | def self.by_version(project) |
||
| 3057 | count_and_group_by(:project => project, |
||
| 3058 | :field => 'fixed_version_id', |
||
| 3059 | :joins => Version.table_name) |
||
| 3060 | end |
||
| 3061 | |||
| 3062 | def self.by_priority(project) |
||
| 3063 | count_and_group_by(:project => project, |
||
| 3064 | :field => 'priority_id', |
||
| 3065 | :joins => IssuePriority.table_name) |
||
| 3066 | end |
||
| 3067 | |||
| 3068 | def self.by_category(project) |
||
| 3069 | count_and_group_by(:project => project, |
||
| 3070 | :field => 'category_id', |
||
| 3071 | :joins => IssueCategory.table_name) |
||
| 3072 | end |
||
| 3073 | |||
| 3074 | def self.by_assigned_to(project) |
||
| 3075 | count_and_group_by(:project => project, |
||
| 3076 | :field => 'assigned_to_id', |
||
| 3077 | :joins => User.table_name) |
||
| 3078 | end |
||
| 3079 | |||
| 3080 | def self.by_author(project) |
||
| 3081 | count_and_group_by(:project => project, |
||
| 3082 | :field => 'author_id', |
||
| 3083 | :joins => User.table_name) |
||
| 3084 | end |
||
| 3085 | |||
| 3086 | def self.by_subproject(project) |
||
| 3087 | ActiveRecord::Base.connection.select_all("select s.id as status_id,
|
||
| 3088 | s.is_closed as closed, |
||
| 3089 | 441:cbce1fd3b1b7 | Chris | #{Issue.table_name}.project_id as project_id,
|
| 3090 | count(#{Issue.table_name}.id) as total
|
||
| 3091 | 0:513646585e45 | Chris | from |
| 3092 | 441:cbce1fd3b1b7 | Chris | #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s
|
| 3093 | 0:513646585e45 | Chris | where |
| 3094 | 441:cbce1fd3b1b7 | Chris | #{Issue.table_name}.status_id=s.id
|
| 3095 | and #{Issue.table_name}.project_id = #{Project.table_name}.id
|
||
| 3096 | and #{visible_condition(User.current, :project => project, :with_subprojects => true)}
|
||
| 3097 | and #{Issue.table_name}.project_id <> #{project.id}
|
||
| 3098 | group by s.id, s.is_closed, #{Issue.table_name}.project_id") if project.descendants.active.any?
|
||
| 3099 | 0:513646585e45 | Chris | end |
| 3100 | # End ReportsController extraction |
||
| 3101 | 441:cbce1fd3b1b7 | Chris | |
| 3102 | 1464:261b3d9a4903 | Chris | # Returns a scope of projects that user can assign the issue to |
| 3103 | 1115:433d4f72a19b | Chris | def allowed_target_projects(user=User.current) |
| 3104 | if new_record? |
||
| 3105 | 1464:261b3d9a4903 | Chris | Project.where(Project.allowed_to_condition(user, :add_issues)) |
| 3106 | 1115:433d4f72a19b | Chris | else |
| 3107 | self.class.allowed_target_projects_on_move(user) |
||
| 3108 | 0:513646585e45 | Chris | end |
| 3109 | 1115:433d4f72a19b | Chris | end |
| 3110 | |||
| 3111 | 1464:261b3d9a4903 | Chris | # Returns a scope of projects that user can move issues to |
| 3112 | 1115:433d4f72a19b | Chris | def self.allowed_target_projects_on_move(user=User.current) |
| 3113 | 1464:261b3d9a4903 | Chris | Project.where(Project.allowed_to_condition(user, :move_issues)) |
| 3114 | 0:513646585e45 | Chris | end |
| 3115 | 441:cbce1fd3b1b7 | Chris | |
| 3116 | 0:513646585e45 | Chris | private |
| 3117 | 441:cbce1fd3b1b7 | Chris | |
| 3118 | 1115:433d4f72a19b | Chris | def after_project_change |
| 3119 | # Update project_id on related time entries |
||
| 3120 | 1517:dffacf8a6908 | Chris | TimeEntry.where({:issue_id => id}).update_all(["project_id = ?", project_id])
|
| 3121 | 1115:433d4f72a19b | Chris | |
| 3122 | # Delete issue relations |
||
| 3123 | unless Setting.cross_project_issue_relations? |
||
| 3124 | relations_from.clear |
||
| 3125 | relations_to.clear |
||
| 3126 | end |
||
| 3127 | |||
| 3128 | # Move subtasks that were in the same project |
||
| 3129 | children.each do |child| |
||
| 3130 | next unless child.project_id == project_id_was |
||
| 3131 | # Change project and keep project |
||
| 3132 | child.send :project=, project, true |
||
| 3133 | unless child.save |
||
| 3134 | raise ActiveRecord::Rollback |
||
| 3135 | end |
||
| 3136 | end |
||
| 3137 | end |
||
| 3138 | |||
| 3139 | # Callback for after the creation of an issue by copy |
||
| 3140 | # * adds a "copied to" relation with the copied issue |
||
| 3141 | # * copies subtasks from the copied issue |
||
| 3142 | def after_create_from_copy |
||
| 3143 | return unless copy? && !@after_create_from_copy_handled |
||
| 3144 | |||
| 3145 | if (@copied_from.project_id == project_id || Setting.cross_project_issue_relations?) && @copy_options[:link] != false |
||
| 3146 | relation = IssueRelation.new(:issue_from => @copied_from, :issue_to => self, :relation_type => IssueRelation::TYPE_COPIED_TO) |
||
| 3147 | unless relation.save |
||
| 3148 | logger.error "Could not create relation while copying ##{@copied_from.id} to ##{id} due to validation errors: #{relation.errors.full_messages.join(', ')}" if logger
|
||
| 3149 | end |
||
| 3150 | end |
||
| 3151 | |||
| 3152 | unless @copied_from.leaf? || @copy_options[:subtasks] == false |
||
| 3153 | 1464:261b3d9a4903 | Chris | copy_options = (@copy_options || {}).merge(:subtasks => false)
|
| 3154 | copied_issue_ids = {@copied_from.id => self.id}
|
||
| 3155 | @copied_from.reload.descendants.reorder("#{Issue.table_name}.lft").each do |child|
|
||
| 3156 | # Do not copy self when copying an issue as a descendant of the copied issue |
||
| 3157 | next if child == self |
||
| 3158 | # Do not copy subtasks of issues that were not copied |
||
| 3159 | next unless copied_issue_ids[child.parent_id] |
||
| 3160 | # Do not copy subtasks that are not visible to avoid potential disclosure of private data |
||
| 3161 | 1115:433d4f72a19b | Chris | unless child.visible? |
| 3162 | logger.error "Subtask ##{child.id} was not copied during ##{@copied_from.id} copy because it is not visible to the current user" if logger
|
||
| 3163 | next |
||
| 3164 | end |
||
| 3165 | 1464:261b3d9a4903 | Chris | copy = Issue.new.copy_from(child, copy_options) |
| 3166 | 1115:433d4f72a19b | Chris | copy.author = author |
| 3167 | copy.project = project |
||
| 3168 | 1464:261b3d9a4903 | Chris | copy.parent_issue_id = copied_issue_ids[child.parent_id] |
| 3169 | 1115:433d4f72a19b | Chris | unless copy.save |
| 3170 | logger.error "Could not copy subtask ##{child.id} while copying ##{@copied_from.id} to ##{id} due to validation errors: #{copy.errors.full_messages.join(', ')}" if logger
|
||
| 3171 | 1464:261b3d9a4903 | Chris | next |
| 3172 | 1115:433d4f72a19b | Chris | end |
| 3173 | 1464:261b3d9a4903 | Chris | copied_issue_ids[child.id] = copy.id |
| 3174 | 1115:433d4f72a19b | Chris | end |
| 3175 | end |
||
| 3176 | @after_create_from_copy_handled = true |
||
| 3177 | end |
||
| 3178 | |||
| 3179 | 0:513646585e45 | Chris | def update_nested_set_attributes |
| 3180 | if root_id.nil? |
||
| 3181 | # issue was just created |
||
| 3182 | self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id) |
||
| 3183 | set_default_left_and_right |
||
| 3184 | 1517:dffacf8a6908 | Chris | Issue.where(["id = ?", id]). |
| 3185 | update_all(["root_id = ?, lft = ?, rgt = ?", root_id, lft, rgt]) |
||
| 3186 | 0:513646585e45 | Chris | if @parent_issue |
| 3187 | move_to_child_of(@parent_issue) |
||
| 3188 | end |
||
| 3189 | elsif parent_issue_id != parent_id |
||
| 3190 | 1464:261b3d9a4903 | Chris | update_nested_set_attributes_on_parent_change |
| 3191 | 0:513646585e45 | Chris | end |
| 3192 | remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue) |
||
| 3193 | end |
||
| 3194 | 441:cbce1fd3b1b7 | Chris | |
| 3195 | 1464:261b3d9a4903 | Chris | # Updates the nested set for when an existing issue is moved |
| 3196 | def update_nested_set_attributes_on_parent_change |
||
| 3197 | former_parent_id = parent_id |
||
| 3198 | # moving an existing issue |
||
| 3199 | if @parent_issue && @parent_issue.root_id == root_id |
||
| 3200 | # inside the same tree |
||
| 3201 | move_to_child_of(@parent_issue) |
||
| 3202 | else |
||
| 3203 | # to another tree |
||
| 3204 | unless root? |
||
| 3205 | move_to_right_of(root) |
||
| 3206 | end |
||
| 3207 | old_root_id = root_id |
||
| 3208 | self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id ) |
||
| 3209 | target_maxright = nested_set_scope.maximum(right_column_name) || 0 |
||
| 3210 | offset = target_maxright + 1 - lft |
||
| 3211 | 1517:dffacf8a6908 | Chris | Issue.where(["root_id = ? AND lft >= ? AND rgt <= ? ", old_root_id, lft, rgt]). |
| 3212 | update_all(["root_id = ?, lft = lft + ?, rgt = rgt + ?", root_id, offset, offset]) |
||
| 3213 | 1464:261b3d9a4903 | Chris | self[left_column_name] = lft + offset |
| 3214 | self[right_column_name] = rgt + offset |
||
| 3215 | if @parent_issue |
||
| 3216 | move_to_child_of(@parent_issue) |
||
| 3217 | end |
||
| 3218 | end |
||
| 3219 | # delete invalid relations of all descendants |
||
| 3220 | self_and_descendants.each do |issue| |
||
| 3221 | issue.relations.each do |relation| |
||
| 3222 | relation.destroy unless relation.valid? |
||
| 3223 | end |
||
| 3224 | end |
||
| 3225 | # update former parent |
||
| 3226 | recalculate_attributes_for(former_parent_id) if former_parent_id |
||
| 3227 | end |
||
| 3228 | |||
| 3229 | 0:513646585e45 | Chris | def update_parent_attributes |
| 3230 | recalculate_attributes_for(parent_id) if parent_id |
||
| 3231 | end |
||
| 3232 | |||
| 3233 | def recalculate_attributes_for(issue_id) |
||
| 3234 | if issue_id && p = Issue.find_by_id(issue_id) |
||
| 3235 | # priority = highest priority of children |
||
| 3236 | 1517:dffacf8a6908 | Chris | if priority_position = p.children.joins(:priority).maximum("#{IssuePriority.table_name}.position")
|
| 3237 | 0:513646585e45 | Chris | p.priority = IssuePriority.find_by_position(priority_position) |
| 3238 | end |
||
| 3239 | 441:cbce1fd3b1b7 | Chris | |
| 3240 | 0:513646585e45 | Chris | # start/due dates = lowest/highest dates of children |
| 3241 | p.start_date = p.children.minimum(:start_date) |
||
| 3242 | p.due_date = p.children.maximum(:due_date) |
||
| 3243 | if p.start_date && p.due_date && p.due_date < p.start_date |
||
| 3244 | p.start_date, p.due_date = p.due_date, p.start_date |
||
| 3245 | end |
||
| 3246 | 441:cbce1fd3b1b7 | Chris | |
| 3247 | 0:513646585e45 | Chris | # done ratio = weighted average ratio of leaves |
| 3248 | 37:94944d00e43c | chris | unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio |
| 3249 | 0:513646585e45 | Chris | leaves_count = p.leaves.count |
| 3250 | if leaves_count > 0 |
||
| 3251 | 1494:e248c7af89ec | Chris | average = p.leaves.where("estimated_hours > 0").average(:estimated_hours).to_f
|
| 3252 | 0:513646585e45 | Chris | if average == 0 |
| 3253 | average = 1 |
||
| 3254 | end |
||
| 3255 | 1517:dffacf8a6908 | Chris | done = p.leaves.joins(:status). |
| 3256 | sum("COALESCE(CASE WHEN estimated_hours > 0 THEN estimated_hours ELSE NULL END, #{average}) " +
|
||
| 3257 | "* (CASE WHEN is_closed = #{connection.quoted_true} THEN 100 ELSE COALESCE(done_ratio, 0) END)").to_f
|
||
| 3258 | 0:513646585e45 | Chris | progress = done / (average * leaves_count) |
| 3259 | p.done_ratio = progress.round |
||
| 3260 | end |
||
| 3261 | end |
||
| 3262 | 441:cbce1fd3b1b7 | Chris | |
| 3263 | 0:513646585e45 | Chris | # estimate = sum of leaves estimates |
| 3264 | p.estimated_hours = p.leaves.sum(:estimated_hours).to_f |
||
| 3265 | p.estimated_hours = nil if p.estimated_hours == 0.0 |
||
| 3266 | 441:cbce1fd3b1b7 | Chris | |
| 3267 | 0:513646585e45 | Chris | # ancestors will be recursively updated |
| 3268 | 1115:433d4f72a19b | Chris | p.save(:validate => false) |
| 3269 | 0:513646585e45 | Chris | end |
| 3270 | end |
||
| 3271 | 441:cbce1fd3b1b7 | Chris | |
| 3272 | 0:513646585e45 | Chris | # Update issues so their versions are not pointing to a |
| 3273 | # fixed_version that is not shared with the issue's project |
||
| 3274 | def self.update_versions(conditions=nil) |
||
| 3275 | # Only need to update issues with a fixed_version from |
||
| 3276 | # a different project and that is not systemwide shared |
||
| 3277 | 1464:261b3d9a4903 | Chris | Issue.includes(:project, :fixed_version). |
| 3278 | where("#{Issue.table_name}.fixed_version_id IS NOT NULL" +
|
||
| 3279 | 1115:433d4f72a19b | Chris | " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
|
| 3280 | 1464:261b3d9a4903 | Chris | " AND #{Version.table_name}.sharing <> 'system'").
|
| 3281 | where(conditions).each do |issue| |
||
| 3282 | 0:513646585e45 | Chris | next if issue.project.nil? || issue.fixed_version.nil? |
| 3283 | unless issue.project.shared_versions.include?(issue.fixed_version) |
||
| 3284 | issue.init_journal(User.current) |
||
| 3285 | issue.fixed_version = nil |
||
| 3286 | issue.save |
||
| 3287 | end |
||
| 3288 | end |
||
| 3289 | end |
||
| 3290 | 441:cbce1fd3b1b7 | Chris | |
| 3291 | 1115:433d4f72a19b | Chris | # Callback on file attachment |
| 3292 | 909:cbb26bc654de | Chris | def attachment_added(obj) |
| 3293 | if @current_journal && !obj.new_record? |
||
| 3294 | @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => obj.id, :value => obj.filename) |
||
| 3295 | end |
||
| 3296 | end |
||
| 3297 | |||
| 3298 | # Callback on attachment deletion |
||
| 3299 | 0:513646585e45 | Chris | def attachment_removed(obj) |
| 3300 | 1115:433d4f72a19b | Chris | if @current_journal && !obj.new_record? |
| 3301 | @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => obj.id, :old_value => obj.filename) |
||
| 3302 | @current_journal.save |
||
| 3303 | end |
||
| 3304 | 0:513646585e45 | Chris | end |
| 3305 | 441:cbce1fd3b1b7 | Chris | |
| 3306 | 0:513646585e45 | Chris | # Default assignment based on category |
| 3307 | def default_assign |
||
| 3308 | if assigned_to.nil? && category && category.assigned_to |
||
| 3309 | self.assigned_to = category.assigned_to |
||
| 3310 | end |
||
| 3311 | end |
||
| 3312 | |||
| 3313 | # Updates start/due dates of following issues |
||
| 3314 | def reschedule_following_issues |
||
| 3315 | if start_date_changed? || due_date_changed? |
||
| 3316 | relations_from.each do |relation| |
||
| 3317 | relation.set_issue_to_dates |
||
| 3318 | end |
||
| 3319 | end |
||
| 3320 | end |
||
| 3321 | |||
| 3322 | # Closes duplicates if the issue is being closed |
||
| 3323 | def close_duplicates |
||
| 3324 | if closing? |
||
| 3325 | duplicates.each do |duplicate| |
||
| 3326 | # Reload is need in case the duplicate was updated by a previous duplicate |
||
| 3327 | duplicate.reload |
||
| 3328 | # Don't re-close it if it's already closed |
||
| 3329 | next if duplicate.closed? |
||
| 3330 | # Same user and notes |
||
| 3331 | if @current_journal |
||
| 3332 | duplicate.init_journal(@current_journal.user, @current_journal.notes) |
||
| 3333 | end |
||
| 3334 | duplicate.update_attribute :status, self.status |
||
| 3335 | end |
||
| 3336 | end |
||
| 3337 | end |
||
| 3338 | 441:cbce1fd3b1b7 | Chris | |
| 3339 | 1464:261b3d9a4903 | Chris | # Make sure updated_on is updated when adding a note and set updated_on now |
| 3340 | # so we can set closed_on with the same value on closing |
||
| 3341 | 1115:433d4f72a19b | Chris | def force_updated_on_change |
| 3342 | 1464:261b3d9a4903 | Chris | if @current_journal || changed? |
| 3343 | 1115:433d4f72a19b | Chris | self.updated_on = current_time_from_proper_timezone |
| 3344 | 1464:261b3d9a4903 | Chris | if new_record? |
| 3345 | self.created_on = updated_on |
||
| 3346 | end |
||
| 3347 | end |
||
| 3348 | end |
||
| 3349 | |||
| 3350 | # Callback for setting closed_on when the issue is closed. |
||
| 3351 | # The closed_on attribute stores the time of the last closing |
||
| 3352 | # and is preserved when the issue is reopened. |
||
| 3353 | def update_closed_on |
||
| 3354 | if closing? || (new_record? && closed?) |
||
| 3355 | self.closed_on = updated_on |
||
| 3356 | 1115:433d4f72a19b | Chris | end |
| 3357 | end |
||
| 3358 | |||
| 3359 | 0:513646585e45 | Chris | # Saves the changes in a Journal |
| 3360 | # Called after_save |
||
| 3361 | def create_journal |
||
| 3362 | if @current_journal |
||
| 3363 | # attributes changes |
||
| 3364 | 1115:433d4f72a19b | Chris | if @attributes_before_change |
| 3365 | 1464:261b3d9a4903 | Chris | (Issue.column_names - %w(id root_id lft rgt lock_version created_on updated_on closed_on)).each {|c|
|
| 3366 | 1115:433d4f72a19b | Chris | before = @attributes_before_change[c] |
| 3367 | after = send(c) |
||
| 3368 | next if before == after || (before.blank? && after.blank?) |
||
| 3369 | @current_journal.details << JournalDetail.new(:property => 'attr', |
||
| 3370 | :prop_key => c, |
||
| 3371 | :old_value => before, |
||
| 3372 | :value => after) |
||
| 3373 | } |
||
| 3374 | end |
||
| 3375 | if @custom_values_before_change |
||
| 3376 | # custom fields changes |
||
| 3377 | custom_field_values.each {|c|
|
||
| 3378 | before = @custom_values_before_change[c.custom_field_id] |
||
| 3379 | after = c.value |
||
| 3380 | next if before == after || (before.blank? && after.blank?) |
||
| 3381 | |||
| 3382 | if before.is_a?(Array) || after.is_a?(Array) |
||
| 3383 | before = [before] unless before.is_a?(Array) |
||
| 3384 | after = [after] unless after.is_a?(Array) |
||
| 3385 | |||
| 3386 | # values removed |
||
| 3387 | (before - after).reject(&:blank?).each do |value| |
||
| 3388 | @current_journal.details << JournalDetail.new(:property => 'cf', |
||
| 3389 | :prop_key => c.custom_field_id, |
||
| 3390 | :old_value => value, |
||
| 3391 | :value => nil) |
||
| 3392 | end |
||
| 3393 | # values added |
||
| 3394 | (after - before).reject(&:blank?).each do |value| |
||
| 3395 | @current_journal.details << JournalDetail.new(:property => 'cf', |
||
| 3396 | :prop_key => c.custom_field_id, |
||
| 3397 | :old_value => nil, |
||
| 3398 | :value => value) |
||
| 3399 | end |
||
| 3400 | else |
||
| 3401 | @current_journal.details << JournalDetail.new(:property => 'cf', |
||
| 3402 | :prop_key => c.custom_field_id, |
||
| 3403 | :old_value => before, |
||
| 3404 | :value => after) |
||
| 3405 | end |
||
| 3406 | } |
||
| 3407 | end |
||
| 3408 | 0:513646585e45 | Chris | @current_journal.save |
| 3409 | # reset current journal |
||
| 3410 | init_journal @current_journal.user, @current_journal.notes |
||
| 3411 | end |
||
| 3412 | end |
||
| 3413 | |||
| 3414 | 1464:261b3d9a4903 | Chris | def send_notification |
| 3415 | if Setting.notified_events.include?('issue_added')
|
||
| 3416 | Mailer.deliver_issue_add(self) |
||
| 3417 | end |
||
| 3418 | end |
||
| 3419 | |||
| 3420 | # Stores the previous assignee so we can still have access |
||
| 3421 | # to it during after_save callbacks (assigned_to_id_was is reset) |
||
| 3422 | def set_assigned_to_was |
||
| 3423 | @previous_assigned_to_id = assigned_to_id_was |
||
| 3424 | end |
||
| 3425 | |||
| 3426 | # Clears the previous assignee at the end of after_save callbacks |
||
| 3427 | def clear_assigned_to_was |
||
| 3428 | @assigned_to_was = nil |
||
| 3429 | @previous_assigned_to_id = nil |
||
| 3430 | end |
||
| 3431 | |||
| 3432 | 0:513646585e45 | Chris | # Query generator for selecting groups of issue counts for a project |
| 3433 | # based on specific criteria |
||
| 3434 | # |
||
| 3435 | # Options |
||
| 3436 | # * project - Project to search in. |
||
| 3437 | # * field - String. Issue field to key off of in the grouping. |
||
| 3438 | # * joins - String. The table name to join against. |
||
| 3439 | def self.count_and_group_by(options) |
||
| 3440 | project = options.delete(:project) |
||
| 3441 | select_field = options.delete(:field) |
||
| 3442 | joins = options.delete(:joins) |
||
| 3443 | |||
| 3444 | 441:cbce1fd3b1b7 | Chris | where = "#{Issue.table_name}.#{select_field}=j.id"
|
| 3445 | |||
| 3446 | 0:513646585e45 | Chris | ActiveRecord::Base.connection.select_all("select s.id as status_id,
|
| 3447 | s.is_closed as closed, |
||
| 3448 | j.id as #{select_field},
|
||
| 3449 | 441:cbce1fd3b1b7 | Chris | count(#{Issue.table_name}.id) as total
|
| 3450 | 0:513646585e45 | Chris | from |
| 3451 | 441:cbce1fd3b1b7 | Chris | #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s, #{joins} j
|
| 3452 | 0:513646585e45 | Chris | where |
| 3453 | 441:cbce1fd3b1b7 | Chris | #{Issue.table_name}.status_id=s.id
|
| 3454 | 0:513646585e45 | Chris | and #{where}
|
| 3455 | 441:cbce1fd3b1b7 | Chris | and #{Issue.table_name}.project_id=#{Project.table_name}.id
|
| 3456 | and #{visible_condition(User.current, :project => project)}
|
||
| 3457 | 0:513646585e45 | Chris | group by s.id, s.is_closed, j.id") |
| 3458 | end |
||
| 3459 | end |
||
| 3460 | 507:0c939c159af4 | Chris | # Redmine - project management software |
| 3461 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 3462 | 0:513646585e45 | Chris | # |
| 3463 | # This program is free software; you can redistribute it and/or |
||
| 3464 | # modify it under the terms of the GNU General Public License |
||
| 3465 | # as published by the Free Software Foundation; either version 2 |
||
| 3466 | # of the License, or (at your option) any later version. |
||
| 3467 | 909:cbb26bc654de | Chris | # |
| 3468 | 0:513646585e45 | Chris | # This program is distributed in the hope that it will be useful, |
| 3469 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 3470 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 3471 | # GNU General Public License for more details. |
||
| 3472 | 909:cbb26bc654de | Chris | # |
| 3473 | 0:513646585e45 | Chris | # You should have received a copy of the GNU General Public License |
| 3474 | # along with this program; if not, write to the Free Software |
||
| 3475 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 3476 | |||
| 3477 | class IssueCategory < ActiveRecord::Base |
||
| 3478 | 929:5f33065ddc4b | Chris | include Redmine::SafeAttributes |
| 3479 | 0:513646585e45 | Chris | belongs_to :project |
| 3480 | 909:cbb26bc654de | Chris | belongs_to :assigned_to, :class_name => 'Principal', :foreign_key => 'assigned_to_id' |
| 3481 | 0:513646585e45 | Chris | has_many :issues, :foreign_key => 'category_id', :dependent => :nullify |
| 3482 | 909:cbb26bc654de | Chris | |
| 3483 | 0:513646585e45 | Chris | validates_presence_of :name |
| 3484 | validates_uniqueness_of :name, :scope => [:project_id] |
||
| 3485 | validates_length_of :name, :maximum => 30 |
||
| 3486 | 1464:261b3d9a4903 | Chris | |
| 3487 | 929:5f33065ddc4b | Chris | safe_attributes 'name', 'assigned_to_id' |
| 3488 | 909:cbb26bc654de | Chris | |
| 3489 | 1115:433d4f72a19b | Chris | scope :named, lambda {|arg| where("LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip)}
|
| 3490 | 909:cbb26bc654de | Chris | |
| 3491 | 0:513646585e45 | Chris | alias :destroy_without_reassign :destroy |
| 3492 | 909:cbb26bc654de | Chris | |
| 3493 | 0:513646585e45 | Chris | # Destroy the category |
| 3494 | # If a category is specified, issues are reassigned to this category |
||
| 3495 | def destroy(reassign_to = nil) |
||
| 3496 | if reassign_to && reassign_to.is_a?(IssueCategory) && reassign_to.project == self.project |
||
| 3497 | 1517:dffacf8a6908 | Chris | Issue.where({:category_id => id}).update_all({:category_id => reassign_to.id})
|
| 3498 | 0:513646585e45 | Chris | end |
| 3499 | destroy_without_reassign |
||
| 3500 | end |
||
| 3501 | 909:cbb26bc654de | Chris | |
| 3502 | 0:513646585e45 | Chris | def <=>(category) |
| 3503 | name <=> category.name |
||
| 3504 | end |
||
| 3505 | 909:cbb26bc654de | Chris | |
| 3506 | 0:513646585e45 | Chris | def to_s; name end |
| 3507 | end |
||
| 3508 | 909:cbb26bc654de | Chris | # Redmine - project management software |
| 3509 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 3510 | 0:513646585e45 | Chris | # |
| 3511 | # This program is free software; you can redistribute it and/or |
||
| 3512 | # modify it under the terms of the GNU General Public License |
||
| 3513 | # as published by the Free Software Foundation; either version 2 |
||
| 3514 | # of the License, or (at your option) any later version. |
||
| 3515 | 909:cbb26bc654de | Chris | # |
| 3516 | 0:513646585e45 | Chris | # This program is distributed in the hope that it will be useful, |
| 3517 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 3518 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 3519 | # GNU General Public License for more details. |
||
| 3520 | 909:cbb26bc654de | Chris | # |
| 3521 | 0:513646585e45 | Chris | # You should have received a copy of the GNU General Public License |
| 3522 | # along with this program; if not, write to the Free Software |
||
| 3523 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 3524 | |||
| 3525 | class IssueCustomField < CustomField |
||
| 3526 | has_and_belongs_to_many :projects, :join_table => "#{table_name_prefix}custom_fields_projects#{table_name_suffix}", :foreign_key => "custom_field_id"
|
||
| 3527 | has_and_belongs_to_many :trackers, :join_table => "#{table_name_prefix}custom_fields_trackers#{table_name_suffix}", :foreign_key => "custom_field_id"
|
||
| 3528 | has_many :issues, :through => :issue_custom_values |
||
| 3529 | 909:cbb26bc654de | Chris | |
| 3530 | 0:513646585e45 | Chris | def type_name |
| 3531 | :label_issue_plural |
||
| 3532 | end |
||
| 3533 | 1464:261b3d9a4903 | Chris | |
| 3534 | def visible_by?(project, user=User.current) |
||
| 3535 | super || (roles & user.roles_for_project(project)).present? |
||
| 3536 | end |
||
| 3537 | |||
| 3538 | 1517:dffacf8a6908 | Chris | def visibility_by_project_condition(project_key=nil, user=User.current, id_column=nil) |
| 3539 | 1464:261b3d9a4903 | Chris | sql = super |
| 3540 | 1517:dffacf8a6908 | Chris | id_column ||= id |
| 3541 | tracker_condition = "#{Issue.table_name}.tracker_id IN (SELECT tracker_id FROM #{table_name_prefix}custom_fields_trackers#{table_name_suffix} WHERE custom_field_id = #{id_column})"
|
||
| 3542 | project_condition = "EXISTS (SELECT 1 FROM #{CustomField.table_name} ifa WHERE ifa.is_for_all = #{connection.quoted_true} AND ifa.id = #{id_column})" +
|
||
| 3543 | " OR #{Issue.table_name}.project_id IN (SELECT project_id FROM #{table_name_prefix}custom_fields_projects#{table_name_suffix} WHERE custom_field_id = #{id_column})"
|
||
| 3544 | |||
| 3545 | "((#{sql}) AND (#{tracker_condition}) AND (#{project_condition}))"
|
||
| 3546 | 1464:261b3d9a4903 | Chris | end |
| 3547 | |||
| 3548 | def validate_custom_field |
||
| 3549 | super |
||
| 3550 | errors.add(:base, l(:label_role_plural) + ' ' + l('activerecord.errors.messages.blank')) unless visible? || roles.present?
|
||
| 3551 | end |
||
| 3552 | 0:513646585e45 | Chris | end |
| 3553 | 909:cbb26bc654de | Chris | # Redmine - project management software |
| 3554 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 3555 | 0:513646585e45 | Chris | # |
| 3556 | # This program is free software; you can redistribute it and/or |
||
| 3557 | # modify it under the terms of the GNU General Public License |
||
| 3558 | # as published by the Free Software Foundation; either version 2 |
||
| 3559 | # of the License, or (at your option) any later version. |
||
| 3560 | 909:cbb26bc654de | Chris | # |
| 3561 | 0:513646585e45 | Chris | # This program is distributed in the hope that it will be useful, |
| 3562 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 3563 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 3564 | # GNU General Public License for more details. |
||
| 3565 | 909:cbb26bc654de | Chris | # |
| 3566 | 0:513646585e45 | Chris | # You should have received a copy of the GNU General Public License |
| 3567 | # along with this program; if not, write to the Free Software |
||
| 3568 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 3569 | |||
| 3570 | class IssuePriority < Enumeration |
||
| 3571 | has_many :issues, :foreign_key => 'priority_id' |
||
| 3572 | |||
| 3573 | 1115:433d4f72a19b | Chris | after_destroy {|priority| priority.class.compute_position_names}
|
| 3574 | after_save {|priority| priority.class.compute_position_names if priority.position_changed? && priority.position}
|
||
| 3575 | |||
| 3576 | 0:513646585e45 | Chris | OptionName = :enumeration_issue_priorities |
| 3577 | |||
| 3578 | def option_name |
||
| 3579 | OptionName |
||
| 3580 | end |
||
| 3581 | |||
| 3582 | def objects_count |
||
| 3583 | issues.count |
||
| 3584 | end |
||
| 3585 | |||
| 3586 | def transfer_relations(to) |
||
| 3587 | issues.update_all("priority_id = #{to.id}")
|
||
| 3588 | end |
||
| 3589 | 1115:433d4f72a19b | Chris | |
| 3590 | def css_classes |
||
| 3591 | "priority-#{id} priority-#{position_name}"
|
||
| 3592 | end |
||
| 3593 | |||
| 3594 | # Clears position_name for all priorities |
||
| 3595 | # Called from migration 20121026003537_populate_enumerations_position_name |
||
| 3596 | def self.clear_position_names |
||
| 3597 | update_all :position_name => nil |
||
| 3598 | end |
||
| 3599 | |||
| 3600 | # Updates position_name for active priorities |
||
| 3601 | # Called from migration 20121026003537_populate_enumerations_position_name |
||
| 3602 | def self.compute_position_names |
||
| 3603 | 1517:dffacf8a6908 | Chris | priorities = where(:active => true).sort_by(&:position) |
| 3604 | 1115:433d4f72a19b | Chris | if priorities.any? |
| 3605 | default = priorities.detect(&:is_default?) || priorities[(priorities.size - 1) / 2] |
||
| 3606 | priorities.each_with_index do |priority, index| |
||
| 3607 | name = case |
||
| 3608 | when priority.position == default.position |
||
| 3609 | "default" |
||
| 3610 | when priority.position < default.position |
||
| 3611 | index == 0 ? "lowest" : "low#{index+1}"
|
||
| 3612 | else |
||
| 3613 | index == (priorities.size - 1) ? "highest" : "high#{priorities.size - index}"
|
||
| 3614 | end |
||
| 3615 | |||
| 3616 | 1517:dffacf8a6908 | Chris | where(:id => priority.id).update_all({:position_name => name})
|
| 3617 | 1115:433d4f72a19b | Chris | end |
| 3618 | end |
||
| 3619 | end |
||
| 3620 | 0:513646585e45 | Chris | end |
| 3621 | 909:cbb26bc654de | Chris | # Redmine - project management software |
| 3622 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 3623 | 0:513646585e45 | Chris | # |
| 3624 | # This program is free software; you can redistribute it and/or |
||
| 3625 | # modify it under the terms of the GNU General Public License |
||
| 3626 | # as published by the Free Software Foundation; either version 2 |
||
| 3627 | # of the License, or (at your option) any later version. |
||
| 3628 | 909:cbb26bc654de | Chris | # |
| 3629 | 0:513646585e45 | Chris | # This program is distributed in the hope that it will be useful, |
| 3630 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 3631 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 3632 | # GNU General Public License for more details. |
||
| 3633 | 909:cbb26bc654de | Chris | # |
| 3634 | 0:513646585e45 | Chris | # You should have received a copy of the GNU General Public License |
| 3635 | # along with this program; if not, write to the Free Software |
||
| 3636 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 3637 | |||
| 3638 | class IssuePriorityCustomField < CustomField |
||
| 3639 | def type_name |
||
| 3640 | :enumeration_issue_priorities |
||
| 3641 | end |
||
| 3642 | end |
||
| 3643 | |||
| 3644 | 1464:261b3d9a4903 | Chris | # Redmine - project management software |
| 3645 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 3646 | 1464:261b3d9a4903 | Chris | # |
| 3647 | # This program is free software; you can redistribute it and/or |
||
| 3648 | # modify it under the terms of the GNU General Public License |
||
| 3649 | # as published by the Free Software Foundation; either version 2 |
||
| 3650 | # of the License, or (at your option) any later version. |
||
| 3651 | # |
||
| 3652 | # This program is distributed in the hope that it will be useful, |
||
| 3653 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 3654 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 3655 | # GNU General Public License for more details. |
||
| 3656 | # |
||
| 3657 | # You should have received a copy of the GNU General Public License |
||
| 3658 | # along with this program; if not, write to the Free Software |
||
| 3659 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 3660 | |||
| 3661 | class IssueQuery < Query |
||
| 3662 | |||
| 3663 | self.queried_class = Issue |
||
| 3664 | |||
| 3665 | self.available_columns = [ |
||
| 3666 | QueryColumn.new(:id, :sortable => "#{Issue.table_name}.id", :default_order => 'desc', :caption => '#', :frozen => true),
|
||
| 3667 | QueryColumn.new(:project, :sortable => "#{Project.table_name}.name", :groupable => true),
|
||
| 3668 | QueryColumn.new(:tracker, :sortable => "#{Tracker.table_name}.position", :groupable => true),
|
||
| 3669 | QueryColumn.new(:parent, :sortable => ["#{Issue.table_name}.root_id", "#{Issue.table_name}.lft ASC"], :default_order => 'desc', :caption => :field_parent_issue),
|
||
| 3670 | QueryColumn.new(:status, :sortable => "#{IssueStatus.table_name}.position", :groupable => true),
|
||
| 3671 | QueryColumn.new(:priority, :sortable => "#{IssuePriority.table_name}.position", :default_order => 'desc', :groupable => true),
|
||
| 3672 | QueryColumn.new(:subject, :sortable => "#{Issue.table_name}.subject"),
|
||
| 3673 | QueryColumn.new(:author, :sortable => lambda {User.fields_for_order_statement("authors")}, :groupable => true),
|
||
| 3674 | QueryColumn.new(:assigned_to, :sortable => lambda {User.fields_for_order_statement}, :groupable => true),
|
||
| 3675 | QueryColumn.new(:updated_on, :sortable => "#{Issue.table_name}.updated_on", :default_order => 'desc'),
|
||
| 3676 | QueryColumn.new(:category, :sortable => "#{IssueCategory.table_name}.name", :groupable => true),
|
||
| 3677 | QueryColumn.new(:fixed_version, :sortable => lambda {Version.fields_for_order_statement}, :groupable => true),
|
||
| 3678 | QueryColumn.new(:start_date, :sortable => "#{Issue.table_name}.start_date"),
|
||
| 3679 | QueryColumn.new(:due_date, :sortable => "#{Issue.table_name}.due_date"),
|
||
| 3680 | QueryColumn.new(:estimated_hours, :sortable => "#{Issue.table_name}.estimated_hours"),
|
||
| 3681 | QueryColumn.new(:done_ratio, :sortable => "#{Issue.table_name}.done_ratio", :groupable => true),
|
||
| 3682 | QueryColumn.new(:created_on, :sortable => "#{Issue.table_name}.created_on", :default_order => 'desc'),
|
||
| 3683 | QueryColumn.new(:closed_on, :sortable => "#{Issue.table_name}.closed_on", :default_order => 'desc'),
|
||
| 3684 | QueryColumn.new(:relations, :caption => :label_related_issues), |
||
| 3685 | QueryColumn.new(:description, :inline => false) |
||
| 3686 | ] |
||
| 3687 | |||
| 3688 | scope :visible, lambda {|*args|
|
||
| 3689 | user = args.shift || User.current |
||
| 3690 | base = Project.allowed_to_condition(user, :view_issues, *args) |
||
| 3691 | scope = includes(:project).where("#{table_name}.project_id IS NULL OR (#{base})")
|
||
| 3692 | |||
| 3693 | if user.admin? |
||
| 3694 | scope.where("#{table_name}.visibility <> ? OR #{table_name}.user_id = ?", VISIBILITY_PRIVATE, user.id)
|
||
| 3695 | elsif user.memberships.any? |
||
| 3696 | scope.where("#{table_name}.visibility = ?" +
|
||
| 3697 | " OR (#{table_name}.visibility = ? AND #{table_name}.id IN (" +
|
||
| 3698 | "SELECT DISTINCT q.id FROM #{table_name} q" +
|
||
| 3699 | " INNER JOIN #{table_name_prefix}queries_roles#{table_name_suffix} qr on qr.query_id = q.id" +
|
||
| 3700 | " INNER JOIN #{MemberRole.table_name} mr ON mr.role_id = qr.role_id" +
|
||
| 3701 | " INNER JOIN #{Member.table_name} m ON m.id = mr.member_id AND m.user_id = ?" +
|
||
| 3702 | " WHERE q.project_id IS NULL OR q.project_id = m.project_id))" + |
||
| 3703 | " OR #{table_name}.user_id = ?",
|
||
| 3704 | VISIBILITY_PUBLIC, VISIBILITY_ROLES, user.id, user.id) |
||
| 3705 | elsif user.logged? |
||
| 3706 | scope.where("#{table_name}.visibility = ? OR #{table_name}.user_id = ?", VISIBILITY_PUBLIC, user.id)
|
||
| 3707 | else |
||
| 3708 | scope.where("#{table_name}.visibility = ?", VISIBILITY_PUBLIC)
|
||
| 3709 | end |
||
| 3710 | } |
||
| 3711 | |||
| 3712 | def initialize(attributes=nil, *args) |
||
| 3713 | super attributes |
||
| 3714 | self.filters ||= { 'status_id' => {:operator => "o", :values => [""]} }
|
||
| 3715 | end |
||
| 3716 | |||
| 3717 | # Returns true if the query is visible to +user+ or the current user. |
||
| 3718 | def visible?(user=User.current) |
||
| 3719 | return true if user.admin? |
||
| 3720 | return false unless project.nil? || user.allowed_to?(:view_issues, project) |
||
| 3721 | case visibility |
||
| 3722 | when VISIBILITY_PUBLIC |
||
| 3723 | true |
||
| 3724 | when VISIBILITY_ROLES |
||
| 3725 | if project |
||
| 3726 | (user.roles_for_project(project) & roles).any? |
||
| 3727 | else |
||
| 3728 | Member.where(:user_id => user.id).joins(:roles).where(:member_roles => {:role_id => roles.map(&:id)}).any?
|
||
| 3729 | end |
||
| 3730 | else |
||
| 3731 | user == self.user |
||
| 3732 | end |
||
| 3733 | end |
||
| 3734 | |||
| 3735 | def is_private? |
||
| 3736 | visibility == VISIBILITY_PRIVATE |
||
| 3737 | end |
||
| 3738 | |||
| 3739 | def is_public? |
||
| 3740 | !is_private? |
||
| 3741 | end |
||
| 3742 | |||
| 3743 | def draw_relations |
||
| 3744 | r = options[:draw_relations] |
||
| 3745 | r.nil? || r == '1' |
||
| 3746 | end |
||
| 3747 | |||
| 3748 | def draw_relations=(arg) |
||
| 3749 | options[:draw_relations] = (arg == '0' ? '0' : nil) |
||
| 3750 | end |
||
| 3751 | |||
| 3752 | def draw_progress_line |
||
| 3753 | r = options[:draw_progress_line] |
||
| 3754 | r == '1' |
||
| 3755 | end |
||
| 3756 | |||
| 3757 | def draw_progress_line=(arg) |
||
| 3758 | options[:draw_progress_line] = (arg == '1' ? '1' : nil) |
||
| 3759 | end |
||
| 3760 | |||
| 3761 | def build_from_params(params) |
||
| 3762 | super |
||
| 3763 | self.draw_relations = params[:draw_relations] || (params[:query] && params[:query][:draw_relations]) |
||
| 3764 | self.draw_progress_line = params[:draw_progress_line] || (params[:query] && params[:query][:draw_progress_line]) |
||
| 3765 | self |
||
| 3766 | end |
||
| 3767 | |||
| 3768 | def initialize_available_filters |
||
| 3769 | principals = [] |
||
| 3770 | subprojects = [] |
||
| 3771 | versions = [] |
||
| 3772 | categories = [] |
||
| 3773 | issue_custom_fields = [] |
||
| 3774 | |||
| 3775 | if project |
||
| 3776 | principals += project.principals.sort |
||
| 3777 | unless project.leaf? |
||
| 3778 | subprojects = project.descendants.visible.all |
||
| 3779 | principals += Principal.member_of(subprojects) |
||
| 3780 | end |
||
| 3781 | versions = project.shared_versions.all |
||
| 3782 | categories = project.issue_categories.all |
||
| 3783 | issue_custom_fields = project.all_issue_custom_fields |
||
| 3784 | else |
||
| 3785 | if all_projects.any? |
||
| 3786 | principals += Principal.member_of(all_projects) |
||
| 3787 | end |
||
| 3788 | 1517:dffacf8a6908 | Chris | versions = Version.visible.where(:sharing => 'system').all |
| 3789 | 1464:261b3d9a4903 | Chris | issue_custom_fields = IssueCustomField.where(:is_for_all => true) |
| 3790 | end |
||
| 3791 | principals.uniq! |
||
| 3792 | principals.sort! |
||
| 3793 | users = principals.select {|p| p.is_a?(User)}
|
||
| 3794 | |||
| 3795 | add_available_filter "status_id", |
||
| 3796 | 1517:dffacf8a6908 | Chris | :type => :list_status, :values => IssueStatus.sorted.collect{|s| [s.name, s.id.to_s] }
|
| 3797 | 1464:261b3d9a4903 | Chris | |
| 3798 | if project.nil? |
||
| 3799 | project_values = [] |
||
| 3800 | if User.current.logged? && User.current.memberships.any? |
||
| 3801 | project_values << ["<< #{l(:label_my_projects).downcase} >>", "mine"]
|
||
| 3802 | end |
||
| 3803 | project_values += all_projects_values |
||
| 3804 | add_available_filter("project_id",
|
||
| 3805 | :type => :list, :values => project_values |
||
| 3806 | ) unless project_values.empty? |
||
| 3807 | end |
||
| 3808 | |||
| 3809 | add_available_filter "tracker_id", |
||
| 3810 | :type => :list, :values => trackers.collect{|s| [s.name, s.id.to_s] }
|
||
| 3811 | add_available_filter "priority_id", |
||
| 3812 | :type => :list, :values => IssuePriority.all.collect{|s| [s.name, s.id.to_s] }
|
||
| 3813 | |||
| 3814 | author_values = [] |
||
| 3815 | author_values << ["<< #{l(:label_me)} >>", "me"] if User.current.logged?
|
||
| 3816 | author_values += users.collect{|s| [s.name, s.id.to_s] }
|
||
| 3817 | add_available_filter("author_id",
|
||
| 3818 | :type => :list, :values => author_values |
||
| 3819 | ) unless author_values.empty? |
||
| 3820 | |||
| 3821 | assigned_to_values = [] |
||
| 3822 | assigned_to_values << ["<< #{l(:label_me)} >>", "me"] if User.current.logged?
|
||
| 3823 | assigned_to_values += (Setting.issue_group_assignment? ? |
||
| 3824 | principals : users).collect{|s| [s.name, s.id.to_s] }
|
||
| 3825 | add_available_filter("assigned_to_id",
|
||
| 3826 | :type => :list_optional, :values => assigned_to_values |
||
| 3827 | ) unless assigned_to_values.empty? |
||
| 3828 | |||
| 3829 | group_values = Group.all.collect {|g| [g.name, g.id.to_s] }
|
||
| 3830 | add_available_filter("member_of_group",
|
||
| 3831 | :type => :list_optional, :values => group_values |
||
| 3832 | ) unless group_values.empty? |
||
| 3833 | |||
| 3834 | role_values = Role.givable.collect {|r| [r.name, r.id.to_s] }
|
||
| 3835 | add_available_filter("assigned_to_role",
|
||
| 3836 | :type => :list_optional, :values => role_values |
||
| 3837 | ) unless role_values.empty? |
||
| 3838 | |||
| 3839 | if versions.any? |
||
| 3840 | add_available_filter "fixed_version_id", |
||
| 3841 | :type => :list_optional, |
||
| 3842 | :values => versions.sort.collect{|s| ["#{s.project.name} - #{s.name}", s.id.to_s] }
|
||
| 3843 | end |
||
| 3844 | |||
| 3845 | if categories.any? |
||
| 3846 | add_available_filter "category_id", |
||
| 3847 | :type => :list_optional, |
||
| 3848 | :values => categories.collect{|s| [s.name, s.id.to_s] }
|
||
| 3849 | end |
||
| 3850 | |||
| 3851 | add_available_filter "subject", :type => :text |
||
| 3852 | add_available_filter "created_on", :type => :date_past |
||
| 3853 | add_available_filter "updated_on", :type => :date_past |
||
| 3854 | add_available_filter "closed_on", :type => :date_past |
||
| 3855 | add_available_filter "start_date", :type => :date |
||
| 3856 | add_available_filter "due_date", :type => :date |
||
| 3857 | add_available_filter "estimated_hours", :type => :float |
||
| 3858 | add_available_filter "done_ratio", :type => :integer |
||
| 3859 | |||
| 3860 | if User.current.allowed_to?(:set_issues_private, nil, :global => true) || |
||
| 3861 | User.current.allowed_to?(:set_own_issues_private, nil, :global => true) |
||
| 3862 | add_available_filter "is_private", |
||
| 3863 | :type => :list, |
||
| 3864 | :values => [[l(:general_text_yes), "1"], [l(:general_text_no), "0"]] |
||
| 3865 | end |
||
| 3866 | |||
| 3867 | if User.current.logged? |
||
| 3868 | add_available_filter "watcher_id", |
||
| 3869 | :type => :list, :values => [["<< #{l(:label_me)} >>", "me"]]
|
||
| 3870 | end |
||
| 3871 | |||
| 3872 | if subprojects.any? |
||
| 3873 | add_available_filter "subproject_id", |
||
| 3874 | :type => :list_subprojects, |
||
| 3875 | :values => subprojects.collect{|s| [s.name, s.id.to_s] }
|
||
| 3876 | end |
||
| 3877 | |||
| 3878 | add_custom_fields_filters(issue_custom_fields) |
||
| 3879 | |||
| 3880 | add_associations_custom_fields_filters :project, :author, :assigned_to, :fixed_version |
||
| 3881 | |||
| 3882 | IssueRelation::TYPES.each do |relation_type, options| |
||
| 3883 | add_available_filter relation_type, :type => :relation, :label => options[:name] |
||
| 3884 | end |
||
| 3885 | |||
| 3886 | Tracker.disabled_core_fields(trackers).each {|field|
|
||
| 3887 | delete_available_filter field |
||
| 3888 | } |
||
| 3889 | end |
||
| 3890 | |||
| 3891 | def available_columns |
||
| 3892 | return @available_columns if @available_columns |
||
| 3893 | @available_columns = self.class.available_columns.dup |
||
| 3894 | @available_columns += (project ? |
||
| 3895 | project.all_issue_custom_fields : |
||
| 3896 | IssueCustomField |
||
| 3897 | ).visible.collect {|cf| QueryCustomFieldColumn.new(cf) }
|
||
| 3898 | |||
| 3899 | if User.current.allowed_to?(:view_time_entries, project, :global => true) |
||
| 3900 | index = nil |
||
| 3901 | @available_columns.each_with_index {|column, i| index = i if column.name == :estimated_hours}
|
||
| 3902 | index = (index ? index + 1 : -1) |
||
| 3903 | # insert the column after estimated_hours or at the end |
||
| 3904 | @available_columns.insert index, QueryColumn.new(:spent_hours, |
||
| 3905 | :sortable => "COALESCE((SELECT SUM(hours) FROM #{TimeEntry.table_name} WHERE #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id), 0)",
|
||
| 3906 | :default_order => 'desc', |
||
| 3907 | :caption => :label_spent_time |
||
| 3908 | ) |
||
| 3909 | end |
||
| 3910 | |||
| 3911 | if User.current.allowed_to?(:set_issues_private, nil, :global => true) || |
||
| 3912 | User.current.allowed_to?(:set_own_issues_private, nil, :global => true) |
||
| 3913 | @available_columns << QueryColumn.new(:is_private, :sortable => "#{Issue.table_name}.is_private")
|
||
| 3914 | end |
||
| 3915 | |||
| 3916 | disabled_fields = Tracker.disabled_core_fields(trackers).map {|field| field.sub(/_id$/, '')}
|
||
| 3917 | @available_columns.reject! {|column|
|
||
| 3918 | disabled_fields.include?(column.name.to_s) |
||
| 3919 | } |
||
| 3920 | |||
| 3921 | @available_columns |
||
| 3922 | end |
||
| 3923 | |||
| 3924 | def default_columns_names |
||
| 3925 | @default_columns_names ||= begin |
||
| 3926 | default_columns = Setting.issue_list_default_columns.map(&:to_sym) |
||
| 3927 | |||
| 3928 | project.present? ? default_columns : [:project] | default_columns |
||
| 3929 | end |
||
| 3930 | end |
||
| 3931 | |||
| 3932 | # Returns the issue count |
||
| 3933 | def issue_count |
||
| 3934 | Issue.visible.joins(:status, :project).where(statement).count |
||
| 3935 | rescue ::ActiveRecord::StatementInvalid => e |
||
| 3936 | raise StatementInvalid.new(e.message) |
||
| 3937 | end |
||
| 3938 | |||
| 3939 | # Returns the issue count by group or nil if query is not grouped |
||
| 3940 | def issue_count_by_group |
||
| 3941 | r = nil |
||
| 3942 | if grouped? |
||
| 3943 | begin |
||
| 3944 | # Rails3 will raise an (unexpected) RecordNotFound if there's only a nil group value |
||
| 3945 | r = Issue.visible. |
||
| 3946 | joins(:status, :project). |
||
| 3947 | where(statement). |
||
| 3948 | joins(joins_for_order_statement(group_by_statement)). |
||
| 3949 | group(group_by_statement). |
||
| 3950 | count |
||
| 3951 | rescue ActiveRecord::RecordNotFound |
||
| 3952 | r = {nil => issue_count}
|
||
| 3953 | end |
||
| 3954 | c = group_by_column |
||
| 3955 | if c.is_a?(QueryCustomFieldColumn) |
||
| 3956 | r = r.keys.inject({}) {|h, k| h[c.custom_field.cast_value(k)] = r[k]; h}
|
||
| 3957 | end |
||
| 3958 | end |
||
| 3959 | r |
||
| 3960 | rescue ::ActiveRecord::StatementInvalid => e |
||
| 3961 | raise StatementInvalid.new(e.message) |
||
| 3962 | end |
||
| 3963 | |||
| 3964 | # Returns the issues |
||
| 3965 | # Valid options are :order, :offset, :limit, :include, :conditions |
||
| 3966 | def issues(options={})
|
||
| 3967 | order_option = [group_by_sort_order, options[:order]].flatten.reject(&:blank?) |
||
| 3968 | |||
| 3969 | scope = Issue.visible. |
||
| 3970 | joins(:status, :project). |
||
| 3971 | where(statement). |
||
| 3972 | includes(([:status, :project] + (options[:include] || [])).uniq). |
||
| 3973 | where(options[:conditions]). |
||
| 3974 | order(order_option). |
||
| 3975 | joins(joins_for_order_statement(order_option.join(','))).
|
||
| 3976 | limit(options[:limit]). |
||
| 3977 | offset(options[:offset]) |
||
| 3978 | |||
| 3979 | 1517:dffacf8a6908 | Chris | scope = scope.preload(:custom_values) |
| 3980 | if has_column?(:author) |
||
| 3981 | scope = scope.preload(:author) |
||
| 3982 | 1464:261b3d9a4903 | Chris | end |
| 3983 | |||
| 3984 | issues = scope.all |
||
| 3985 | |||
| 3986 | if has_column?(:spent_hours) |
||
| 3987 | Issue.load_visible_spent_hours(issues) |
||
| 3988 | end |
||
| 3989 | if has_column?(:relations) |
||
| 3990 | Issue.load_visible_relations(issues) |
||
| 3991 | end |
||
| 3992 | issues |
||
| 3993 | rescue ::ActiveRecord::StatementInvalid => e |
||
| 3994 | raise StatementInvalid.new(e.message) |
||
| 3995 | end |
||
| 3996 | |||
| 3997 | # Returns the issues ids |
||
| 3998 | def issue_ids(options={})
|
||
| 3999 | order_option = [group_by_sort_order, options[:order]].flatten.reject(&:blank?) |
||
| 4000 | |||
| 4001 | Issue.visible. |
||
| 4002 | joins(:status, :project). |
||
| 4003 | where(statement). |
||
| 4004 | includes(([:status, :project] + (options[:include] || [])).uniq). |
||
| 4005 | where(options[:conditions]). |
||
| 4006 | order(order_option). |
||
| 4007 | joins(joins_for_order_statement(order_option.join(','))).
|
||
| 4008 | limit(options[:limit]). |
||
| 4009 | offset(options[:offset]). |
||
| 4010 | find_ids |
||
| 4011 | rescue ::ActiveRecord::StatementInvalid => e |
||
| 4012 | raise StatementInvalid.new(e.message) |
||
| 4013 | end |
||
| 4014 | |||
| 4015 | # Returns the journals |
||
| 4016 | # Valid options are :order, :offset, :limit |
||
| 4017 | def journals(options={})
|
||
| 4018 | Journal.visible. |
||
| 4019 | joins(:issue => [:project, :status]). |
||
| 4020 | where(statement). |
||
| 4021 | order(options[:order]). |
||
| 4022 | limit(options[:limit]). |
||
| 4023 | offset(options[:offset]). |
||
| 4024 | preload(:details, :user, {:issue => [:project, :author, :tracker, :status]}).
|
||
| 4025 | all |
||
| 4026 | rescue ::ActiveRecord::StatementInvalid => e |
||
| 4027 | raise StatementInvalid.new(e.message) |
||
| 4028 | end |
||
| 4029 | |||
| 4030 | # Returns the versions |
||
| 4031 | # Valid options are :conditions |
||
| 4032 | def versions(options={})
|
||
| 4033 | Version.visible. |
||
| 4034 | where(project_statement). |
||
| 4035 | where(options[:conditions]). |
||
| 4036 | includes(:project). |
||
| 4037 | all |
||
| 4038 | rescue ::ActiveRecord::StatementInvalid => e |
||
| 4039 | raise StatementInvalid.new(e.message) |
||
| 4040 | end |
||
| 4041 | |||
| 4042 | def sql_for_watcher_id_field(field, operator, value) |
||
| 4043 | db_table = Watcher.table_name |
||
| 4044 | "#{Issue.table_name}.id #{ operator == '=' ? 'IN' : 'NOT IN' } (SELECT #{db_table}.watchable_id FROM #{db_table} WHERE #{db_table}.watchable_type='Issue' AND " +
|
||
| 4045 | sql_for_field(field, '=', value, db_table, 'user_id') + ')' |
||
| 4046 | end |
||
| 4047 | |||
| 4048 | def sql_for_member_of_group_field(field, operator, value) |
||
| 4049 | if operator == '*' # Any group |
||
| 4050 | groups = Group.all |
||
| 4051 | operator = '=' # Override the operator since we want to find by assigned_to |
||
| 4052 | elsif operator == "!*" |
||
| 4053 | groups = Group.all |
||
| 4054 | operator = '!' # Override the operator since we want to find by assigned_to |
||
| 4055 | else |
||
| 4056 | 1517:dffacf8a6908 | Chris | groups = Group.where(:id => value).all |
| 4057 | 1464:261b3d9a4903 | Chris | end |
| 4058 | groups ||= [] |
||
| 4059 | |||
| 4060 | members_of_groups = groups.inject([]) {|user_ids, group|
|
||
| 4061 | user_ids + group.user_ids + [group.id] |
||
| 4062 | }.uniq.compact.sort.collect(&:to_s) |
||
| 4063 | |||
| 4064 | '(' + sql_for_field("assigned_to_id", operator, members_of_groups, Issue.table_name, "assigned_to_id", false) + ')'
|
||
| 4065 | end |
||
| 4066 | |||
| 4067 | def sql_for_assigned_to_role_field(field, operator, value) |
||
| 4068 | case operator |
||
| 4069 | when "*", "!*" # Member / Not member |
||
| 4070 | sw = operator == "!*" ? 'NOT' : '' |
||
| 4071 | nl = operator == "!*" ? "#{Issue.table_name}.assigned_to_id IS NULL OR" : ''
|
||
| 4072 | "(#{nl} #{Issue.table_name}.assigned_to_id #{sw} IN (SELECT DISTINCT #{Member.table_name}.user_id FROM #{Member.table_name}" +
|
||
| 4073 | " WHERE #{Member.table_name}.project_id = #{Issue.table_name}.project_id))"
|
||
| 4074 | when "=", "!" |
||
| 4075 | role_cond = value.any? ? |
||
| 4076 | "#{MemberRole.table_name}.role_id IN (" + value.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + ")" :
|
||
| 4077 | "1=0" |
||
| 4078 | |||
| 4079 | sw = operator == "!" ? 'NOT' : '' |
||
| 4080 | nl = operator == "!" ? "#{Issue.table_name}.assigned_to_id IS NULL OR" : ''
|
||
| 4081 | "(#{nl} #{Issue.table_name}.assigned_to_id #{sw} IN (SELECT DISTINCT #{Member.table_name}.user_id FROM #{Member.table_name}, #{MemberRole.table_name}" +
|
||
| 4082 | " WHERE #{Member.table_name}.project_id = #{Issue.table_name}.project_id AND #{Member.table_name}.id = #{MemberRole.table_name}.member_id AND #{role_cond}))"
|
||
| 4083 | end |
||
| 4084 | end |
||
| 4085 | |||
| 4086 | def sql_for_is_private_field(field, operator, value) |
||
| 4087 | op = (operator == "=" ? 'IN' : 'NOT IN') |
||
| 4088 | va = value.map {|v| v == '0' ? connection.quoted_false : connection.quoted_true}.uniq.join(',')
|
||
| 4089 | |||
| 4090 | "#{Issue.table_name}.is_private #{op} (#{va})"
|
||
| 4091 | end |
||
| 4092 | |||
| 4093 | def sql_for_relations(field, operator, value, options={})
|
||
| 4094 | relation_options = IssueRelation::TYPES[field] |
||
| 4095 | return relation_options unless relation_options |
||
| 4096 | |||
| 4097 | relation_type = field |
||
| 4098 | join_column, target_join_column = "issue_from_id", "issue_to_id" |
||
| 4099 | if relation_options[:reverse] || options[:reverse] |
||
| 4100 | relation_type = relation_options[:reverse] || relation_type |
||
| 4101 | join_column, target_join_column = target_join_column, join_column |
||
| 4102 | end |
||
| 4103 | |||
| 4104 | sql = case operator |
||
| 4105 | when "*", "!*" |
||
| 4106 | op = (operator == "*" ? 'IN' : 'NOT IN') |
||
| 4107 | "#{Issue.table_name}.id #{op} (SELECT DISTINCT #{IssueRelation.table_name}.#{join_column} FROM #{IssueRelation.table_name} WHERE #{IssueRelation.table_name}.relation_type = '#{connection.quote_string(relation_type)}')"
|
||
| 4108 | when "=", "!" |
||
| 4109 | op = (operator == "=" ? 'IN' : 'NOT IN') |
||
| 4110 | "#{Issue.table_name}.id #{op} (SELECT DISTINCT #{IssueRelation.table_name}.#{join_column} FROM #{IssueRelation.table_name} WHERE #{IssueRelation.table_name}.relation_type = '#{connection.quote_string(relation_type)}' AND #{IssueRelation.table_name}.#{target_join_column} = #{value.first.to_i})"
|
||
| 4111 | when "=p", "=!p", "!p" |
||
| 4112 | op = (operator == "!p" ? 'NOT IN' : 'IN') |
||
| 4113 | comp = (operator == "=!p" ? '<>' : '=') |
||
| 4114 | "#{Issue.table_name}.id #{op} (SELECT DISTINCT #{IssueRelation.table_name}.#{join_column} FROM #{IssueRelation.table_name}, #{Issue.table_name} relissues WHERE #{IssueRelation.table_name}.relation_type = '#{connection.quote_string(relation_type)}' AND #{IssueRelation.table_name}.#{target_join_column} = relissues.id AND relissues.project_id #{comp} #{value.first.to_i})"
|
||
| 4115 | end |
||
| 4116 | |||
| 4117 | if relation_options[:sym] == field && !options[:reverse] |
||
| 4118 | sqls = [sql, sql_for_relations(field, operator, value, :reverse => true)] |
||
| 4119 | sql = sqls.join(["!", "!*", "!p"].include?(operator) ? " AND " : " OR ") |
||
| 4120 | end |
||
| 4121 | "(#{sql})"
|
||
| 4122 | end |
||
| 4123 | |||
| 4124 | IssueRelation::TYPES.keys.each do |relation_type| |
||
| 4125 | alias_method "sql_for_#{relation_type}_field".to_sym, :sql_for_relations
|
||
| 4126 | end |
||
| 4127 | end |
||
| 4128 | 909:cbb26bc654de | Chris | # Redmine - project management software |
| 4129 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 4130 | 0:513646585e45 | Chris | # |
| 4131 | # This program is free software; you can redistribute it and/or |
||
| 4132 | # modify it under the terms of the GNU General Public License |
||
| 4133 | # as published by the Free Software Foundation; either version 2 |
||
| 4134 | # of the License, or (at your option) any later version. |
||
| 4135 | 909:cbb26bc654de | Chris | # |
| 4136 | 0:513646585e45 | Chris | # This program is distributed in the hope that it will be useful, |
| 4137 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 4138 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 4139 | # GNU General Public License for more details. |
||
| 4140 | 909:cbb26bc654de | Chris | # |
| 4141 | 0:513646585e45 | Chris | # You should have received a copy of the GNU General Public License |
| 4142 | # along with this program; if not, write to the Free Software |
||
| 4143 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 4144 | |||
| 4145 | 1464:261b3d9a4903 | Chris | class IssueRelation < ActiveRecord::Base |
| 4146 | # Class used to represent the relations of an issue |
||
| 4147 | class Relations < Array |
||
| 4148 | include Redmine::I18n |
||
| 4149 | 1115:433d4f72a19b | Chris | |
| 4150 | 1464:261b3d9a4903 | Chris | def initialize(issue, *args) |
| 4151 | @issue = issue |
||
| 4152 | super(*args) |
||
| 4153 | end |
||
| 4154 | |||
| 4155 | def to_s(*args) |
||
| 4156 | map {|relation| "#{l(relation.label_for(@issue))} ##{relation.other_issue(@issue).id}"}.join(', ')
|
||
| 4157 | end |
||
| 4158 | 1115:433d4f72a19b | Chris | end |
| 4159 | |||
| 4160 | 0:513646585e45 | Chris | belongs_to :issue_from, :class_name => 'Issue', :foreign_key => 'issue_from_id' |
| 4161 | belongs_to :issue_to, :class_name => 'Issue', :foreign_key => 'issue_to_id' |
||
| 4162 | 909:cbb26bc654de | Chris | |
| 4163 | 0:513646585e45 | Chris | TYPE_RELATES = "relates" |
| 4164 | TYPE_DUPLICATES = "duplicates" |
||
| 4165 | TYPE_DUPLICATED = "duplicated" |
||
| 4166 | TYPE_BLOCKS = "blocks" |
||
| 4167 | TYPE_BLOCKED = "blocked" |
||
| 4168 | TYPE_PRECEDES = "precedes" |
||
| 4169 | TYPE_FOLLOWS = "follows" |
||
| 4170 | 1115:433d4f72a19b | Chris | TYPE_COPIED_TO = "copied_to" |
| 4171 | TYPE_COPIED_FROM = "copied_from" |
||
| 4172 | 909:cbb26bc654de | Chris | |
| 4173 | 1115:433d4f72a19b | Chris | TYPES = {
|
| 4174 | TYPE_RELATES => { :name => :label_relates_to, :sym_name => :label_relates_to,
|
||
| 4175 | :order => 1, :sym => TYPE_RELATES }, |
||
| 4176 | TYPE_DUPLICATES => { :name => :label_duplicates, :sym_name => :label_duplicated_by,
|
||
| 4177 | :order => 2, :sym => TYPE_DUPLICATED }, |
||
| 4178 | TYPE_DUPLICATED => { :name => :label_duplicated_by, :sym_name => :label_duplicates,
|
||
| 4179 | :order => 3, :sym => TYPE_DUPLICATES, :reverse => TYPE_DUPLICATES }, |
||
| 4180 | TYPE_BLOCKS => { :name => :label_blocks, :sym_name => :label_blocked_by,
|
||
| 4181 | :order => 4, :sym => TYPE_BLOCKED }, |
||
| 4182 | TYPE_BLOCKED => { :name => :label_blocked_by, :sym_name => :label_blocks,
|
||
| 4183 | :order => 5, :sym => TYPE_BLOCKS, :reverse => TYPE_BLOCKS }, |
||
| 4184 | TYPE_PRECEDES => { :name => :label_precedes, :sym_name => :label_follows,
|
||
| 4185 | :order => 6, :sym => TYPE_FOLLOWS }, |
||
| 4186 | TYPE_FOLLOWS => { :name => :label_follows, :sym_name => :label_precedes,
|
||
| 4187 | :order => 7, :sym => TYPE_PRECEDES, :reverse => TYPE_PRECEDES }, |
||
| 4188 | TYPE_COPIED_TO => { :name => :label_copied_to, :sym_name => :label_copied_from,
|
||
| 4189 | :order => 8, :sym => TYPE_COPIED_FROM }, |
||
| 4190 | TYPE_COPIED_FROM => { :name => :label_copied_from, :sym_name => :label_copied_to,
|
||
| 4191 | :order => 9, :sym => TYPE_COPIED_TO, :reverse => TYPE_COPIED_TO } |
||
| 4192 | }.freeze |
||
| 4193 | 909:cbb26bc654de | Chris | |
| 4194 | 0:513646585e45 | Chris | validates_presence_of :issue_from, :issue_to, :relation_type |
| 4195 | validates_inclusion_of :relation_type, :in => TYPES.keys |
||
| 4196 | validates_numericality_of :delay, :allow_nil => true |
||
| 4197 | validates_uniqueness_of :issue_to_id, :scope => :issue_from_id |
||
| 4198 | 909:cbb26bc654de | Chris | validate :validate_issue_relation |
| 4199 | |||
| 4200 | 0:513646585e45 | Chris | attr_protected :issue_from_id, :issue_to_id |
| 4201 | 909:cbb26bc654de | Chris | before_save :handle_issue_order |
| 4202 | 1464:261b3d9a4903 | Chris | after_create :create_journal_after_create |
| 4203 | after_destroy :create_journal_after_delete |
||
| 4204 | 909:cbb26bc654de | Chris | |
| 4205 | def visible?(user=User.current) |
||
| 4206 | (issue_from.nil? || issue_from.visible?(user)) && (issue_to.nil? || issue_to.visible?(user)) |
||
| 4207 | end |
||
| 4208 | |||
| 4209 | def deletable?(user=User.current) |
||
| 4210 | visible?(user) && |
||
| 4211 | ((issue_from.nil? || user.allowed_to?(:manage_issue_relations, issue_from.project)) || |
||
| 4212 | (issue_to.nil? || user.allowed_to?(:manage_issue_relations, issue_to.project))) |
||
| 4213 | end |
||
| 4214 | |||
| 4215 | 1115:433d4f72a19b | Chris | def initialize(attributes=nil, *args) |
| 4216 | super |
||
| 4217 | 909:cbb26bc654de | Chris | if new_record? |
| 4218 | if relation_type.blank? |
||
| 4219 | self.relation_type = IssueRelation::TYPE_RELATES |
||
| 4220 | end |
||
| 4221 | end |
||
| 4222 | end |
||
| 4223 | |||
| 4224 | def validate_issue_relation |
||
| 4225 | 0:513646585e45 | Chris | if issue_from && issue_to |
| 4226 | errors.add :issue_to_id, :invalid if issue_from_id == issue_to_id |
||
| 4227 | 1115:433d4f72a19b | Chris | unless issue_from.project_id == issue_to.project_id || |
| 4228 | Setting.cross_project_issue_relations? |
||
| 4229 | errors.add :issue_to_id, :not_same_project |
||
| 4230 | end |
||
| 4231 | # detect circular dependencies depending wether the relation should be reversed |
||
| 4232 | 507:0c939c159af4 | Chris | if TYPES.has_key?(relation_type) && TYPES[relation_type][:reverse] |
| 4233 | 909:cbb26bc654de | Chris | errors.add :base, :circular_dependency if issue_from.all_dependent_issues.include? issue_to |
| 4234 | 507:0c939c159af4 | Chris | else |
| 4235 | 909:cbb26bc654de | Chris | errors.add :base, :circular_dependency if issue_to.all_dependent_issues.include? issue_from |
| 4236 | 507:0c939c159af4 | Chris | end |
| 4237 | 1115:433d4f72a19b | Chris | if issue_from.is_descendant_of?(issue_to) || issue_from.is_ancestor_of?(issue_to) |
| 4238 | errors.add :base, :cant_link_an_issue_with_a_descendant |
||
| 4239 | end |
||
| 4240 | 0:513646585e45 | Chris | end |
| 4241 | end |
||
| 4242 | 909:cbb26bc654de | Chris | |
| 4243 | 0:513646585e45 | Chris | def other_issue(issue) |
| 4244 | (self.issue_from_id == issue.id) ? issue_to : issue_from |
||
| 4245 | end |
||
| 4246 | 909:cbb26bc654de | Chris | |
| 4247 | 0:513646585e45 | Chris | # Returns the relation type for +issue+ |
| 4248 | def relation_type_for(issue) |
||
| 4249 | if TYPES[relation_type] |
||
| 4250 | if self.issue_from_id == issue.id |
||
| 4251 | relation_type |
||
| 4252 | else |
||
| 4253 | TYPES[relation_type][:sym] |
||
| 4254 | end |
||
| 4255 | end |
||
| 4256 | end |
||
| 4257 | 909:cbb26bc654de | Chris | |
| 4258 | 0:513646585e45 | Chris | def label_for(issue) |
| 4259 | 1115:433d4f72a19b | Chris | TYPES[relation_type] ? |
| 4260 | TYPES[relation_type][(self.issue_from_id == issue.id) ? :name : :sym_name] : |
||
| 4261 | :unknow |
||
| 4262 | end |
||
| 4263 | |||
| 4264 | def css_classes_for(issue) |
||
| 4265 | "rel-#{relation_type_for(issue)}"
|
||
| 4266 | 0:513646585e45 | Chris | end |
| 4267 | 909:cbb26bc654de | Chris | |
| 4268 | def handle_issue_order |
||
| 4269 | 0:513646585e45 | Chris | reverse_if_needed |
| 4270 | 909:cbb26bc654de | Chris | |
| 4271 | 0:513646585e45 | Chris | if TYPE_PRECEDES == relation_type |
| 4272 | self.delay ||= 0 |
||
| 4273 | else |
||
| 4274 | self.delay = nil |
||
| 4275 | end |
||
| 4276 | set_issue_to_dates |
||
| 4277 | end |
||
| 4278 | 909:cbb26bc654de | Chris | |
| 4279 | 0:513646585e45 | Chris | def set_issue_to_dates |
| 4280 | soonest_start = self.successor_soonest_start |
||
| 4281 | 119:8661b858af72 | Chris | if soonest_start && issue_to |
| 4282 | 1115:433d4f72a19b | Chris | issue_to.reschedule_on!(soonest_start) |
| 4283 | 0:513646585e45 | Chris | end |
| 4284 | end |
||
| 4285 | 909:cbb26bc654de | Chris | |
| 4286 | 0:513646585e45 | Chris | def successor_soonest_start |
| 4287 | 1115:433d4f72a19b | Chris | if (TYPE_PRECEDES == self.relation_type) && delay && issue_from && |
| 4288 | (issue_from.start_date || issue_from.due_date) |
||
| 4289 | 119:8661b858af72 | Chris | (issue_from.due_date || issue_from.start_date) + 1 + delay |
| 4290 | end |
||
| 4291 | 0:513646585e45 | Chris | end |
| 4292 | 909:cbb26bc654de | Chris | |
| 4293 | 0:513646585e45 | Chris | def <=>(relation) |
| 4294 | 1115:433d4f72a19b | Chris | r = TYPES[self.relation_type][:order] <=> TYPES[relation.relation_type][:order] |
| 4295 | r == 0 ? id <=> relation.id : r |
||
| 4296 | 0:513646585e45 | Chris | end |
| 4297 | 909:cbb26bc654de | Chris | |
| 4298 | 0:513646585e45 | Chris | private |
| 4299 | 909:cbb26bc654de | Chris | |
| 4300 | 0:513646585e45 | Chris | # Reverses the relation if needed so that it gets stored in the proper way |
| 4301 | 909:cbb26bc654de | Chris | # Should not be reversed before validation so that it can be displayed back |
| 4302 | # as entered on new relation form |
||
| 4303 | 0:513646585e45 | Chris | def reverse_if_needed |
| 4304 | if TYPES.has_key?(relation_type) && TYPES[relation_type][:reverse] |
||
| 4305 | issue_tmp = issue_to |
||
| 4306 | self.issue_to = issue_from |
||
| 4307 | self.issue_from = issue_tmp |
||
| 4308 | self.relation_type = TYPES[relation_type][:reverse] |
||
| 4309 | end |
||
| 4310 | end |
||
| 4311 | 1464:261b3d9a4903 | Chris | |
| 4312 | def create_journal_after_create |
||
| 4313 | journal = issue_from.init_journal(User.current) |
||
| 4314 | journal.details << JournalDetail.new(:property => 'relation', |
||
| 4315 | 1517:dffacf8a6908 | Chris | :prop_key => relation_type_for(issue_from), |
| 4316 | 1464:261b3d9a4903 | Chris | :value => issue_to.id) |
| 4317 | journal.save |
||
| 4318 | journal = issue_to.init_journal(User.current) |
||
| 4319 | journal.details << JournalDetail.new(:property => 'relation', |
||
| 4320 | 1517:dffacf8a6908 | Chris | :prop_key => relation_type_for(issue_to), |
| 4321 | 1464:261b3d9a4903 | Chris | :value => issue_from.id) |
| 4322 | journal.save |
||
| 4323 | end |
||
| 4324 | |||
| 4325 | def create_journal_after_delete |
||
| 4326 | journal = issue_from.init_journal(User.current) |
||
| 4327 | journal.details << JournalDetail.new(:property => 'relation', |
||
| 4328 | 1517:dffacf8a6908 | Chris | :prop_key => relation_type_for(issue_from), |
| 4329 | 1464:261b3d9a4903 | Chris | :old_value => issue_to.id) |
| 4330 | journal.save |
||
| 4331 | journal = issue_to.init_journal(User.current) |
||
| 4332 | journal.details << JournalDetail.new(:property => 'relation', |
||
| 4333 | 1517:dffacf8a6908 | Chris | :prop_key => relation_type_for(issue_to), |
| 4334 | 1464:261b3d9a4903 | Chris | :old_value => issue_from.id) |
| 4335 | journal.save |
||
| 4336 | end |
||
| 4337 | 0:513646585e45 | Chris | end |
| 4338 | 909:cbb26bc654de | Chris | # Redmine - project management software |
| 4339 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 4340 | 0:513646585e45 | Chris | # |
| 4341 | # This program is free software; you can redistribute it and/or |
||
| 4342 | # modify it under the terms of the GNU General Public License |
||
| 4343 | # as published by the Free Software Foundation; either version 2 |
||
| 4344 | # of the License, or (at your option) any later version. |
||
| 4345 | 909:cbb26bc654de | Chris | # |
| 4346 | 0:513646585e45 | Chris | # This program is distributed in the hope that it will be useful, |
| 4347 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 4348 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 4349 | # GNU General Public License for more details. |
||
| 4350 | 909:cbb26bc654de | Chris | # |
| 4351 | 0:513646585e45 | Chris | # You should have received a copy of the GNU General Public License |
| 4352 | # along with this program; if not, write to the Free Software |
||
| 4353 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 4354 | |||
| 4355 | class IssueStatus < ActiveRecord::Base |
||
| 4356 | 909:cbb26bc654de | Chris | before_destroy :check_integrity |
| 4357 | 1115:433d4f72a19b | Chris | has_many :workflows, :class_name => 'WorkflowTransition', :foreign_key => "old_status_id" |
| 4358 | 0:513646585e45 | Chris | acts_as_list |
| 4359 | 909:cbb26bc654de | Chris | |
| 4360 | 1115:433d4f72a19b | Chris | before_destroy :delete_workflow_rules |
| 4361 | 909:cbb26bc654de | Chris | after_save :update_default |
| 4362 | 0:513646585e45 | Chris | |
| 4363 | validates_presence_of :name |
||
| 4364 | validates_uniqueness_of :name |
||
| 4365 | validates_length_of :name, :maximum => 30 |
||
| 4366 | validates_inclusion_of :default_done_ratio, :in => 0..100, :allow_nil => true |
||
| 4367 | 909:cbb26bc654de | Chris | |
| 4368 | 1464:261b3d9a4903 | Chris | scope :sorted, lambda { order("#{table_name}.position ASC") }
|
| 4369 | 1115:433d4f72a19b | Chris | scope :named, lambda {|arg| where("LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip)}
|
| 4370 | 0:513646585e45 | Chris | |
| 4371 | 909:cbb26bc654de | Chris | def update_default |
| 4372 | 1517:dffacf8a6908 | Chris | IssueStatus.where(['id <> ?', id]).update_all({:is_default => false}) if self.is_default?
|
| 4373 | 909:cbb26bc654de | Chris | end |
| 4374 | |||
| 4375 | 0:513646585e45 | Chris | # Returns the default status for new issues |
| 4376 | def self.default |
||
| 4377 | 1115:433d4f72a19b | Chris | where(:is_default => true).first |
| 4378 | 0:513646585e45 | Chris | end |
| 4379 | 909:cbb26bc654de | Chris | |
| 4380 | 0:513646585e45 | Chris | # Update all the +Issues+ setting their done_ratio to the value of their +IssueStatus+ |
| 4381 | def self.update_issue_done_ratios |
||
| 4382 | if Issue.use_status_for_done_ratio? |
||
| 4383 | 1517:dffacf8a6908 | Chris | IssueStatus.where("default_done_ratio >= 0").each do |status|
|
| 4384 | Issue.where({:status_id => status.id}).update_all({:done_ratio => status.default_done_ratio})
|
||
| 4385 | 0:513646585e45 | Chris | end |
| 4386 | end |
||
| 4387 | |||
| 4388 | return Issue.use_status_for_done_ratio? |
||
| 4389 | end |
||
| 4390 | |||
| 4391 | # Returns an array of all statuses the given role can switch to |
||
| 4392 | # Uses association cache when called more than one time |
||
| 4393 | 245:051f544170fe | Chris | def new_statuses_allowed_to(roles, tracker, author=false, assignee=false) |
| 4394 | 0:513646585e45 | Chris | if roles && tracker |
| 4395 | role_ids = roles.collect(&:id) |
||
| 4396 | 245:051f544170fe | Chris | transitions = workflows.select do |w| |
| 4397 | role_ids.include?(w.role_id) && |
||
| 4398 | 909:cbb26bc654de | Chris | w.tracker_id == tracker.id && |
| 4399 | ((!w.author && !w.assignee) || (author && w.author) || (assignee && w.assignee)) |
||
| 4400 | 245:051f544170fe | Chris | end |
| 4401 | 1115:433d4f72a19b | Chris | transitions.map(&:new_status).compact.sort |
| 4402 | 0:513646585e45 | Chris | else |
| 4403 | [] |
||
| 4404 | end |
||
| 4405 | end |
||
| 4406 | 909:cbb26bc654de | Chris | |
| 4407 | 0:513646585e45 | Chris | # Same thing as above but uses a database query |
| 4408 | # More efficient than the previous method if called just once |
||
| 4409 | 245:051f544170fe | Chris | def find_new_statuses_allowed_to(roles, tracker, author=false, assignee=false) |
| 4410 | 909:cbb26bc654de | Chris | if roles.present? && tracker |
| 4411 | conditions = "(author = :false AND assignee = :false)" |
||
| 4412 | conditions << " OR author = :true" if author |
||
| 4413 | conditions << " OR assignee = :true" if assignee |
||
| 4414 | |||
| 4415 | 1115:433d4f72a19b | Chris | workflows. |
| 4416 | includes(:new_status). |
||
| 4417 | where(["role_id IN (:role_ids) AND tracker_id = :tracker_id AND (#{conditions})",
|
||
| 4418 | 909:cbb26bc654de | Chris | {:role_ids => roles.collect(&:id), :tracker_id => tracker.id, :true => true, :false => false}
|
| 4419 | 1115:433d4f72a19b | Chris | ]).all. |
| 4420 | map(&:new_status).compact.sort |
||
| 4421 | 0:513646585e45 | Chris | else |
| 4422 | [] |
||
| 4423 | end |
||
| 4424 | end |
||
| 4425 | |||
| 4426 | def <=>(status) |
||
| 4427 | position <=> status.position |
||
| 4428 | end |
||
| 4429 | 909:cbb26bc654de | Chris | |
| 4430 | 0:513646585e45 | Chris | def to_s; name end |
| 4431 | |||
| 4432 | 1115:433d4f72a19b | Chris | private |
| 4433 | |||
| 4434 | 0:513646585e45 | Chris | def check_integrity |
| 4435 | 1115:433d4f72a19b | Chris | raise "Can't delete status" if Issue.where(:status_id => id).any? |
| 4436 | 0:513646585e45 | Chris | end |
| 4437 | 909:cbb26bc654de | Chris | |
| 4438 | 1:cca12e1c1fd4 | Chris | # Deletes associated workflows |
| 4439 | 1115:433d4f72a19b | Chris | def delete_workflow_rules |
| 4440 | WorkflowRule.delete_all(["old_status_id = :id OR new_status_id = :id", {:id => id}])
|
||
| 4441 | 1:cca12e1c1fd4 | Chris | end |
| 4442 | 0:513646585e45 | Chris | end |
| 4443 | 441:cbce1fd3b1b7 | Chris | # Redmine - project management software |
| 4444 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 4445 | 0:513646585e45 | Chris | # |
| 4446 | # This program is free software; you can redistribute it and/or |
||
| 4447 | # modify it under the terms of the GNU General Public License |
||
| 4448 | # as published by the Free Software Foundation; either version 2 |
||
| 4449 | # of the License, or (at your option) any later version. |
||
| 4450 | 909:cbb26bc654de | Chris | # |
| 4451 | 0:513646585e45 | Chris | # This program is distributed in the hope that it will be useful, |
| 4452 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 4453 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 4454 | # GNU General Public License for more details. |
||
| 4455 | 909:cbb26bc654de | Chris | # |
| 4456 | 0:513646585e45 | Chris | # You should have received a copy of the GNU General Public License |
| 4457 | # along with this program; if not, write to the Free Software |
||
| 4458 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 4459 | |||
| 4460 | class Journal < ActiveRecord::Base |
||
| 4461 | belongs_to :journalized, :polymorphic => true |
||
| 4462 | # added as a quick fix to allow eager loading of the polymorphic association |
||
| 4463 | # since always associated to an issue, for now |
||
| 4464 | belongs_to :issue, :foreign_key => :journalized_id |
||
| 4465 | 909:cbb26bc654de | Chris | |
| 4466 | 0:513646585e45 | Chris | belongs_to :user |
| 4467 | has_many :details, :class_name => "JournalDetail", :dependent => :delete_all |
||
| 4468 | attr_accessor :indice |
||
| 4469 | 909:cbb26bc654de | Chris | |
| 4470 | 0:513646585e45 | Chris | acts_as_event :title => Proc.new {|o| status = ((s = o.new_status) ? " (#{s})" : nil); "#{o.issue.tracker} ##{o.issue.id}#{status}: #{o.issue.subject}" },
|
| 4471 | :description => :notes, |
||
| 4472 | :author => :user, |
||
| 4473 | 1464:261b3d9a4903 | Chris | :group => :issue, |
| 4474 | 0:513646585e45 | Chris | :type => Proc.new {|o| (s = o.new_status) ? (s.is_closed? ? 'issue-closed' : 'issue-edit') : 'issue-note' },
|
| 4475 | :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.issue.id, :anchor => "change-#{o.id}"}}
|
||
| 4476 | |||
| 4477 | acts_as_activity_provider :type => 'issues', |
||
| 4478 | :author_key => :user_id, |
||
| 4479 | :find_options => {:include => [{:issue => :project}, :details, :user],
|
||
| 4480 | :conditions => "#{Journal.table_name}.journalized_type = 'Issue' AND" +
|
||
| 4481 | " (#{JournalDetail.table_name}.prop_key = 'status_id' OR #{Journal.table_name}.notes <> '')"}
|
||
| 4482 | 909:cbb26bc654de | Chris | |
| 4483 | 1115:433d4f72a19b | Chris | before_create :split_private_notes |
| 4484 | 1464:261b3d9a4903 | Chris | after_create :send_notification |
| 4485 | 1115:433d4f72a19b | Chris | |
| 4486 | scope :visible, lambda {|*args|
|
||
| 4487 | user = args.shift || User.current |
||
| 4488 | |||
| 4489 | includes(:issue => :project). |
||
| 4490 | where(Issue.visible_condition(user, *args)). |
||
| 4491 | where("(#{Journal.table_name}.private_notes = ? OR (#{Project.allowed_to_condition(user, :view_private_notes, *args)}))", false)
|
||
| 4492 | } |
||
| 4493 | 909:cbb26bc654de | Chris | |
| 4494 | 0:513646585e45 | Chris | def save(*args) |
| 4495 | # Do not save an empty journal |
||
| 4496 | (details.empty? && notes.blank?) ? false : super |
||
| 4497 | end |
||
| 4498 | 909:cbb26bc654de | Chris | |
| 4499 | 1464:261b3d9a4903 | Chris | # Returns journal details that are visible to user |
| 4500 | def visible_details(user=User.current) |
||
| 4501 | details.select do |detail| |
||
| 4502 | if detail.property == 'cf' |
||
| 4503 | detail.custom_field && detail.custom_field.visible_by?(project, user) |
||
| 4504 | elsif detail.property == 'relation' |
||
| 4505 | Issue.find_by_id(detail.value || detail.old_value).try(:visible?, user) |
||
| 4506 | else |
||
| 4507 | true |
||
| 4508 | end |
||
| 4509 | end |
||
| 4510 | end |
||
| 4511 | |||
| 4512 | def each_notification(users, &block) |
||
| 4513 | if users.any? |
||
| 4514 | users_by_details_visibility = users.group_by do |user| |
||
| 4515 | visible_details(user) |
||
| 4516 | end |
||
| 4517 | users_by_details_visibility.each do |visible_details, users| |
||
| 4518 | if notes? || visible_details.any? |
||
| 4519 | yield(users) |
||
| 4520 | end |
||
| 4521 | end |
||
| 4522 | end |
||
| 4523 | end |
||
| 4524 | |||
| 4525 | 0:513646585e45 | Chris | # Returns the new status if the journal contains a status change, otherwise nil |
| 4526 | def new_status |
||
| 4527 | c = details.detect {|detail| detail.prop_key == 'status_id'}
|
||
| 4528 | (c && c.value) ? IssueStatus.find_by_id(c.value.to_i) : nil |
||
| 4529 | end |
||
| 4530 | 909:cbb26bc654de | Chris | |
| 4531 | 0:513646585e45 | Chris | def new_value_for(prop) |
| 4532 | c = details.detect {|detail| detail.prop_key == prop}
|
||
| 4533 | c ? c.value : nil |
||
| 4534 | end |
||
| 4535 | 909:cbb26bc654de | Chris | |
| 4536 | 0:513646585e45 | Chris | def editable_by?(usr) |
| 4537 | usr && usr.logged? && (usr.allowed_to?(:edit_issue_notes, project) || (self.user == usr && usr.allowed_to?(:edit_own_issue_notes, project))) |
||
| 4538 | end |
||
| 4539 | 909:cbb26bc654de | Chris | |
| 4540 | 0:513646585e45 | Chris | def project |
| 4541 | journalized.respond_to?(:project) ? journalized.project : nil |
||
| 4542 | end |
||
| 4543 | 909:cbb26bc654de | Chris | |
| 4544 | 0:513646585e45 | Chris | def attachments |
| 4545 | journalized.respond_to?(:attachments) ? journalized.attachments : nil |
||
| 4546 | end |
||
| 4547 | 22:40f7cfd4df19 | chris | |
| 4548 | # Returns a string of css classes |
||
| 4549 | def css_classes |
||
| 4550 | s = 'journal' |
||
| 4551 | s << ' has-notes' unless notes.blank? |
||
| 4552 | s << ' has-details' unless details.blank? |
||
| 4553 | 1115:433d4f72a19b | Chris | s << ' private-notes' if private_notes? |
| 4554 | 22:40f7cfd4df19 | chris | s |
| 4555 | end |
||
| 4556 | 909:cbb26bc654de | Chris | |
| 4557 | 441:cbce1fd3b1b7 | Chris | def notify? |
| 4558 | @notify != false |
||
| 4559 | end |
||
| 4560 | 909:cbb26bc654de | Chris | |
| 4561 | 441:cbce1fd3b1b7 | Chris | def notify=(arg) |
| 4562 | @notify = arg |
||
| 4563 | end |
||
| 4564 | 1115:433d4f72a19b | Chris | |
| 4565 | 1464:261b3d9a4903 | Chris | def notified_users |
| 4566 | 1115:433d4f72a19b | Chris | notified = journalized.notified_users |
| 4567 | if private_notes? |
||
| 4568 | notified = notified.select {|user| user.allowed_to?(:view_private_notes, journalized.project)}
|
||
| 4569 | end |
||
| 4570 | 1464:261b3d9a4903 | Chris | notified |
| 4571 | 1115:433d4f72a19b | Chris | end |
| 4572 | |||
| 4573 | 1464:261b3d9a4903 | Chris | def recipients |
| 4574 | notified_users.map(&:mail) |
||
| 4575 | end |
||
| 4576 | |||
| 4577 | def notified_watchers |
||
| 4578 | 1115:433d4f72a19b | Chris | notified = journalized.notified_watchers |
| 4579 | if private_notes? |
||
| 4580 | notified = notified.select {|user| user.allowed_to?(:view_private_notes, journalized.project)}
|
||
| 4581 | end |
||
| 4582 | 1464:261b3d9a4903 | Chris | notified |
| 4583 | end |
||
| 4584 | |||
| 4585 | def watcher_recipients |
||
| 4586 | notified_watchers.map(&:mail) |
||
| 4587 | end |
||
| 4588 | |||
| 4589 | # Sets @custom_field instance variable on journals details using a single query |
||
| 4590 | def self.preload_journals_details_custom_fields(journals) |
||
| 4591 | field_ids = journals.map(&:details).flatten.select {|d| d.property == 'cf'}.map(&:prop_key).uniq
|
||
| 4592 | if field_ids.any? |
||
| 4593 | 1517:dffacf8a6908 | Chris | fields_by_id = CustomField.where(:id => field_ids).inject({}) {|h, f| h[f.id] = f; h}
|
| 4594 | 1464:261b3d9a4903 | Chris | journals.each do |journal| |
| 4595 | journal.details.each do |detail| |
||
| 4596 | if detail.property == 'cf' |
||
| 4597 | detail.instance_variable_set "@custom_field", fields_by_id[detail.prop_key.to_i] |
||
| 4598 | end |
||
| 4599 | end |
||
| 4600 | end |
||
| 4601 | end |
||
| 4602 | journals |
||
| 4603 | 1115:433d4f72a19b | Chris | end |
| 4604 | |||
| 4605 | private |
||
| 4606 | |||
| 4607 | def split_private_notes |
||
| 4608 | if private_notes? |
||
| 4609 | if notes.present? |
||
| 4610 | if details.any? |
||
| 4611 | # Split the journal (notes/changes) so we don't have half-private journals |
||
| 4612 | journal = Journal.new(:journalized => journalized, :user => user, :notes => nil, :private_notes => false) |
||
| 4613 | journal.details = details |
||
| 4614 | journal.save |
||
| 4615 | self.details = [] |
||
| 4616 | self.created_on = journal.created_on |
||
| 4617 | end |
||
| 4618 | else |
||
| 4619 | # Blank notes should not be private |
||
| 4620 | self.private_notes = false |
||
| 4621 | end |
||
| 4622 | end |
||
| 4623 | true |
||
| 4624 | end |
||
| 4625 | 1464:261b3d9a4903 | Chris | |
| 4626 | def send_notification |
||
| 4627 | if notify? && (Setting.notified_events.include?('issue_updated') ||
|
||
| 4628 | (Setting.notified_events.include?('issue_note_added') && notes.present?) ||
|
||
| 4629 | (Setting.notified_events.include?('issue_status_updated') && new_status.present?) ||
|
||
| 4630 | (Setting.notified_events.include?('issue_priority_updated') && new_value_for('priority_id').present?)
|
||
| 4631 | ) |
||
| 4632 | Mailer.deliver_issue_edit(self) |
||
| 4633 | end |
||
| 4634 | end |
||
| 4635 | 0:513646585e45 | Chris | end |
| 4636 | 245:051f544170fe | Chris | # Redmine - project management software |
| 4637 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 4638 | 0:513646585e45 | Chris | # |
| 4639 | # This program is free software; you can redistribute it and/or |
||
| 4640 | # modify it under the terms of the GNU General Public License |
||
| 4641 | # as published by the Free Software Foundation; either version 2 |
||
| 4642 | # of the License, or (at your option) any later version. |
||
| 4643 | 909:cbb26bc654de | Chris | # |
| 4644 | 0:513646585e45 | Chris | # This program is distributed in the hope that it will be useful, |
| 4645 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 4646 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 4647 | # GNU General Public License for more details. |
||
| 4648 | 909:cbb26bc654de | Chris | # |
| 4649 | 0:513646585e45 | Chris | # You should have received a copy of the GNU General Public License |
| 4650 | # along with this program; if not, write to the Free Software |
||
| 4651 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 4652 | |||
| 4653 | class JournalDetail < ActiveRecord::Base |
||
| 4654 | belongs_to :journal |
||
| 4655 | 441:cbce1fd3b1b7 | Chris | before_save :normalize_values |
| 4656 | 909:cbb26bc654de | Chris | |
| 4657 | 1464:261b3d9a4903 | Chris | def custom_field |
| 4658 | if property == 'cf' |
||
| 4659 | @custom_field ||= CustomField.find_by_id(prop_key) |
||
| 4660 | end |
||
| 4661 | end |
||
| 4662 | |||
| 4663 | 441:cbce1fd3b1b7 | Chris | private |
| 4664 | 909:cbb26bc654de | Chris | |
| 4665 | 441:cbce1fd3b1b7 | Chris | def normalize_values |
| 4666 | self.value = normalize(value) |
||
| 4667 | self.old_value = normalize(old_value) |
||
| 4668 | end |
||
| 4669 | 909:cbb26bc654de | Chris | |
| 4670 | 441:cbce1fd3b1b7 | Chris | def normalize(v) |
| 4671 | 1464:261b3d9a4903 | Chris | case v |
| 4672 | when true |
||
| 4673 | 441:cbce1fd3b1b7 | Chris | "1" |
| 4674 | 1464:261b3d9a4903 | Chris | when false |
| 4675 | 441:cbce1fd3b1b7 | Chris | "0" |
| 4676 | 1464:261b3d9a4903 | Chris | when Date |
| 4677 | v.strftime("%Y-%m-%d")
|
||
| 4678 | 441:cbce1fd3b1b7 | Chris | else |
| 4679 | v |
||
| 4680 | end |
||
| 4681 | end |
||
| 4682 | 0:513646585e45 | Chris | end |
| 4683 | 441:cbce1fd3b1b7 | Chris | # Redmine - project management software |
| 4684 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 4685 | 0:513646585e45 | Chris | # |
| 4686 | # This program is free software; you can redistribute it and/or |
||
| 4687 | # modify it under the terms of the GNU General Public License |
||
| 4688 | # as published by the Free Software Foundation; either version 2 |
||
| 4689 | # of the License, or (at your option) any later version. |
||
| 4690 | 441:cbce1fd3b1b7 | Chris | # |
| 4691 | 0:513646585e45 | Chris | # This program is distributed in the hope that it will be useful, |
| 4692 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 4693 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 4694 | # GNU General Public License for more details. |
||
| 4695 | 441:cbce1fd3b1b7 | Chris | # |
| 4696 | 0:513646585e45 | Chris | # You should have received a copy of the GNU General Public License |
| 4697 | # along with this program; if not, write to the Free Software |
||
| 4698 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 4699 | |||
| 4700 | class MailHandler < ActionMailer::Base |
||
| 4701 | include ActionView::Helpers::SanitizeHelper |
||
| 4702 | 37:94944d00e43c | chris | include Redmine::I18n |
| 4703 | 0:513646585e45 | Chris | |
| 4704 | class UnauthorizedAction < StandardError; end |
||
| 4705 | class MissingInformation < StandardError; end |
||
| 4706 | 441:cbce1fd3b1b7 | Chris | |
| 4707 | 0:513646585e45 | Chris | attr_reader :email, :user |
| 4708 | |||
| 4709 | def self.receive(email, options={})
|
||
| 4710 | @@handler_options = options.dup |
||
| 4711 | 441:cbce1fd3b1b7 | Chris | |
| 4712 | 0:513646585e45 | Chris | @@handler_options[:issue] ||= {}
|
| 4713 | 441:cbce1fd3b1b7 | Chris | |
| 4714 | 1115:433d4f72a19b | Chris | if @@handler_options[:allow_override].is_a?(String) |
| 4715 | @@handler_options[:allow_override] = @@handler_options[:allow_override].split(',').collect(&:strip)
|
||
| 4716 | end |
||
| 4717 | 0:513646585e45 | Chris | @@handler_options[:allow_override] ||= [] |
| 4718 | # Project needs to be overridable if not specified |
||
| 4719 | @@handler_options[:allow_override] << 'project' unless @@handler_options[:issue].has_key?(:project) |
||
| 4720 | # Status overridable by default |
||
| 4721 | 441:cbce1fd3b1b7 | Chris | @@handler_options[:allow_override] << 'status' unless @@handler_options[:issue].has_key?(:status) |
| 4722 | |||
| 4723 | 1464:261b3d9a4903 | Chris | @@handler_options[:no_account_notice] = (@@handler_options[:no_account_notice].to_s == '1') |
| 4724 | @@handler_options[:no_notification] = (@@handler_options[:no_notification].to_s == '1') |
||
| 4725 | @@handler_options[:no_permission_check] = (@@handler_options[:no_permission_check].to_s == '1') |
||
| 4726 | 1115:433d4f72a19b | Chris | |
| 4727 | email.force_encoding('ASCII-8BIT') if email.respond_to?(:force_encoding)
|
||
| 4728 | super(email) |
||
| 4729 | 0:513646585e45 | Chris | end |
| 4730 | 441:cbce1fd3b1b7 | Chris | |
| 4731 | 1464:261b3d9a4903 | Chris | # Extracts MailHandler options from environment variables |
| 4732 | # Use when receiving emails with rake tasks |
||
| 4733 | def self.extract_options_from_env(env) |
||
| 4734 | options = {:issue => {}}
|
||
| 4735 | %w(project status tracker category priority).each do |option| |
||
| 4736 | options[:issue][option.to_sym] = env[option] if env[option] |
||
| 4737 | end |
||
| 4738 | %w(allow_override unknown_user no_permission_check no_account_notice default_group).each do |option| |
||
| 4739 | options[option.to_sym] = env[option] if env[option] |
||
| 4740 | end |
||
| 4741 | options |
||
| 4742 | end |
||
| 4743 | |||
| 4744 | 1115:433d4f72a19b | Chris | def logger |
| 4745 | Rails.logger |
||
| 4746 | end |
||
| 4747 | |||
| 4748 | cattr_accessor :ignored_emails_headers |
||
| 4749 | @@ignored_emails_headers = {
|
||
| 4750 | 'X-Auto-Response-Suppress' => 'oof', |
||
| 4751 | 'Auto-Submitted' => /^auto-/ |
||
| 4752 | } |
||
| 4753 | |||
| 4754 | 0:513646585e45 | Chris | # Processes incoming emails |
| 4755 | # Returns the created object (eg. an issue, a message) or false |
||
| 4756 | def receive(email) |
||
| 4757 | @email = email |
||
| 4758 | sender_email = email.from.to_a.first.to_s.strip |
||
| 4759 | # Ignore emails received from the application emission address to avoid hell cycles |
||
| 4760 | if sender_email.downcase == Setting.mail_from.to_s.strip.downcase |
||
| 4761 | 1464:261b3d9a4903 | Chris | if logger |
| 4762 | 1115:433d4f72a19b | Chris | logger.info "MailHandler: ignoring email from Redmine emission address [#{sender_email}]"
|
| 4763 | end |
||
| 4764 | 0:513646585e45 | Chris | return false |
| 4765 | end |
||
| 4766 | 1115:433d4f72a19b | Chris | # Ignore auto generated emails |
| 4767 | self.class.ignored_emails_headers.each do |key, ignored_value| |
||
| 4768 | value = email.header[key] |
||
| 4769 | if value |
||
| 4770 | value = value.to_s.downcase |
||
| 4771 | if (ignored_value.is_a?(Regexp) && value.match(ignored_value)) || value == ignored_value |
||
| 4772 | 1464:261b3d9a4903 | Chris | if logger |
| 4773 | 1115:433d4f72a19b | Chris | logger.info "MailHandler: ignoring email with #{key}:#{value} header"
|
| 4774 | end |
||
| 4775 | return false |
||
| 4776 | end |
||
| 4777 | end |
||
| 4778 | end |
||
| 4779 | 0:513646585e45 | Chris | @user = User.find_by_mail(sender_email) if sender_email.present? |
| 4780 | if @user && !@user.active? |
||
| 4781 | 1464:261b3d9a4903 | Chris | if logger |
| 4782 | 1115:433d4f72a19b | Chris | logger.info "MailHandler: ignoring email from non-active user [#{@user.login}]"
|
| 4783 | end |
||
| 4784 | 0:513646585e45 | Chris | return false |
| 4785 | end |
||
| 4786 | if @user.nil? |
||
| 4787 | # Email was submitted by an unknown user |
||
| 4788 | case @@handler_options[:unknown_user] |
||
| 4789 | when 'accept' |
||
| 4790 | @user = User.anonymous |
||
| 4791 | when 'create' |
||
| 4792 | 1115:433d4f72a19b | Chris | @user = create_user_from_email |
| 4793 | 0:513646585e45 | Chris | if @user |
| 4794 | 1464:261b3d9a4903 | Chris | if logger |
| 4795 | 1115:433d4f72a19b | Chris | logger.info "MailHandler: [#{@user.login}] account created"
|
| 4796 | end |
||
| 4797 | 1464:261b3d9a4903 | Chris | add_user_to_group(@@handler_options[:default_group]) |
| 4798 | unless @@handler_options[:no_account_notice] |
||
| 4799 | Mailer.account_information(@user, @user.password).deliver |
||
| 4800 | end |
||
| 4801 | 0:513646585e45 | Chris | else |
| 4802 | 1464:261b3d9a4903 | Chris | if logger |
| 4803 | 1115:433d4f72a19b | Chris | logger.error "MailHandler: could not create account for [#{sender_email}]"
|
| 4804 | end |
||
| 4805 | 0:513646585e45 | Chris | return false |
| 4806 | end |
||
| 4807 | else |
||
| 4808 | # Default behaviour, emails from unknown users are ignored |
||
| 4809 | 1464:261b3d9a4903 | Chris | if logger |
| 4810 | logger.info "MailHandler: ignoring email from unknown user [#{sender_email}]"
|
||
| 4811 | 1115:433d4f72a19b | Chris | end |
| 4812 | 0:513646585e45 | Chris | return false |
| 4813 | end |
||
| 4814 | end |
||
| 4815 | User.current = @user |
||
| 4816 | dispatch |
||
| 4817 | end |
||
| 4818 | 441:cbce1fd3b1b7 | Chris | |
| 4819 | 0:513646585e45 | Chris | private |
| 4820 | |||
| 4821 | 1464:261b3d9a4903 | Chris | MESSAGE_ID_RE = %r{^<?redmine\.([a-z0-9_]+)\-(\d+)\.\d+(\.[a-f0-9]+)?@}
|
| 4822 | 0:513646585e45 | Chris | ISSUE_REPLY_SUBJECT_RE = %r{\[[^\]]*#(\d+)\]}
|
| 4823 | MESSAGE_REPLY_SUBJECT_RE = %r{\[[^\]]*msg(\d+)\]}
|
||
| 4824 | 441:cbce1fd3b1b7 | Chris | |
| 4825 | 0:513646585e45 | Chris | def dispatch |
| 4826 | headers = [email.in_reply_to, email.references].flatten.compact |
||
| 4827 | 1115:433d4f72a19b | Chris | subject = email.subject.to_s |
| 4828 | 0:513646585e45 | Chris | if headers.detect {|h| h.to_s =~ MESSAGE_ID_RE}
|
| 4829 | klass, object_id = $1, $2.to_i |
||
| 4830 | method_name = "receive_#{klass}_reply"
|
||
| 4831 | if self.class.private_instance_methods.collect(&:to_s).include?(method_name) |
||
| 4832 | send method_name, object_id |
||
| 4833 | else |
||
| 4834 | # ignoring it |
||
| 4835 | end |
||
| 4836 | 1115:433d4f72a19b | Chris | elsif m = subject.match(ISSUE_REPLY_SUBJECT_RE) |
| 4837 | 0:513646585e45 | Chris | receive_issue_reply(m[1].to_i) |
| 4838 | 1115:433d4f72a19b | Chris | elsif m = subject.match(MESSAGE_REPLY_SUBJECT_RE) |
| 4839 | 0:513646585e45 | Chris | receive_message_reply(m[1].to_i) |
| 4840 | else |
||
| 4841 | 245:051f544170fe | Chris | dispatch_to_default |
| 4842 | 0:513646585e45 | Chris | end |
| 4843 | rescue ActiveRecord::RecordInvalid => e |
||
| 4844 | # TODO: send a email to the user |
||
| 4845 | logger.error e.message if logger |
||
| 4846 | false |
||
| 4847 | rescue MissingInformation => e |
||
| 4848 | logger.error "MailHandler: missing information from #{user}: #{e.message}" if logger
|
||
| 4849 | false |
||
| 4850 | rescue UnauthorizedAction => e |
||
| 4851 | logger.error "MailHandler: unauthorized attempt from #{user}" if logger
|
||
| 4852 | false |
||
| 4853 | end |
||
| 4854 | 245:051f544170fe | Chris | |
| 4855 | def dispatch_to_default |
||
| 4856 | receive_issue |
||
| 4857 | end |
||
| 4858 | 441:cbce1fd3b1b7 | Chris | |
| 4859 | 0:513646585e45 | Chris | # Creates a new issue |
| 4860 | def receive_issue |
||
| 4861 | project = target_project |
||
| 4862 | # check permission |
||
| 4863 | unless @@handler_options[:no_permission_check] |
||
| 4864 | raise UnauthorizedAction unless user.allowed_to?(:add_issues, project) |
||
| 4865 | end |
||
| 4866 | |||
| 4867 | 37:94944d00e43c | chris | issue = Issue.new(:author => user, :project => project) |
| 4868 | issue.safe_attributes = issue_attributes_from_keywords(issue) |
||
| 4869 | issue.safe_attributes = {'custom_field_values' => custom_field_values_from_keywords(issue)}
|
||
| 4870 | 1115:433d4f72a19b | Chris | issue.subject = cleaned_up_subject |
| 4871 | 0:513646585e45 | Chris | if issue.subject.blank? |
| 4872 | issue.subject = '(no subject)' |
||
| 4873 | end |
||
| 4874 | issue.description = cleaned_up_text_body |
||
| 4875 | 1517:dffacf8a6908 | Chris | issue.start_date ||= Date.today if Setting.default_issue_start_date_to_creation_date? |
| 4876 | 441:cbce1fd3b1b7 | Chris | |
| 4877 | 0:513646585e45 | Chris | # add To and Cc as watchers before saving so the watchers can reply to Redmine |
| 4878 | add_watchers(issue) |
||
| 4879 | issue.save! |
||
| 4880 | add_attachments(issue) |
||
| 4881 | 1464:261b3d9a4903 | Chris | logger.info "MailHandler: issue ##{issue.id} created by #{user}" if logger
|
| 4882 | 0:513646585e45 | Chris | issue |
| 4883 | end |
||
| 4884 | 441:cbce1fd3b1b7 | Chris | |
| 4885 | 0:513646585e45 | Chris | # Adds a note to an existing issue |
| 4886 | 1115:433d4f72a19b | Chris | def receive_issue_reply(issue_id, from_journal=nil) |
| 4887 | 0:513646585e45 | Chris | issue = Issue.find_by_id(issue_id) |
| 4888 | return unless issue |
||
| 4889 | # check permission |
||
| 4890 | unless @@handler_options[:no_permission_check] |
||
| 4891 | 1115:433d4f72a19b | Chris | unless user.allowed_to?(:add_issue_notes, issue.project) || |
| 4892 | user.allowed_to?(:edit_issues, issue.project) |
||
| 4893 | raise UnauthorizedAction |
||
| 4894 | end |
||
| 4895 | 0:513646585e45 | Chris | end |
| 4896 | 441:cbce1fd3b1b7 | Chris | |
| 4897 | 119:8661b858af72 | Chris | # ignore CLI-supplied defaults for new issues |
| 4898 | @@handler_options[:issue].clear |
||
| 4899 | 441:cbce1fd3b1b7 | Chris | |
| 4900 | journal = issue.init_journal(user) |
||
| 4901 | 1115:433d4f72a19b | Chris | if from_journal && from_journal.private_notes? |
| 4902 | # If the received email was a reply to a private note, make the added note private |
||
| 4903 | issue.private_notes = true |
||
| 4904 | end |
||
| 4905 | 37:94944d00e43c | chris | issue.safe_attributes = issue_attributes_from_keywords(issue) |
| 4906 | issue.safe_attributes = {'custom_field_values' => custom_field_values_from_keywords(issue)}
|
||
| 4907 | 441:cbce1fd3b1b7 | Chris | journal.notes = cleaned_up_text_body |
| 4908 | 0:513646585e45 | Chris | add_attachments(issue) |
| 4909 | issue.save! |
||
| 4910 | 1464:261b3d9a4903 | Chris | if logger |
| 4911 | 1115:433d4f72a19b | Chris | logger.info "MailHandler: issue ##{issue.id} updated by #{user}"
|
| 4912 | end |
||
| 4913 | 0:513646585e45 | Chris | journal |
| 4914 | end |
||
| 4915 | 441:cbce1fd3b1b7 | Chris | |
| 4916 | 0:513646585e45 | Chris | # Reply will be added to the issue |
| 4917 | def receive_journal_reply(journal_id) |
||
| 4918 | journal = Journal.find_by_id(journal_id) |
||
| 4919 | if journal && journal.journalized_type == 'Issue' |
||
| 4920 | 1115:433d4f72a19b | Chris | receive_issue_reply(journal.journalized_id, journal) |
| 4921 | 0:513646585e45 | Chris | end |
| 4922 | end |
||
| 4923 | 441:cbce1fd3b1b7 | Chris | |
| 4924 | 0:513646585e45 | Chris | # Receives a reply to a forum message |
| 4925 | def receive_message_reply(message_id) |
||
| 4926 | message = Message.find_by_id(message_id) |
||
| 4927 | if message |
||
| 4928 | message = message.root |
||
| 4929 | 441:cbce1fd3b1b7 | Chris | |
| 4930 | 0:513646585e45 | Chris | unless @@handler_options[:no_permission_check] |
| 4931 | raise UnauthorizedAction unless user.allowed_to?(:add_messages, message.project) |
||
| 4932 | end |
||
| 4933 | 441:cbce1fd3b1b7 | Chris | |
| 4934 | 0:513646585e45 | Chris | if !message.locked? |
| 4935 | 1115:433d4f72a19b | Chris | reply = Message.new(:subject => cleaned_up_subject.gsub(%r{^.*msg\d+\]}, '').strip,
|
| 4936 | 0:513646585e45 | Chris | :content => cleaned_up_text_body) |
| 4937 | reply.author = user |
||
| 4938 | reply.board = message.board |
||
| 4939 | message.children << reply |
||
| 4940 | add_attachments(reply) |
||
| 4941 | reply |
||
| 4942 | else |
||
| 4943 | 1464:261b3d9a4903 | Chris | if logger |
| 4944 | 1115:433d4f72a19b | Chris | logger.info "MailHandler: ignoring reply from [#{sender_email}] to a locked topic"
|
| 4945 | end |
||
| 4946 | 0:513646585e45 | Chris | end |
| 4947 | end |
||
| 4948 | end |
||
| 4949 | 441:cbce1fd3b1b7 | Chris | |
| 4950 | 0:513646585e45 | Chris | def add_attachments(obj) |
| 4951 | 909:cbb26bc654de | Chris | if email.attachments && email.attachments.any? |
| 4952 | 0:513646585e45 | Chris | email.attachments.each do |attachment| |
| 4953 | 1464:261b3d9a4903 | Chris | next unless accept_attachment?(attachment) |
| 4954 | 909:cbb26bc654de | Chris | obj.attachments << Attachment.create(:container => obj, |
| 4955 | 1115:433d4f72a19b | Chris | :file => attachment.decoded, |
| 4956 | 1294:3e4c3460b6ca | Chris | :filename => attachment.filename, |
| 4957 | 0:513646585e45 | Chris | :author => user, |
| 4958 | 1115:433d4f72a19b | Chris | :content_type => attachment.mime_type) |
| 4959 | 0:513646585e45 | Chris | end |
| 4960 | end |
||
| 4961 | end |
||
| 4962 | 441:cbce1fd3b1b7 | Chris | |
| 4963 | 1464:261b3d9a4903 | Chris | # Returns false if the +attachment+ of the incoming email should be ignored |
| 4964 | def accept_attachment?(attachment) |
||
| 4965 | @excluded ||= Setting.mail_handler_excluded_filenames.to_s.split(',').map(&:strip).reject(&:blank?)
|
||
| 4966 | @excluded.each do |pattern| |
||
| 4967 | regexp = %r{\A#{Regexp.escape(pattern).gsub("\\*", ".*")}\z}i
|
||
| 4968 | if attachment.filename.to_s =~ regexp |
||
| 4969 | logger.info "MailHandler: ignoring attachment #{attachment.filename} matching #{pattern}"
|
||
| 4970 | return false |
||
| 4971 | end |
||
| 4972 | end |
||
| 4973 | true |
||
| 4974 | end |
||
| 4975 | |||
| 4976 | 0:513646585e45 | Chris | # Adds To and Cc as watchers of the given object if the sender has the |
| 4977 | # appropriate permission |
||
| 4978 | def add_watchers(obj) |
||
| 4979 | if user.allowed_to?("add_#{obj.class.name.underscore}_watchers".to_sym, obj.project)
|
||
| 4980 | addresses = [email.to, email.cc].flatten.compact.uniq.collect {|a| a.strip.downcase}
|
||
| 4981 | unless addresses.empty? |
||
| 4982 | 1517:dffacf8a6908 | Chris | User.active.where('LOWER(mail) IN (?)', addresses).each do |w|
|
| 4983 | obj.add_watcher(w) |
||
| 4984 | end |
||
| 4985 | 0:513646585e45 | Chris | end |
| 4986 | end |
||
| 4987 | end |
||
| 4988 | 441:cbce1fd3b1b7 | Chris | |
| 4989 | 0:513646585e45 | Chris | def get_keyword(attr, options={})
|
| 4990 | @keywords ||= {}
|
||
| 4991 | if @keywords.has_key?(attr) |
||
| 4992 | @keywords[attr] |
||
| 4993 | else |
||
| 4994 | @keywords[attr] = begin |
||
| 4995 | 1115:433d4f72a19b | Chris | if (options[:override] || @@handler_options[:allow_override].include?(attr.to_s)) && |
| 4996 | (v = extract_keyword!(plain_text_body, attr, options[:format])) |
||
| 4997 | 37:94944d00e43c | chris | v |
| 4998 | 0:513646585e45 | Chris | elsif !@@handler_options[:issue][attr].blank? |
| 4999 | @@handler_options[:issue][attr] |
||
| 5000 | end |
||
| 5001 | end |
||
| 5002 | end |
||
| 5003 | end |
||
| 5004 | 441:cbce1fd3b1b7 | Chris | |
| 5005 | 37:94944d00e43c | chris | # Destructively extracts the value for +attr+ in +text+ |
| 5006 | # Returns nil if no matching keyword found |
||
| 5007 | def extract_keyword!(text, attr, format=nil) |
||
| 5008 | keys = [attr.to_s.humanize] |
||
| 5009 | if attr.is_a?(Symbol) |
||
| 5010 | 1115:433d4f72a19b | Chris | if user && user.language.present? |
| 5011 | keys << l("field_#{attr}", :default => '', :locale => user.language)
|
||
| 5012 | end |
||
| 5013 | if Setting.default_language.present? |
||
| 5014 | keys << l("field_#{attr}", :default => '', :locale => Setting.default_language)
|
||
| 5015 | end |
||
| 5016 | 37:94944d00e43c | chris | end |
| 5017 | keys.reject! {|k| k.blank?}
|
||
| 5018 | keys.collect! {|k| Regexp.escape(k)}
|
||
| 5019 | format ||= '.+' |
||
| 5020 | 1115:433d4f72a19b | Chris | keyword = nil |
| 5021 | regexp = /^(#{keys.join('|')})[ \t]*:[ \t]*(#{format})\s*$/i
|
||
| 5022 | if m = text.match(regexp) |
||
| 5023 | keyword = m[2].strip |
||
| 5024 | text.gsub!(regexp, '') |
||
| 5025 | end |
||
| 5026 | keyword |
||
| 5027 | 37:94944d00e43c | chris | end |
| 5028 | |||
| 5029 | def target_project |
||
| 5030 | # TODO: other ways to specify project: |
||
| 5031 | # * parse the email To field |
||
| 5032 | # * specific project (eg. Setting.mail_handler_target_project) |
||
| 5033 | target = Project.find_by_identifier(get_keyword(:project)) |
||
| 5034 | 1464:261b3d9a4903 | Chris | if target.nil? |
| 5035 | # Invalid project keyword, use the project specified as the default one |
||
| 5036 | default_project = @@handler_options[:issue][:project] |
||
| 5037 | if default_project.present? |
||
| 5038 | target = Project.find_by_identifier(default_project) |
||
| 5039 | end |
||
| 5040 | end |
||
| 5041 | 37:94944d00e43c | chris | raise MissingInformation.new('Unable to determine target project') if target.nil?
|
| 5042 | target |
||
| 5043 | end |
||
| 5044 | 441:cbce1fd3b1b7 | Chris | |
| 5045 | 37:94944d00e43c | chris | # Returns a Hash of issue attributes extracted from keywords in the email body |
| 5046 | def issue_attributes_from_keywords(issue) |
||
| 5047 | 909:cbb26bc654de | Chris | assigned_to = (k = get_keyword(:assigned_to, :override => true)) && find_assignee_from_keyword(k, issue) |
| 5048 | 441:cbce1fd3b1b7 | Chris | |
| 5049 | 119:8661b858af72 | Chris | attrs = {
|
| 5050 | 507:0c939c159af4 | Chris | 'tracker_id' => (k = get_keyword(:tracker)) && issue.project.trackers.named(k).first.try(:id), |
| 5051 | 'status_id' => (k = get_keyword(:status)) && IssueStatus.named(k).first.try(:id), |
||
| 5052 | 'priority_id' => (k = get_keyword(:priority)) && IssuePriority.named(k).first.try(:id), |
||
| 5053 | 'category_id' => (k = get_keyword(:category)) && issue.project.issue_categories.named(k).first.try(:id), |
||
| 5054 | 37:94944d00e43c | chris | 'assigned_to_id' => assigned_to.try(:id), |
| 5055 | 1115:433d4f72a19b | Chris | 'fixed_version_id' => (k = get_keyword(:fixed_version, :override => true)) && |
| 5056 | issue.project.shared_versions.named(k).first.try(:id), |
||
| 5057 | 37:94944d00e43c | chris | 'start_date' => get_keyword(:start_date, :override => true, :format => '\d{4}-\d{2}-\d{2}'),
|
| 5058 | 'due_date' => get_keyword(:due_date, :override => true, :format => '\d{4}-\d{2}-\d{2}'),
|
||
| 5059 | 'estimated_hours' => get_keyword(:estimated_hours, :override => true), |
||
| 5060 | 'done_ratio' => get_keyword(:done_ratio, :override => true, :format => '(\d|10)?0') |
||
| 5061 | }.delete_if {|k, v| v.blank? }
|
||
| 5062 | 441:cbce1fd3b1b7 | Chris | |
| 5063 | 119:8661b858af72 | Chris | if issue.new_record? && attrs['tracker_id'].nil? |
| 5064 | 1464:261b3d9a4903 | Chris | attrs['tracker_id'] = issue.project.trackers.first.try(:id) |
| 5065 | 119:8661b858af72 | Chris | end |
| 5066 | 441:cbce1fd3b1b7 | Chris | |
| 5067 | 119:8661b858af72 | Chris | attrs |
| 5068 | 37:94944d00e43c | chris | end |
| 5069 | 441:cbce1fd3b1b7 | Chris | |
| 5070 | 37:94944d00e43c | chris | # Returns a Hash of issue custom field values extracted from keywords in the email body |
| 5071 | 441:cbce1fd3b1b7 | Chris | def custom_field_values_from_keywords(customized) |
| 5072 | 37:94944d00e43c | chris | customized.custom_field_values.inject({}) do |h, v|
|
| 5073 | 1115:433d4f72a19b | Chris | if keyword = get_keyword(v.custom_field.name, :override => true) |
| 5074 | h[v.custom_field.id.to_s] = v.custom_field.value_from_keyword(keyword, customized) |
||
| 5075 | 37:94944d00e43c | chris | end |
| 5076 | h |
||
| 5077 | end |
||
| 5078 | end |
||
| 5079 | 441:cbce1fd3b1b7 | Chris | |
| 5080 | 0:513646585e45 | Chris | # Returns the text/plain part of the email |
| 5081 | # If not found (eg. HTML-only email), returns the body with tags removed |
||
| 5082 | def plain_text_body |
||
| 5083 | return @plain_text_body unless @plain_text_body.nil? |
||
| 5084 | 1115:433d4f72a19b | Chris | |
| 5085 | 1464:261b3d9a4903 | Chris | parts = if (text_parts = email.all_parts.select {|p| p.mime_type == 'text/plain'}).present?
|
| 5086 | text_parts |
||
| 5087 | elsif (html_parts = email.all_parts.select {|p| p.mime_type == 'text/html'}).present?
|
||
| 5088 | html_parts |
||
| 5089 | else |
||
| 5090 | [email] |
||
| 5091 | end |
||
| 5092 | |||
| 5093 | parts.reject! do |part| |
||
| 5094 | part.header[:content_disposition].try(:disposition_type) == 'attachment' |
||
| 5095 | end |
||
| 5096 | |||
| 5097 | 1517:dffacf8a6908 | Chris | @plain_text_body = parts.map do |p| |
| 5098 | body_charset = p.charset.respond_to?(:force_encoding) ? |
||
| 5099 | Mail::RubyVer.pick_encoding(p.charset).to_s : p.charset |
||
| 5100 | Redmine::CodesetUtil.to_utf8(p.body.decoded, body_charset) |
||
| 5101 | end.join("\r\n")
|
||
| 5102 | 1115:433d4f72a19b | Chris | |
| 5103 | # strip html tags and remove doctype directive |
||
| 5104 | 1464:261b3d9a4903 | Chris | if parts.any? {|p| p.mime_type == 'text/html'}
|
| 5105 | @plain_text_body = strip_tags(@plain_text_body.strip) |
||
| 5106 | @plain_text_body.sub! %r{^<!DOCTYPE .*$}, ''
|
||
| 5107 | end |
||
| 5108 | |||
| 5109 | 0:513646585e45 | Chris | @plain_text_body |
| 5110 | end |
||
| 5111 | 441:cbce1fd3b1b7 | Chris | |
| 5112 | 0:513646585e45 | Chris | def cleaned_up_text_body |
| 5113 | cleanup_body(plain_text_body) |
||
| 5114 | end |
||
| 5115 | |||
| 5116 | 1115:433d4f72a19b | Chris | def cleaned_up_subject |
| 5117 | subject = email.subject.to_s |
||
| 5118 | subject.strip[0,255] |
||
| 5119 | end |
||
| 5120 | |||
| 5121 | 0:513646585e45 | Chris | def self.full_sanitizer |
| 5122 | @full_sanitizer ||= HTML::FullSanitizer.new |
||
| 5123 | end |
||
| 5124 | 441:cbce1fd3b1b7 | Chris | |
| 5125 | 1115:433d4f72a19b | Chris | def self.assign_string_attribute_with_limit(object, attribute, value, limit=nil) |
| 5126 | limit ||= object.class.columns_hash[attribute.to_s].limit || 255 |
||
| 5127 | 909:cbb26bc654de | Chris | value = value.to_s.slice(0, limit) |
| 5128 | object.send("#{attribute}=", value)
|
||
| 5129 | end |
||
| 5130 | |||
| 5131 | # Returns a User from an email address and a full name |
||
| 5132 | def self.new_user_from_attributes(email_address, fullname=nil) |
||
| 5133 | user = User.new |
||
| 5134 | |||
| 5135 | # Truncating the email address would result in an invalid format |
||
| 5136 | user.mail = email_address |
||
| 5137 | 1115:433d4f72a19b | Chris | assign_string_attribute_with_limit(user, 'login', email_address, User::LOGIN_LENGTH_LIMIT) |
| 5138 | 909:cbb26bc654de | Chris | |
| 5139 | names = fullname.blank? ? email_address.gsub(/@.*$/, '').split('.') : fullname.split
|
||
| 5140 | 1464:261b3d9a4903 | Chris | assign_string_attribute_with_limit(user, 'firstname', names.shift, 30) |
| 5141 | assign_string_attribute_with_limit(user, 'lastname', names.join(' '), 30)
|
||
| 5142 | 909:cbb26bc654de | Chris | user.lastname = '-' if user.lastname.blank? |
| 5143 | user.language = Setting.default_language |
||
| 5144 | 1464:261b3d9a4903 | Chris | user.generate_password = true |
| 5145 | user.mail_notification = 'only_my_events' |
||
| 5146 | 909:cbb26bc654de | Chris | |
| 5147 | unless user.valid? |
||
| 5148 | 1115:433d4f72a19b | Chris | user.login = "user#{Redmine::Utils.random_hex(6)}" unless user.errors[:login].blank?
|
| 5149 | user.firstname = "-" unless user.errors[:firstname].blank? |
||
| 5150 | 1464:261b3d9a4903 | Chris | (puts user.errors[:lastname];user.lastname = "-") unless user.errors[:lastname].blank? |
| 5151 | 909:cbb26bc654de | Chris | end |
| 5152 | |||
| 5153 | user |
||
| 5154 | end |
||
| 5155 | |||
| 5156 | # Creates a User for the +email+ sender |
||
| 5157 | # Returns the user or nil if it could not be created |
||
| 5158 | 1115:433d4f72a19b | Chris | def create_user_from_email |
| 5159 | from = email.header['from'].to_s |
||
| 5160 | addr, name = from, nil |
||
| 5161 | if m = from.match(/^"?(.+?)"?\s+<(.+@.+)>$/) |
||
| 5162 | addr, name = m[2], m[1] |
||
| 5163 | end |
||
| 5164 | if addr.present? |
||
| 5165 | user = self.class.new_user_from_attributes(addr, name) |
||
| 5166 | 1464:261b3d9a4903 | Chris | if @@handler_options[:no_notification] |
| 5167 | user.mail_notification = 'none' |
||
| 5168 | end |
||
| 5169 | 909:cbb26bc654de | Chris | if user.save |
| 5170 | user |
||
| 5171 | else |
||
| 5172 | logger.error "MailHandler: failed to create User: #{user.errors.full_messages}" if logger
|
||
| 5173 | nil |
||
| 5174 | end |
||
| 5175 | else |
||
| 5176 | logger.error "MailHandler: failed to create User: no FROM address found" if logger |
||
| 5177 | nil |
||
| 5178 | 0:513646585e45 | Chris | end |
| 5179 | end |
||
| 5180 | |||
| 5181 | 1464:261b3d9a4903 | Chris | # Adds the newly created user to default group |
| 5182 | def add_user_to_group(default_group) |
||
| 5183 | if default_group.present? |
||
| 5184 | default_group.split(',').each do |group_name|
|
||
| 5185 | if group = Group.named(group_name).first |
||
| 5186 | group.users << @user |
||
| 5187 | elsif logger |
||
| 5188 | logger.warn "MailHandler: could not add user to [#{group_name}], group not found"
|
||
| 5189 | end |
||
| 5190 | end |
||
| 5191 | end |
||
| 5192 | end |
||
| 5193 | |||
| 5194 | 0:513646585e45 | Chris | # Removes the email body of text after the truncation configurations. |
| 5195 | def cleanup_body(body) |
||
| 5196 | delimiters = Setting.mail_handler_body_delimiters.to_s.split(/[\r\n]+/).reject(&:blank?).map {|s| Regexp.escape(s)}
|
||
| 5197 | unless delimiters.empty? |
||
| 5198 | 37:94944d00e43c | chris | regex = Regexp.new("^[> ]*(#{ delimiters.join('|') })\s*[\r\n].*", Regexp::MULTILINE)
|
| 5199 | 0:513646585e45 | Chris | body = body.gsub(regex, '') |
| 5200 | end |
||
| 5201 | body.strip |
||
| 5202 | end |
||
| 5203 | |||
| 5204 | 909:cbb26bc654de | Chris | def find_assignee_from_keyword(keyword, issue) |
| 5205 | keyword = keyword.to_s.downcase |
||
| 5206 | assignable = issue.assignable_users |
||
| 5207 | assignee = nil |
||
| 5208 | 1115:433d4f72a19b | Chris | assignee ||= assignable.detect {|a|
|
| 5209 | a.mail.to_s.downcase == keyword || |
||
| 5210 | a.login.to_s.downcase == keyword |
||
| 5211 | } |
||
| 5212 | 909:cbb26bc654de | Chris | if assignee.nil? && keyword.match(/ /) |
| 5213 | 0:513646585e45 | Chris | firstname, lastname = *(keyword.split) # "First Last Throwaway" |
| 5214 | 1464:261b3d9a4903 | Chris | assignee ||= assignable.detect {|a|
|
| 5215 | 1115:433d4f72a19b | Chris | a.is_a?(User) && a.firstname.to_s.downcase == firstname && |
| 5216 | a.lastname.to_s.downcase == lastname |
||
| 5217 | } |
||
| 5218 | 0:513646585e45 | Chris | end |
| 5219 | 909:cbb26bc654de | Chris | if assignee.nil? |
| 5220 | 1115:433d4f72a19b | Chris | assignee ||= assignable.detect {|a| a.name.downcase == keyword}
|
| 5221 | 909:cbb26bc654de | Chris | end |
| 5222 | assignee |
||
| 5223 | 0:513646585e45 | Chris | end |
| 5224 | end |
||
| 5225 | 441:cbce1fd3b1b7 | Chris | # Redmine - project management software |
| 5226 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 5227 | 0:513646585e45 | Chris | # |
| 5228 | # This program is free software; you can redistribute it and/or |
||
| 5229 | # modify it under the terms of the GNU General Public License |
||
| 5230 | # as published by the Free Software Foundation; either version 2 |
||
| 5231 | # of the License, or (at your option) any later version. |
||
| 5232 | # |
||
| 5233 | # This program is distributed in the hope that it will be useful, |
||
| 5234 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 5235 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 5236 | # GNU General Public License for more details. |
||
| 5237 | # |
||
| 5238 | # You should have received a copy of the GNU General Public License |
||
| 5239 | # along with this program; if not, write to the Free Software |
||
| 5240 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 5241 | |||
| 5242 | class Mailer < ActionMailer::Base |
||
| 5243 | layout 'mailer' |
||
| 5244 | helper :application |
||
| 5245 | helper :issues |
||
| 5246 | helper :custom_fields |
||
| 5247 | |||
| 5248 | include Redmine::I18n |
||
| 5249 | |||
| 5250 | def self.default_url_options |
||
| 5251 | 1115:433d4f72a19b | Chris | { :host => Setting.host_name, :protocol => Setting.protocol }
|
| 5252 | 0:513646585e45 | Chris | end |
| 5253 | 441:cbce1fd3b1b7 | Chris | |
| 5254 | 1484:51364c0cd58f | Chris | # Builds a mail for notifying the specified member that they were |
| 5255 | # added to a project |
||
| 5256 | 1270:b2f7f52a164d | chris | def member_added_to_project(member, project) |
| 5257 | |||
| 5258 | 949:ebfda4c68b7a | luis | principal = Principal.find(member.user_id) |
| 5259 | 291:f3b2dc533e83 | luis | |
| 5260 | 1270:b2f7f52a164d | chris | users = [] |
| 5261 | 949:ebfda4c68b7a | luis | if principal.type == "User" |
| 5262 | 1270:b2f7f52a164d | chris | users = [User.find(member.user_id)] |
| 5263 | 949:ebfda4c68b7a | luis | else |
| 5264 | users = Principal.find(member.user_id).users |
||
| 5265 | end |
||
| 5266 | 291:f3b2dc533e83 | luis | |
| 5267 | 1270:b2f7f52a164d | chris | users.map do |user| |
| 5268 | |||
| 5269 | set_language_if_valid user.language |
||
| 5270 | @project_url = url_for(:controller => 'projects', :action => 'show', :id => project.id) |
||
| 5271 | @project_name = project.name |
||
| 5272 | mail :to => user.mail, |
||
| 5273 | :subject => l(:mail_subject_added_to_project, Setting.app_title) |
||
| 5274 | |||
| 5275 | end |
||
| 5276 | 291:f3b2dc533e83 | luis | end |
| 5277 | 37:94944d00e43c | chris | |
| 5278 | 1464:261b3d9a4903 | Chris | # Builds a mail for notifying to_users and cc_users about a new issue |
| 5279 | def issue_add(issue, to_users, cc_users) |
||
| 5280 | 0:513646585e45 | Chris | redmine_headers 'Project' => issue.project.identifier, |
| 5281 | 'Issue-Id' => issue.id, |
||
| 5282 | 'Issue-Author' => issue.author.login |
||
| 5283 | redmine_headers 'Issue-Assignee' => issue.assigned_to.login if issue.assigned_to |
||
| 5284 | message_id issue |
||
| 5285 | 1464:261b3d9a4903 | Chris | references issue |
| 5286 | 1115:433d4f72a19b | Chris | @author = issue.author |
| 5287 | @issue = issue |
||
| 5288 | 1464:261b3d9a4903 | Chris | @users = to_users + cc_users |
| 5289 | 1115:433d4f72a19b | Chris | @issue_url = url_for(:controller => 'issues', :action => 'show', :id => issue) |
| 5290 | 1464:261b3d9a4903 | Chris | mail :to => to_users.map(&:mail), |
| 5291 | :cc => cc_users.map(&:mail), |
||
| 5292 | 1115:433d4f72a19b | Chris | :subject => "[#{issue.project.name} - #{issue.tracker.name} ##{issue.id}] (#{issue.status.name}) #{issue.subject}"
|
| 5293 | 0:513646585e45 | Chris | end |
| 5294 | |||
| 5295 | 1464:261b3d9a4903 | Chris | # Notifies users about a new issue |
| 5296 | def self.deliver_issue_add(issue) |
||
| 5297 | to = issue.notified_users |
||
| 5298 | cc = issue.notified_watchers - to |
||
| 5299 | issue.each_notification(to + cc) do |users| |
||
| 5300 | Mailer.issue_add(issue, to & users, cc & users).deliver |
||
| 5301 | end |
||
| 5302 | end |
||
| 5303 | |||
| 5304 | # Builds a mail for notifying to_users and cc_users about an issue update |
||
| 5305 | def issue_edit(journal, to_users, cc_users) |
||
| 5306 | issue = journal.journalized |
||
| 5307 | 0:513646585e45 | Chris | redmine_headers 'Project' => issue.project.identifier, |
| 5308 | 'Issue-Id' => issue.id, |
||
| 5309 | 'Issue-Author' => issue.author.login |
||
| 5310 | redmine_headers 'Issue-Assignee' => issue.assigned_to.login if issue.assigned_to |
||
| 5311 | message_id journal |
||
| 5312 | references issue |
||
| 5313 | @author = journal.user |
||
| 5314 | s = "[#{issue.project.name} - #{issue.tracker.name} ##{issue.id}] "
|
||
| 5315 | s << "(#{issue.status.name}) " if journal.new_value_for('status_id')
|
||
| 5316 | s << issue.subject |
||
| 5317 | 1115:433d4f72a19b | Chris | @issue = issue |
| 5318 | 1464:261b3d9a4903 | Chris | @users = to_users + cc_users |
| 5319 | 1115:433d4f72a19b | Chris | @journal = journal |
| 5320 | 1464:261b3d9a4903 | Chris | @journal_details = journal.visible_details(@users.first) |
| 5321 | 1115:433d4f72a19b | Chris | @issue_url = url_for(:controller => 'issues', :action => 'show', :id => issue, :anchor => "change-#{journal.id}")
|
| 5322 | 1464:261b3d9a4903 | Chris | mail :to => to_users.map(&:mail), |
| 5323 | :cc => cc_users.map(&:mail), |
||
| 5324 | 1115:433d4f72a19b | Chris | :subject => s |
| 5325 | 0:513646585e45 | Chris | end |
| 5326 | |||
| 5327 | 1464:261b3d9a4903 | Chris | # Notifies users about an issue update |
| 5328 | def self.deliver_issue_edit(journal) |
||
| 5329 | issue = journal.journalized.reload |
||
| 5330 | to = journal.notified_users |
||
| 5331 | 1517:dffacf8a6908 | Chris | cc = journal.notified_watchers - to |
| 5332 | 1464:261b3d9a4903 | Chris | journal.each_notification(to + cc) do |users| |
| 5333 | issue.each_notification(users) do |users2| |
||
| 5334 | Mailer.issue_edit(journal, to & users2, cc & users2).deliver |
||
| 5335 | end |
||
| 5336 | end |
||
| 5337 | end |
||
| 5338 | |||
| 5339 | 0:513646585e45 | Chris | def reminder(user, issues, days) |
| 5340 | set_language_if_valid user.language |
||
| 5341 | 1115:433d4f72a19b | Chris | @issues = issues |
| 5342 | @days = days |
||
| 5343 | @issues_url = url_for(:controller => 'issues', :action => 'index', |
||
| 5344 | 909:cbb26bc654de | Chris | :set_filter => 1, :assigned_to_id => user.id, |
| 5345 | :sort => 'due_date:asc') |
||
| 5346 | 1115:433d4f72a19b | Chris | mail :to => user.mail, |
| 5347 | :subject => l(:mail_subject_reminder, :count => issues.size, :days => days) |
||
| 5348 | 0:513646585e45 | Chris | end |
| 5349 | |||
| 5350 | 1115:433d4f72a19b | Chris | # Builds a Mail::Message object used to email users belonging to the added document's project. |
| 5351 | 0:513646585e45 | Chris | # |
| 5352 | # Example: |
||
| 5353 | 1115:433d4f72a19b | Chris | # document_added(document) => Mail::Message object |
| 5354 | # Mailer.document_added(document).deliver => sends an email to the document's project recipients |
||
| 5355 | 0:513646585e45 | Chris | def document_added(document) |
| 5356 | redmine_headers 'Project' => document.project.identifier |
||
| 5357 | 1115:433d4f72a19b | Chris | @author = User.current |
| 5358 | @document = document |
||
| 5359 | @document_url = url_for(:controller => 'documents', :action => 'show', :id => document) |
||
| 5360 | mail :to => document.recipients, |
||
| 5361 | :subject => "[#{document.project.name}] #{l(:label_document_new)}: #{document.title}"
|
||
| 5362 | 0:513646585e45 | Chris | end |
| 5363 | |||
| 5364 | 1115:433d4f72a19b | Chris | # Builds a Mail::Message object used to email recipients of a project when an attachements are added. |
| 5365 | 0:513646585e45 | Chris | # |
| 5366 | # Example: |
||
| 5367 | 1115:433d4f72a19b | Chris | # attachments_added(attachments) => Mail::Message object |
| 5368 | # Mailer.attachments_added(attachments).deliver => sends an email to the project's recipients |
||
| 5369 | 0:513646585e45 | Chris | def attachments_added(attachments) |
| 5370 | container = attachments.first.container |
||
| 5371 | added_to = '' |
||
| 5372 | added_to_url = '' |
||
| 5373 | 1115:433d4f72a19b | Chris | @author = attachments.first.author |
| 5374 | 0:513646585e45 | Chris | case container.class.name |
| 5375 | when 'Project' |
||
| 5376 | 441:cbce1fd3b1b7 | Chris | added_to_url = url_for(:controller => 'files', :action => 'index', :project_id => container) |
| 5377 | 0:513646585e45 | Chris | added_to = "#{l(:label_project)}: #{container}"
|
| 5378 | 1115:433d4f72a19b | Chris | recipients = container.project.notified_users.select {|user| user.allowed_to?(:view_files, container.project)}.collect {|u| u.mail}
|
| 5379 | 0:513646585e45 | Chris | when 'Version' |
| 5380 | 441:cbce1fd3b1b7 | Chris | added_to_url = url_for(:controller => 'files', :action => 'index', :project_id => container.project) |
| 5381 | 0:513646585e45 | Chris | added_to = "#{l(:label_version)}: #{container.name}"
|
| 5382 | 1115:433d4f72a19b | Chris | recipients = container.project.notified_users.select {|user| user.allowed_to?(:view_files, container.project)}.collect {|u| u.mail}
|
| 5383 | 0:513646585e45 | Chris | when 'Document' |
| 5384 | added_to_url = url_for(:controller => 'documents', :action => 'show', :id => container.id) |
||
| 5385 | added_to = "#{l(:label_document)}: #{container.title}"
|
||
| 5386 | 1115:433d4f72a19b | Chris | recipients = container.recipients |
| 5387 | 0:513646585e45 | Chris | end |
| 5388 | redmine_headers 'Project' => container.project.identifier |
||
| 5389 | 1115:433d4f72a19b | Chris | @attachments = attachments |
| 5390 | @added_to = added_to |
||
| 5391 | @added_to_url = added_to_url |
||
| 5392 | mail :to => recipients, |
||
| 5393 | :subject => "[#{container.project.name}] #{l(:label_attachment_new)}"
|
||
| 5394 | 0:513646585e45 | Chris | end |
| 5395 | 441:cbce1fd3b1b7 | Chris | |
| 5396 | 1115:433d4f72a19b | Chris | # Builds a Mail::Message object used to email recipients of a news' project when a news item is added. |
| 5397 | 0:513646585e45 | Chris | # |
| 5398 | # Example: |
||
| 5399 | 1115:433d4f72a19b | Chris | # news_added(news) => Mail::Message object |
| 5400 | # Mailer.news_added(news).deliver => sends an email to the news' project recipients |
||
| 5401 | 0:513646585e45 | Chris | def news_added(news) |
| 5402 | redmine_headers 'Project' => news.project.identifier |
||
| 5403 | 1115:433d4f72a19b | Chris | @author = news.author |
| 5404 | 0:513646585e45 | Chris | message_id news |
| 5405 | 1464:261b3d9a4903 | Chris | references news |
| 5406 | 1115:433d4f72a19b | Chris | @news = news |
| 5407 | @news_url = url_for(:controller => 'news', :action => 'show', :id => news) |
||
| 5408 | mail :to => news.recipients, |
||
| 5409 | 1517:dffacf8a6908 | Chris | :cc => news.cc_for_added_news, |
| 5410 | 1115:433d4f72a19b | Chris | :subject => "[#{news.project.name}] #{l(:label_news)}: #{news.title}"
|
| 5411 | 0:513646585e45 | Chris | end |
| 5412 | |||
| 5413 | 1115:433d4f72a19b | Chris | # Builds a Mail::Message object used to email recipients of a news' project when a news comment is added. |
| 5414 | 441:cbce1fd3b1b7 | Chris | # |
| 5415 | # Example: |
||
| 5416 | 1115:433d4f72a19b | Chris | # news_comment_added(comment) => Mail::Message object |
| 5417 | 441:cbce1fd3b1b7 | Chris | # Mailer.news_comment_added(comment) => sends an email to the news' project recipients |
| 5418 | def news_comment_added(comment) |
||
| 5419 | news = comment.commented |
||
| 5420 | redmine_headers 'Project' => news.project.identifier |
||
| 5421 | 1115:433d4f72a19b | Chris | @author = comment.author |
| 5422 | 441:cbce1fd3b1b7 | Chris | message_id comment |
| 5423 | 1464:261b3d9a4903 | Chris | references news |
| 5424 | 1115:433d4f72a19b | Chris | @news = news |
| 5425 | @comment = comment |
||
| 5426 | @news_url = url_for(:controller => 'news', :action => 'show', :id => news) |
||
| 5427 | mail :to => news.recipients, |
||
| 5428 | :cc => news.watcher_recipients, |
||
| 5429 | :subject => "Re: [#{news.project.name}] #{l(:label_news)}: #{news.title}"
|
||
| 5430 | 441:cbce1fd3b1b7 | Chris | end |
| 5431 | |||
| 5432 | 1115:433d4f72a19b | Chris | # Builds a Mail::Message object used to email the recipients of the specified message that was posted. |
| 5433 | 0:513646585e45 | Chris | # |
| 5434 | # Example: |
||
| 5435 | 1115:433d4f72a19b | Chris | # message_posted(message) => Mail::Message object |
| 5436 | # Mailer.message_posted(message).deliver => sends an email to the recipients |
||
| 5437 | 0:513646585e45 | Chris | def message_posted(message) |
| 5438 | redmine_headers 'Project' => message.project.identifier, |
||
| 5439 | 'Topic-Id' => (message.parent_id || message.id) |
||
| 5440 | 1115:433d4f72a19b | Chris | @author = message.author |
| 5441 | 0:513646585e45 | Chris | message_id message |
| 5442 | 1464:261b3d9a4903 | Chris | references message.root |
| 5443 | 1115:433d4f72a19b | Chris | recipients = message.recipients |
| 5444 | cc = ((message.root.watcher_recipients + message.board.watcher_recipients).uniq - recipients) |
||
| 5445 | @message = message |
||
| 5446 | @message_url = url_for(message.event_url) |
||
| 5447 | mail :to => recipients, |
||
| 5448 | :cc => cc, |
||
| 5449 | :subject => "[#{message.board.project.name} - #{message.board.name} - msg#{message.root.id}] #{message.subject}"
|
||
| 5450 | 0:513646585e45 | Chris | end |
| 5451 | 441:cbce1fd3b1b7 | Chris | |
| 5452 | 1115:433d4f72a19b | Chris | # Builds a Mail::Message object used to email the recipients of a project of the specified wiki content was added. |
| 5453 | 0:513646585e45 | Chris | # |
| 5454 | # Example: |
||
| 5455 | 1115:433d4f72a19b | Chris | # wiki_content_added(wiki_content) => Mail::Message object |
| 5456 | # Mailer.wiki_content_added(wiki_content).deliver => sends an email to the project's recipients |
||
| 5457 | 0:513646585e45 | Chris | def wiki_content_added(wiki_content) |
| 5458 | redmine_headers 'Project' => wiki_content.project.identifier, |
||
| 5459 | 'Wiki-Page-Id' => wiki_content.page.id |
||
| 5460 | 1115:433d4f72a19b | Chris | @author = wiki_content.author |
| 5461 | 0:513646585e45 | Chris | message_id wiki_content |
| 5462 | 1115:433d4f72a19b | Chris | recipients = wiki_content.recipients |
| 5463 | cc = wiki_content.page.wiki.watcher_recipients - recipients |
||
| 5464 | @wiki_content = wiki_content |
||
| 5465 | @wiki_content_url = url_for(:controller => 'wiki', :action => 'show', |
||
| 5466 | 909:cbb26bc654de | Chris | :project_id => wiki_content.project, |
| 5467 | :id => wiki_content.page.title) |
||
| 5468 | 1115:433d4f72a19b | Chris | mail :to => recipients, |
| 5469 | :cc => cc, |
||
| 5470 | :subject => "[#{wiki_content.project.name}] #{l(:mail_subject_wiki_content_added, :id => wiki_content.page.pretty_title)}"
|
||
| 5471 | 0:513646585e45 | Chris | end |
| 5472 | 441:cbce1fd3b1b7 | Chris | |
| 5473 | 1115:433d4f72a19b | Chris | # Builds a Mail::Message object used to email the recipients of a project of the specified wiki content was updated. |
| 5474 | 0:513646585e45 | Chris | # |
| 5475 | # Example: |
||
| 5476 | 1115:433d4f72a19b | Chris | # wiki_content_updated(wiki_content) => Mail::Message object |
| 5477 | # Mailer.wiki_content_updated(wiki_content).deliver => sends an email to the project's recipients |
||
| 5478 | 0:513646585e45 | Chris | def wiki_content_updated(wiki_content) |
| 5479 | redmine_headers 'Project' => wiki_content.project.identifier, |
||
| 5480 | 'Wiki-Page-Id' => wiki_content.page.id |
||
| 5481 | 1115:433d4f72a19b | Chris | @author = wiki_content.author |
| 5482 | 0:513646585e45 | Chris | message_id wiki_content |
| 5483 | 1115:433d4f72a19b | Chris | recipients = wiki_content.recipients |
| 5484 | cc = wiki_content.page.wiki.watcher_recipients + wiki_content.page.watcher_recipients - recipients |
||
| 5485 | @wiki_content = wiki_content |
||
| 5486 | @wiki_content_url = url_for(:controller => 'wiki', :action => 'show', |
||
| 5487 | 909:cbb26bc654de | Chris | :project_id => wiki_content.project, |
| 5488 | 1115:433d4f72a19b | Chris | :id => wiki_content.page.title) |
| 5489 | @wiki_diff_url = url_for(:controller => 'wiki', :action => 'diff', |
||
| 5490 | 909:cbb26bc654de | Chris | :project_id => wiki_content.project, :id => wiki_content.page.title, |
| 5491 | :version => wiki_content.version) |
||
| 5492 | 1115:433d4f72a19b | Chris | mail :to => recipients, |
| 5493 | :cc => cc, |
||
| 5494 | :subject => "[#{wiki_content.project.name}] #{l(:mail_subject_wiki_content_updated, :id => wiki_content.page.pretty_title)}"
|
||
| 5495 | 0:513646585e45 | Chris | end |
| 5496 | |||
| 5497 | 1115:433d4f72a19b | Chris | # Builds a Mail::Message object used to email the specified user their account information. |
| 5498 | 0:513646585e45 | Chris | # |
| 5499 | # Example: |
||
| 5500 | 1115:433d4f72a19b | Chris | # account_information(user, password) => Mail::Message object |
| 5501 | # Mailer.account_information(user, password).deliver => sends account information to the user |
||
| 5502 | 0:513646585e45 | Chris | def account_information(user, password) |
| 5503 | set_language_if_valid user.language |
||
| 5504 | 1115:433d4f72a19b | Chris | @user = user |
| 5505 | @password = password |
||
| 5506 | @login_url = url_for(:controller => 'account', :action => 'login') |
||
| 5507 | mail :to => user.mail, |
||
| 5508 | :subject => l(:mail_subject_register, Setting.app_title) |
||
| 5509 | 0:513646585e45 | Chris | end |
| 5510 | |||
| 5511 | 1115:433d4f72a19b | Chris | # Builds a Mail::Message object used to email all active administrators of an account activation request. |
| 5512 | 0:513646585e45 | Chris | # |
| 5513 | # Example: |
||
| 5514 | 1115:433d4f72a19b | Chris | # account_activation_request(user) => Mail::Message object |
| 5515 | # Mailer.account_activation_request(user).deliver => sends an email to all active administrators |
||
| 5516 | 0:513646585e45 | Chris | def account_activation_request(user) |
| 5517 | # Send the email to all active administrators |
||
| 5518 | 1517:dffacf8a6908 | Chris | recipients = User.active.where(:admin => true).collect { |u| u.mail }.compact
|
| 5519 | 1115:433d4f72a19b | Chris | @user = user |
| 5520 | @url = url_for(:controller => 'users', :action => 'index', |
||
| 5521 | 909:cbb26bc654de | Chris | :status => User::STATUS_REGISTERED, |
| 5522 | :sort_key => 'created_on', :sort_order => 'desc') |
||
| 5523 | 1115:433d4f72a19b | Chris | mail :to => recipients, |
| 5524 | :subject => l(:mail_subject_account_activation_request, Setting.app_title) |
||
| 5525 | 0:513646585e45 | Chris | end |
| 5526 | |||
| 5527 | 1115:433d4f72a19b | Chris | # Builds a Mail::Message object used to email the specified user that their account was activated by an administrator. |
| 5528 | 0:513646585e45 | Chris | # |
| 5529 | # Example: |
||
| 5530 | 1115:433d4f72a19b | Chris | # account_activated(user) => Mail::Message object |
| 5531 | # Mailer.account_activated(user).deliver => sends an email to the registered user |
||
| 5532 | 0:513646585e45 | Chris | def account_activated(user) |
| 5533 | set_language_if_valid user.language |
||
| 5534 | 1115:433d4f72a19b | Chris | @user = user |
| 5535 | @login_url = url_for(:controller => 'account', :action => 'login') |
||
| 5536 | mail :to => user.mail, |
||
| 5537 | :subject => l(:mail_subject_register, Setting.app_title) |
||
| 5538 | 0:513646585e45 | Chris | end |
| 5539 | |||
| 5540 | def lost_password(token) |
||
| 5541 | set_language_if_valid(token.user.language) |
||
| 5542 | 1115:433d4f72a19b | Chris | @token = token |
| 5543 | @url = url_for(:controller => 'account', :action => 'lost_password', :token => token.value) |
||
| 5544 | mail :to => token.user.mail, |
||
| 5545 | :subject => l(:mail_subject_lost_password, Setting.app_title) |
||
| 5546 | 0:513646585e45 | Chris | end |
| 5547 | |||
| 5548 | def register(token) |
||
| 5549 | set_language_if_valid(token.user.language) |
||
| 5550 | 1115:433d4f72a19b | Chris | @token = token |
| 5551 | @url = url_for(:controller => 'account', :action => 'activate', :token => token.value) |
||
| 5552 | mail :to => token.user.mail, |
||
| 5553 | :subject => l(:mail_subject_register, Setting.app_title) |
||
| 5554 | 0:513646585e45 | Chris | end |
| 5555 | |||
| 5556 | 1115:433d4f72a19b | Chris | def test_email(user) |
| 5557 | 0:513646585e45 | Chris | set_language_if_valid(user.language) |
| 5558 | 1115:433d4f72a19b | Chris | @url = url_for(:controller => 'welcome') |
| 5559 | mail :to => user.mail, |
||
| 5560 | :subject => 'Redmine test' |
||
| 5561 | 0:513646585e45 | Chris | end |
| 5562 | |||
| 5563 | # Sends reminders to issue assignees |
||
| 5564 | # Available options: |
||
| 5565 | # * :days => how many days in the future to remind about (defaults to 7) |
||
| 5566 | # * :tracker => id of tracker for filtering issues (defaults to all trackers) |
||
| 5567 | # * :project => id or identifier of project to process (defaults to all projects) |
||
| 5568 | 1115:433d4f72a19b | Chris | # * :users => array of user/group ids who should be reminded |
| 5569 | 0:513646585e45 | Chris | def self.reminders(options={})
|
| 5570 | days = options[:days] || 7 |
||
| 5571 | project = options[:project] ? Project.find(options[:project]) : nil |
||
| 5572 | tracker = options[:tracker] ? Tracker.find(options[:tracker]) : nil |
||
| 5573 | 22:40f7cfd4df19 | chris | user_ids = options[:users] |
| 5574 | 0:513646585e45 | Chris | |
| 5575 | 1115:433d4f72a19b | Chris | scope = Issue.open.where("#{Issue.table_name}.assigned_to_id IS NOT NULL" +
|
| 5576 | " AND #{Project.table_name}.status = #{Project::STATUS_ACTIVE}" +
|
||
| 5577 | " AND #{Issue.table_name}.due_date <= ?", days.day.from_now.to_date
|
||
| 5578 | ) |
||
| 5579 | scope = scope.where(:assigned_to_id => user_ids) if user_ids.present? |
||
| 5580 | scope = scope.where(:project_id => project.id) if project |
||
| 5581 | scope = scope.where(:tracker_id => tracker.id) if tracker |
||
| 5582 | 1517:dffacf8a6908 | Chris | issues_by_assignee = scope.includes(:status, :assigned_to, :project, :tracker). |
| 5583 | group_by(&:assigned_to) |
||
| 5584 | 1115:433d4f72a19b | Chris | issues_by_assignee.keys.each do |assignee| |
| 5585 | if assignee.is_a?(Group) |
||
| 5586 | assignee.users.each do |user| |
||
| 5587 | issues_by_assignee[user] ||= [] |
||
| 5588 | issues_by_assignee[user] += issues_by_assignee[assignee] |
||
| 5589 | end |
||
| 5590 | end |
||
| 5591 | end |
||
| 5592 | |||
| 5593 | 0:513646585e45 | Chris | issues_by_assignee.each do |assignee, issues| |
| 5594 | 1115:433d4f72a19b | Chris | reminder(assignee, issues, days).deliver if assignee.is_a?(User) && assignee.active? |
| 5595 | 0:513646585e45 | Chris | end |
| 5596 | end |
||
| 5597 | 441:cbce1fd3b1b7 | Chris | |
| 5598 | 0:513646585e45 | Chris | # Activates/desactivates email deliveries during +block+ |
| 5599 | def self.with_deliveries(enabled = true, &block) |
||
| 5600 | was_enabled = ActionMailer::Base.perform_deliveries |
||
| 5601 | ActionMailer::Base.perform_deliveries = !!enabled |
||
| 5602 | yield |
||
| 5603 | ensure |
||
| 5604 | ActionMailer::Base.perform_deliveries = was_enabled |
||
| 5605 | end |
||
| 5606 | |||
| 5607 | 1115:433d4f72a19b | Chris | # Sends emails synchronously in the given block |
| 5608 | def self.with_synched_deliveries(&block) |
||
| 5609 | saved_method = ActionMailer::Base.delivery_method |
||
| 5610 | if m = saved_method.to_s.match(%r{^async_(.+)$})
|
||
| 5611 | synched_method = m[1] |
||
| 5612 | ActionMailer::Base.delivery_method = synched_method.to_sym |
||
| 5613 | ActionMailer::Base.send "#{synched_method}_settings=", ActionMailer::Base.send("async_#{synched_method}_settings")
|
||
| 5614 | end |
||
| 5615 | yield |
||
| 5616 | ensure |
||
| 5617 | ActionMailer::Base.delivery_method = saved_method |
||
| 5618 | end |
||
| 5619 | 441:cbce1fd3b1b7 | Chris | |
| 5620 | 1464:261b3d9a4903 | Chris | def mail(headers={}, &block)
|
| 5621 | 1115:433d4f72a19b | Chris | headers.merge! 'X-Mailer' => 'Redmine', |
| 5622 | 0:513646585e45 | Chris | 'X-Redmine-Host' => Setting.host_name, |
| 5623 | 'X-Redmine-Site' => Setting.app_title, |
||
| 5624 | 909:cbb26bc654de | Chris | 'X-Auto-Response-Suppress' => 'OOF', |
| 5625 | 1115:433d4f72a19b | Chris | 'Auto-Submitted' => 'auto-generated', |
| 5626 | 'From' => Setting.mail_from, |
||
| 5627 | 'List-Id' => "<#{Setting.mail_from.to_s.gsub('@', '.')}>"
|
||
| 5628 | |||
| 5629 | # Removes the author from the recipients and cc |
||
| 5630 | 1464:261b3d9a4903 | Chris | # if the author does not want to receive notifications |
| 5631 | # about what the author do |
||
| 5632 | if @author && @author.logged? && @author.pref.no_self_notified |
||
| 5633 | 1115:433d4f72a19b | Chris | headers[:to].delete(@author.mail) if headers[:to].is_a?(Array) |
| 5634 | headers[:cc].delete(@author.mail) if headers[:cc].is_a?(Array) |
||
| 5635 | end |
||
| 5636 | |||
| 5637 | if @author && @author.logged? |
||
| 5638 | redmine_headers 'Sender' => @author.login |
||
| 5639 | end |
||
| 5640 | |||
| 5641 | # Blind carbon copy recipients |
||
| 5642 | if Setting.bcc_recipients? |
||
| 5643 | headers[:bcc] = [headers[:to], headers[:cc]].flatten.uniq.reject(&:blank?) |
||
| 5644 | headers[:to] = nil |
||
| 5645 | headers[:cc] = nil |
||
| 5646 | end |
||
| 5647 | |||
| 5648 | if @message_id_object |
||
| 5649 | headers[:message_id] = "<#{self.class.message_id_for(@message_id_object)}>"
|
||
| 5650 | end |
||
| 5651 | if @references_objects |
||
| 5652 | 1464:261b3d9a4903 | Chris | headers[:references] = @references_objects.collect {|o| "<#{self.class.references_for(o)}>"}.join(' ')
|
| 5653 | 1115:433d4f72a19b | Chris | end |
| 5654 | |||
| 5655 | 1464:261b3d9a4903 | Chris | m = if block_given? |
| 5656 | super headers, &block |
||
| 5657 | else |
||
| 5658 | super headers do |format| |
||
| 5659 | format.text |
||
| 5660 | format.html unless Setting.plain_text_mail? |
||
| 5661 | end |
||
| 5662 | 1115:433d4f72a19b | Chris | end |
| 5663 | 1464:261b3d9a4903 | Chris | set_language_if_valid @initial_language |
| 5664 | 1115:433d4f72a19b | Chris | |
| 5665 | 1464:261b3d9a4903 | Chris | m |
| 5666 | 0:513646585e45 | Chris | end |
| 5667 | |||
| 5668 | 1115:433d4f72a19b | Chris | def initialize(*args) |
| 5669 | @initial_language = current_language |
||
| 5670 | set_language_if_valid Setting.default_language |
||
| 5671 | super |
||
| 5672 | end |
||
| 5673 | 1464:261b3d9a4903 | Chris | |
| 5674 | 1115:433d4f72a19b | Chris | def self.deliver_mail(mail) |
| 5675 | return false if mail.to.blank? && mail.cc.blank? && mail.bcc.blank? |
||
| 5676 | 1464:261b3d9a4903 | Chris | begin |
| 5677 | # Log errors when raise_delivery_errors is set to false, Rails does not |
||
| 5678 | mail.raise_delivery_errors = true |
||
| 5679 | super |
||
| 5680 | rescue Exception => e |
||
| 5681 | if ActionMailer::Base.raise_delivery_errors |
||
| 5682 | raise e |
||
| 5683 | else |
||
| 5684 | Rails.logger.error "Email delivery error: #{e.message}"
|
||
| 5685 | end |
||
| 5686 | end |
||
| 5687 | 1115:433d4f72a19b | Chris | end |
| 5688 | |||
| 5689 | def self.method_missing(method, *args, &block) |
||
| 5690 | if m = method.to_s.match(%r{^deliver_(.+)$})
|
||
| 5691 | ActiveSupport::Deprecation.warn "Mailer.deliver_#{m[1]}(*args) is deprecated. Use Mailer.#{m[1]}(*args).deliver instead."
|
||
| 5692 | send(m[1], *args).deliver |
||
| 5693 | else |
||
| 5694 | super |
||
| 5695 | end |
||
| 5696 | end |
||
| 5697 | |||
| 5698 | private |
||
| 5699 | |||
| 5700 | 0:513646585e45 | Chris | # Appends a Redmine header field (name is prepended with 'X-Redmine-') |
| 5701 | def redmine_headers(h) |
||
| 5702 | 1115:433d4f72a19b | Chris | h.each { |k,v| headers["X-Redmine-#{k}"] = v.to_s }
|
| 5703 | 0:513646585e45 | Chris | end |
| 5704 | |||
| 5705 | 1464:261b3d9a4903 | Chris | def self.token_for(object, rand=true) |
| 5706 | 441:cbce1fd3b1b7 | Chris | timestamp = object.send(object.respond_to?(:created_on) ? :created_on : :updated_on) |
| 5707 | 1464:261b3d9a4903 | Chris | hash = [ |
| 5708 | "redmine", |
||
| 5709 | "#{object.class.name.demodulize.underscore}-#{object.id}",
|
||
| 5710 | timestamp.strftime("%Y%m%d%H%M%S")
|
||
| 5711 | ] |
||
| 5712 | if rand |
||
| 5713 | hash << Redmine::Utils.random_hex(8) |
||
| 5714 | end |
||
| 5715 | 1517:dffacf8a6908 | Chris | host = Setting.mail_from.to_s.strip.gsub(%r{^.*@|>}, '')
|
| 5716 | 0:513646585e45 | Chris | host = "#{::Socket.gethostname}.redmine" if host.empty?
|
| 5717 | 1464:261b3d9a4903 | Chris | "#{hash.join('.')}@#{host}"
|
| 5718 | end |
||
| 5719 | |||
| 5720 | # Returns a Message-Id for the given object |
||
| 5721 | def self.message_id_for(object) |
||
| 5722 | token_for(object, true) |
||
| 5723 | end |
||
| 5724 | |||
| 5725 | # Returns a uniq token for a given object referenced by all notifications |
||
| 5726 | # related to this object |
||
| 5727 | def self.references_for(object) |
||
| 5728 | token_for(object, false) |
||
| 5729 | 0:513646585e45 | Chris | end |
| 5730 | 441:cbce1fd3b1b7 | Chris | |
| 5731 | 0:513646585e45 | Chris | def message_id(object) |
| 5732 | @message_id_object = object |
||
| 5733 | end |
||
| 5734 | 441:cbce1fd3b1b7 | Chris | |
| 5735 | 0:513646585e45 | Chris | def references(object) |
| 5736 | @references_objects ||= [] |
||
| 5737 | @references_objects << object |
||
| 5738 | end |
||
| 5739 | 441:cbce1fd3b1b7 | Chris | |
| 5740 | 0:513646585e45 | Chris | def mylogger |
| 5741 | 909:cbb26bc654de | Chris | Rails.logger |
| 5742 | 0:513646585e45 | Chris | end |
| 5743 | end |
||
| 5744 | 929:5f33065ddc4b | Chris | |
| 5745 | # Patch TMail so that message_id is not overwritten |
||
| 5746 | 1116:bb32da3bea34 | Chris | |
| 5747 | ### NB: Redmine 2.2 no longer uses TMail I think? This function has |
||
| 5748 | ### been removed there |
||
| 5749 | |||
| 5750 | 929:5f33065ddc4b | Chris | module TMail |
| 5751 | class Mail |
||
| 5752 | def add_message_id( fqdn = nil ) |
||
| 5753 | self.message_id ||= ::TMail::new_message_id(fqdn) |
||
| 5754 | end |
||
| 5755 | end |
||
| 5756 | end |
||
| 5757 | 291:f3b2dc533e83 | luis | |
| 5758 | 0:513646585e45 | Chris | # Redmine - project management software |
| 5759 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 5760 | 0:513646585e45 | Chris | # |
| 5761 | # This program is free software; you can redistribute it and/or |
||
| 5762 | # modify it under the terms of the GNU General Public License |
||
| 5763 | # as published by the Free Software Foundation; either version 2 |
||
| 5764 | # of the License, or (at your option) any later version. |
||
| 5765 | 909:cbb26bc654de | Chris | # |
| 5766 | 0:513646585e45 | Chris | # This program is distributed in the hope that it will be useful, |
| 5767 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 5768 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 5769 | # GNU General Public License for more details. |
||
| 5770 | 909:cbb26bc654de | Chris | # |
| 5771 | 0:513646585e45 | Chris | # You should have received a copy of the GNU General Public License |
| 5772 | # along with this program; if not, write to the Free Software |
||
| 5773 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 5774 | |||
| 5775 | class Member < ActiveRecord::Base |
||
| 5776 | belongs_to :user |
||
| 5777 | belongs_to :principal, :foreign_key => 'user_id' |
||
| 5778 | has_many :member_roles, :dependent => :destroy |
||
| 5779 | has_many :roles, :through => :member_roles |
||
| 5780 | belongs_to :project |
||
| 5781 | |||
| 5782 | validates_presence_of :principal, :project |
||
| 5783 | validates_uniqueness_of :user_id, :scope => :project_id |
||
| 5784 | 1115:433d4f72a19b | Chris | validate :validate_role |
| 5785 | 0:513646585e45 | Chris | |
| 5786 | 1115:433d4f72a19b | Chris | before_destroy :set_issue_category_nil |
| 5787 | 909:cbb26bc654de | Chris | |
| 5788 | 1115:433d4f72a19b | Chris | def role |
| 5789 | end |
||
| 5790 | |||
| 5791 | def role= |
||
| 5792 | end |
||
| 5793 | |||
| 5794 | 0:513646585e45 | Chris | def name |
| 5795 | self.user.name |
||
| 5796 | end |
||
| 5797 | 909:cbb26bc654de | Chris | |
| 5798 | 0:513646585e45 | Chris | alias :base_role_ids= :role_ids= |
| 5799 | def role_ids=(arg) |
||
| 5800 | ids = (arg || []).collect(&:to_i) - [0] |
||
| 5801 | # Keep inherited roles |
||
| 5802 | ids += member_roles.select {|mr| !mr.inherited_from.nil?}.collect(&:role_id)
|
||
| 5803 | 909:cbb26bc654de | Chris | |
| 5804 | 0:513646585e45 | Chris | new_role_ids = ids - role_ids |
| 5805 | # Add new roles |
||
| 5806 | new_role_ids.each {|id| member_roles << MemberRole.new(:role_id => id) }
|
||
| 5807 | # Remove roles (Rails' #role_ids= will not trigger MemberRole#on_destroy) |
||
| 5808 | member_roles_to_destroy = member_roles.select {|mr| !ids.include?(mr.role_id)}
|
||
| 5809 | if member_roles_to_destroy.any? |
||
| 5810 | member_roles_to_destroy.each(&:destroy) |
||
| 5811 | end |
||
| 5812 | end |
||
| 5813 | 909:cbb26bc654de | Chris | |
| 5814 | 0:513646585e45 | Chris | def <=>(member) |
| 5815 | a, b = roles.sort.first, member.roles.sort.first |
||
| 5816 | 929:5f33065ddc4b | Chris | if a == b |
| 5817 | if principal |
||
| 5818 | principal <=> member.principal |
||
| 5819 | else |
||
| 5820 | 1 |
||
| 5821 | end |
||
| 5822 | elsif a |
||
| 5823 | a <=> b |
||
| 5824 | else |
||
| 5825 | 1 |
||
| 5826 | end |
||
| 5827 | 0:513646585e45 | Chris | end |
| 5828 | 909:cbb26bc654de | Chris | |
| 5829 | 0:513646585e45 | Chris | def deletable? |
| 5830 | member_roles.detect {|mr| mr.inherited_from}.nil?
|
||
| 5831 | end |
||
| 5832 | 909:cbb26bc654de | Chris | |
| 5833 | 1517:dffacf8a6908 | Chris | def destroy |
| 5834 | if member_roles.reload.present? |
||
| 5835 | # destroying the last role will destroy another instance |
||
| 5836 | # of the same Member record, #super would then trigger callbacks twice |
||
| 5837 | member_roles.destroy_all |
||
| 5838 | @destroyed = true |
||
| 5839 | freeze |
||
| 5840 | else |
||
| 5841 | super |
||
| 5842 | end |
||
| 5843 | end |
||
| 5844 | |||
| 5845 | 0:513646585e45 | Chris | def include?(user) |
| 5846 | if principal.is_a?(Group) |
||
| 5847 | !user.nil? && user.groups.include?(principal) |
||
| 5848 | else |
||
| 5849 | self.user == user |
||
| 5850 | end |
||
| 5851 | end |
||
| 5852 | 909:cbb26bc654de | Chris | |
| 5853 | 1115:433d4f72a19b | Chris | def set_issue_category_nil |
| 5854 | 0:513646585e45 | Chris | if user |
| 5855 | # remove category based auto assignments for this member |
||
| 5856 | 1517:dffacf8a6908 | Chris | IssueCategory.where(["project_id = ? AND assigned_to_id = ?", project.id, user.id]). |
| 5857 | update_all("assigned_to_id = NULL")
|
||
| 5858 | 0:513646585e45 | Chris | end |
| 5859 | end |
||
| 5860 | |||
| 5861 | # Find or initilize a Member with an id, attributes, and for a Principal |
||
| 5862 | def self.edit_membership(id, new_attributes, principal=nil) |
||
| 5863 | @membership = id.present? ? Member.find(id) : Member.new(:principal => principal) |
||
| 5864 | @membership.attributes = new_attributes |
||
| 5865 | @membership |
||
| 5866 | end |
||
| 5867 | 909:cbb26bc654de | Chris | |
| 5868 | 1464:261b3d9a4903 | Chris | # Finds or initilizes a Member for the given project and principal |
| 5869 | def self.find_or_new(project, principal) |
||
| 5870 | project_id = project.is_a?(Project) ? project.id : project |
||
| 5871 | principal_id = principal.is_a?(Principal) ? principal.id : principal |
||
| 5872 | |||
| 5873 | member = Member.find_by_project_id_and_user_id(project_id, principal_id) |
||
| 5874 | member ||= Member.new(:project_id => project_id, :user_id => principal_id) |
||
| 5875 | member |
||
| 5876 | end |
||
| 5877 | |||
| 5878 | 0:513646585e45 | Chris | protected |
| 5879 | 909:cbb26bc654de | Chris | |
| 5880 | 1115:433d4f72a19b | Chris | def validate_role |
| 5881 | 14:1d32c0a0efbf | Chris | errors.add_on_empty :role if member_roles.empty? && roles.empty? |
| 5882 | 0:513646585e45 | Chris | end |
| 5883 | end |
||
| 5884 | # Redmine - project management software |
||
| 5885 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 5886 | 0:513646585e45 | Chris | # |
| 5887 | # This program is free software; you can redistribute it and/or |
||
| 5888 | # modify it under the terms of the GNU General Public License |
||
| 5889 | # as published by the Free Software Foundation; either version 2 |
||
| 5890 | # of the License, or (at your option) any later version. |
||
| 5891 | 909:cbb26bc654de | Chris | # |
| 5892 | 0:513646585e45 | Chris | # This program is distributed in the hope that it will be useful, |
| 5893 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 5894 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 5895 | # GNU General Public License for more details. |
||
| 5896 | 909:cbb26bc654de | Chris | # |
| 5897 | 0:513646585e45 | Chris | # You should have received a copy of the GNU General Public License |
| 5898 | # along with this program; if not, write to the Free Software |
||
| 5899 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 5900 | |||
| 5901 | class MemberRole < ActiveRecord::Base |
||
| 5902 | belongs_to :member |
||
| 5903 | belongs_to :role |
||
| 5904 | 909:cbb26bc654de | Chris | |
| 5905 | 0:513646585e45 | Chris | after_destroy :remove_member_if_empty |
| 5906 | |||
| 5907 | 1464:261b3d9a4903 | Chris | after_create :add_role_to_group_users, :add_role_to_subprojects |
| 5908 | after_destroy :remove_inherited_roles |
||
| 5909 | 909:cbb26bc654de | Chris | |
| 5910 | 0:513646585e45 | Chris | validates_presence_of :role |
| 5911 | 1115:433d4f72a19b | Chris | validate :validate_role_member |
| 5912 | 909:cbb26bc654de | Chris | |
| 5913 | 1115:433d4f72a19b | Chris | def validate_role_member |
| 5914 | 0:513646585e45 | Chris | errors.add :role_id, :invalid if role && !role.member? |
| 5915 | end |
||
| 5916 | 909:cbb26bc654de | Chris | |
| 5917 | 0:513646585e45 | Chris | def inherited? |
| 5918 | !inherited_from.nil? |
||
| 5919 | end |
||
| 5920 | 909:cbb26bc654de | Chris | |
| 5921 | 0:513646585e45 | Chris | private |
| 5922 | 909:cbb26bc654de | Chris | |
| 5923 | 0:513646585e45 | Chris | def remove_member_if_empty |
| 5924 | if member.roles.empty? |
||
| 5925 | member.destroy |
||
| 5926 | end |
||
| 5927 | end |
||
| 5928 | 909:cbb26bc654de | Chris | |
| 5929 | 0:513646585e45 | Chris | def add_role_to_group_users |
| 5930 | 1464:261b3d9a4903 | Chris | if member.principal.is_a?(Group) && !inherited? |
| 5931 | 0:513646585e45 | Chris | member.principal.users.each do |user| |
| 5932 | 1464:261b3d9a4903 | Chris | user_member = Member.find_or_new(member.project_id, user.id) |
| 5933 | 0:513646585e45 | Chris | user_member.member_roles << MemberRole.new(:role => role, :inherited_from => id) |
| 5934 | user_member.save! |
||
| 5935 | end |
||
| 5936 | end |
||
| 5937 | end |
||
| 5938 | 909:cbb26bc654de | Chris | |
| 5939 | 1464:261b3d9a4903 | Chris | def add_role_to_subprojects |
| 5940 | member.project.children.each do |subproject| |
||
| 5941 | if subproject.inherit_members? |
||
| 5942 | child_member = Member.find_or_new(subproject.id, member.user_id) |
||
| 5943 | child_member.member_roles << MemberRole.new(:role => role, :inherited_from => id) |
||
| 5944 | child_member.save! |
||
| 5945 | 0:513646585e45 | Chris | end |
| 5946 | end |
||
| 5947 | end |
||
| 5948 | 1464:261b3d9a4903 | Chris | |
| 5949 | def remove_inherited_roles |
||
| 5950 | 1517:dffacf8a6908 | Chris | MemberRole.where(:inherited_from => id).group_by(&:member). |
| 5951 | each do |member, member_roles| |
||
| 5952 | 1464:261b3d9a4903 | Chris | member_roles.each(&:destroy) |
| 5953 | end |
||
| 5954 | end |
||
| 5955 | 0:513646585e45 | Chris | end |
| 5956 | 441:cbce1fd3b1b7 | Chris | # Redmine - project management software |
| 5957 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 5958 | 0:513646585e45 | Chris | # |
| 5959 | # This program is free software; you can redistribute it and/or |
||
| 5960 | # modify it under the terms of the GNU General Public License |
||
| 5961 | # as published by the Free Software Foundation; either version 2 |
||
| 5962 | # of the License, or (at your option) any later version. |
||
| 5963 | 441:cbce1fd3b1b7 | Chris | # |
| 5964 | 0:513646585e45 | Chris | # This program is distributed in the hope that it will be useful, |
| 5965 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 5966 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 5967 | # GNU General Public License for more details. |
||
| 5968 | 441:cbce1fd3b1b7 | Chris | # |
| 5969 | 0:513646585e45 | Chris | # You should have received a copy of the GNU General Public License |
| 5970 | # along with this program; if not, write to the Free Software |
||
| 5971 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 5972 | |||
| 5973 | class Message < ActiveRecord::Base |
||
| 5974 | 929:5f33065ddc4b | Chris | include Redmine::SafeAttributes |
| 5975 | 0:513646585e45 | Chris | belongs_to :board |
| 5976 | belongs_to :author, :class_name => 'User', :foreign_key => 'author_id' |
||
| 5977 | acts_as_tree :counter_cache => :replies_count, :order => "#{Message.table_name}.created_on ASC"
|
||
| 5978 | acts_as_attachable |
||
| 5979 | belongs_to :last_reply, :class_name => 'Message', :foreign_key => 'last_reply_id' |
||
| 5980 | 441:cbce1fd3b1b7 | Chris | |
| 5981 | 0:513646585e45 | Chris | acts_as_searchable :columns => ['subject', 'content'], |
| 5982 | :include => {:board => :project},
|
||
| 5983 | 441:cbce1fd3b1b7 | Chris | :project_key => "#{Board.table_name}.project_id",
|
| 5984 | 0:513646585e45 | Chris | :date_column => "#{table_name}.created_on"
|
| 5985 | acts_as_event :title => Proc.new {|o| "#{o.board.name}: #{o.subject}"},
|
||
| 5986 | :description => :content, |
||
| 5987 | 1464:261b3d9a4903 | Chris | :group => :parent, |
| 5988 | 0:513646585e45 | Chris | :type => Proc.new {|o| o.parent_id.nil? ? 'message' : 'reply'},
|
| 5989 | 909:cbb26bc654de | Chris | :url => Proc.new {|o| {:controller => 'messages', :action => 'show', :board_id => o.board_id}.merge(o.parent_id.nil? ? {:id => o.id} :
|
| 5990 | 0:513646585e45 | Chris | {:id => o.parent_id, :r => o.id, :anchor => "message-#{o.id}"})}
|
| 5991 | |||
| 5992 | acts_as_activity_provider :find_options => {:include => [{:board => :project}, :author]},
|
||
| 5993 | :author_key => :author_id |
||
| 5994 | acts_as_watchable |
||
| 5995 | 441:cbce1fd3b1b7 | Chris | |
| 5996 | 0:513646585e45 | Chris | validates_presence_of :board, :subject, :content |
| 5997 | validates_length_of :subject, :maximum => 255 |
||
| 5998 | 909:cbb26bc654de | Chris | validate :cannot_reply_to_locked_topic, :on => :create |
| 5999 | 441:cbce1fd3b1b7 | Chris | |
| 6000 | 1115:433d4f72a19b | Chris | after_create :add_author_as_watcher, :reset_counters! |
| 6001 | 909:cbb26bc654de | Chris | after_update :update_messages_board |
| 6002 | 1115:433d4f72a19b | Chris | after_destroy :reset_counters! |
| 6003 | 1464:261b3d9a4903 | Chris | after_create :send_notification |
| 6004 | 441:cbce1fd3b1b7 | Chris | |
| 6005 | 1464:261b3d9a4903 | Chris | scope :visible, lambda {|*args|
|
| 6006 | includes(:board => :project).where(Project.allowed_to_condition(args.shift || User.current, :view_messages, *args)) |
||
| 6007 | } |
||
| 6008 | 441:cbce1fd3b1b7 | Chris | |
| 6009 | 929:5f33065ddc4b | Chris | safe_attributes 'subject', 'content' |
| 6010 | safe_attributes 'locked', 'sticky', 'board_id', |
||
| 6011 | :if => lambda {|message, user|
|
||
| 6012 | user.allowed_to?(:edit_messages, message.project) |
||
| 6013 | } |
||
| 6014 | |||
| 6015 | 0:513646585e45 | Chris | def visible?(user=User.current) |
| 6016 | !user.nil? && user.allowed_to?(:view_messages, project) |
||
| 6017 | end |
||
| 6018 | 441:cbce1fd3b1b7 | Chris | |
| 6019 | 909:cbb26bc654de | Chris | def cannot_reply_to_locked_topic |
| 6020 | 0:513646585e45 | Chris | # Can not reply to a locked topic |
| 6021 | 909:cbb26bc654de | Chris | errors.add :base, 'Topic is locked' if root.locked? && self != root |
| 6022 | 0:513646585e45 | Chris | end |
| 6023 | 441:cbce1fd3b1b7 | Chris | |
| 6024 | 909:cbb26bc654de | Chris | def update_messages_board |
| 6025 | 0:513646585e45 | Chris | if board_id_changed? |
| 6026 | 1517:dffacf8a6908 | Chris | Message.where(["id = ? OR parent_id = ?", root.id, root.id]).update_all({:board_id => board_id})
|
| 6027 | 0:513646585e45 | Chris | Board.reset_counters!(board_id_was) |
| 6028 | Board.reset_counters!(board_id) |
||
| 6029 | end |
||
| 6030 | end |
||
| 6031 | 441:cbce1fd3b1b7 | Chris | |
| 6032 | 1115:433d4f72a19b | Chris | def reset_counters! |
| 6033 | if parent && parent.id |
||
| 6034 | 1517:dffacf8a6908 | Chris | Message.where({:id => parent.id}).update_all({:last_reply_id => parent.children.maximum(:id)})
|
| 6035 | 1115:433d4f72a19b | Chris | end |
| 6036 | 0:513646585e45 | Chris | board.reset_counters! |
| 6037 | end |
||
| 6038 | 441:cbce1fd3b1b7 | Chris | |
| 6039 | 0:513646585e45 | Chris | def sticky=(arg) |
| 6040 | write_attribute :sticky, (arg == true || arg.to_s == '1' ? 1 : 0) |
||
| 6041 | end |
||
| 6042 | 441:cbce1fd3b1b7 | Chris | |
| 6043 | 0:513646585e45 | Chris | def sticky? |
| 6044 | sticky == 1 |
||
| 6045 | end |
||
| 6046 | 441:cbce1fd3b1b7 | Chris | |
| 6047 | 0:513646585e45 | Chris | def project |
| 6048 | board.project |
||
| 6049 | end |
||
| 6050 | |||
| 6051 | def editable_by?(usr) |
||
| 6052 | usr && usr.logged? && (usr.allowed_to?(:edit_messages, project) || (self.author == usr && usr.allowed_to?(:edit_own_messages, project))) |
||
| 6053 | end |
||
| 6054 | |||
| 6055 | def destroyable_by?(usr) |
||
| 6056 | usr && usr.logged? && (usr.allowed_to?(:delete_messages, project) || (self.author == usr && usr.allowed_to?(:delete_own_messages, project))) |
||
| 6057 | end |
||
| 6058 | 441:cbce1fd3b1b7 | Chris | |
| 6059 | 0:513646585e45 | Chris | private |
| 6060 | 441:cbce1fd3b1b7 | Chris | |
| 6061 | 0:513646585e45 | Chris | def add_author_as_watcher |
| 6062 | Watcher.create(:watchable => self.root, :user => author) |
||
| 6063 | end |
||
| 6064 | 1464:261b3d9a4903 | Chris | |
| 6065 | def send_notification |
||
| 6066 | if Setting.notified_events.include?('message_posted')
|
||
| 6067 | Mailer.message_posted(self).deliver |
||
| 6068 | end |
||
| 6069 | end |
||
| 6070 | 0:513646585e45 | Chris | end |
| 6071 | # Redmine - project management software |
||
| 6072 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 6073 | 0:513646585e45 | Chris | # |
| 6074 | # This program is free software; you can redistribute it and/or |
||
| 6075 | # modify it under the terms of the GNU General Public License |
||
| 6076 | # as published by the Free Software Foundation; either version 2 |
||
| 6077 | # of the License, or (at your option) any later version. |
||
| 6078 | 441:cbce1fd3b1b7 | Chris | # |
| 6079 | 0:513646585e45 | Chris | # This program is distributed in the hope that it will be useful, |
| 6080 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 6081 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 6082 | # GNU General Public License for more details. |
||
| 6083 | 441:cbce1fd3b1b7 | Chris | # |
| 6084 | 0:513646585e45 | Chris | # You should have received a copy of the GNU General Public License |
| 6085 | # along with this program; if not, write to the Free Software |
||
| 6086 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 6087 | |||
| 6088 | class News < ActiveRecord::Base |
||
| 6089 | 929:5f33065ddc4b | Chris | include Redmine::SafeAttributes |
| 6090 | 0:513646585e45 | Chris | belongs_to :project |
| 6091 | belongs_to :author, :class_name => 'User', :foreign_key => 'author_id' |
||
| 6092 | has_many :comments, :as => :commented, :dependent => :delete_all, :order => "created_on" |
||
| 6093 | 441:cbce1fd3b1b7 | Chris | |
| 6094 | 0:513646585e45 | Chris | validates_presence_of :title, :description |
| 6095 | validates_length_of :title, :maximum => 60 |
||
| 6096 | validates_length_of :summary, :maximum => 255 |
||
| 6097 | |||
| 6098 | 1115:433d4f72a19b | Chris | acts_as_attachable :delete_permission => :manage_news |
| 6099 | 0:513646585e45 | Chris | acts_as_searchable :columns => ['title', 'summary', "#{table_name}.description"], :include => :project
|
| 6100 | acts_as_event :url => Proc.new {|o| {:controller => 'news', :action => 'show', :id => o.id}}
|
||
| 6101 | acts_as_activity_provider :find_options => {:include => [:project, :author]},
|
||
| 6102 | :author_key => :author_id |
||
| 6103 | 441:cbce1fd3b1b7 | Chris | acts_as_watchable |
| 6104 | |||
| 6105 | after_create :add_author_as_watcher |
||
| 6106 | 1464:261b3d9a4903 | Chris | after_create :send_notification |
| 6107 | 441:cbce1fd3b1b7 | Chris | |
| 6108 | 1464:261b3d9a4903 | Chris | scope :visible, lambda {|*args|
|
| 6109 | includes(:project).where(Project.allowed_to_condition(args.shift || User.current, :view_news, *args)) |
||
| 6110 | } |
||
| 6111 | 441:cbce1fd3b1b7 | Chris | |
| 6112 | 929:5f33065ddc4b | Chris | safe_attributes 'title', 'summary', 'description' |
| 6113 | |||
| 6114 | 0:513646585e45 | Chris | def visible?(user=User.current) |
| 6115 | !user.nil? && user.allowed_to?(:view_news, project) |
||
| 6116 | end |
||
| 6117 | 441:cbce1fd3b1b7 | Chris | |
| 6118 | 1115:433d4f72a19b | Chris | # Returns true if the news can be commented by user |
| 6119 | def commentable?(user=User.current) |
||
| 6120 | user.allowed_to?(:comment_news, project) |
||
| 6121 | end |
||
| 6122 | |||
| 6123 | 1464:261b3d9a4903 | Chris | def recipients |
| 6124 | 1517:dffacf8a6908 | Chris | project.users.select {|user| user.notify_about?(self) && user.allowed_to?(:view_news, project)}.map(&:mail)
|
| 6125 | end |
||
| 6126 | |||
| 6127 | # Returns the email addresses that should be cc'd when a new news is added |
||
| 6128 | def cc_for_added_news |
||
| 6129 | cc = [] |
||
| 6130 | if m = project.enabled_module('news')
|
||
| 6131 | cc = m.notified_watchers |
||
| 6132 | unless project.is_public? |
||
| 6133 | cc = cc.select {|user| project.users.include?(user)}
|
||
| 6134 | end |
||
| 6135 | end |
||
| 6136 | cc.map(&:mail) |
||
| 6137 | 1464:261b3d9a4903 | Chris | end |
| 6138 | |||
| 6139 | 0:513646585e45 | Chris | # returns latest news for projects visible by user |
| 6140 | def self.latest(user = User.current, count = 5) |
||
| 6141 | 1115:433d4f72a19b | Chris | visible(user).includes([:author, :project]).order("#{News.table_name}.created_on DESC").limit(count).all
|
| 6142 | 0:513646585e45 | Chris | end |
| 6143 | 441:cbce1fd3b1b7 | Chris | |
| 6144 | private |
||
| 6145 | |||
| 6146 | def add_author_as_watcher |
||
| 6147 | Watcher.create(:watchable => self, :user => author) |
||
| 6148 | end |
||
| 6149 | 1464:261b3d9a4903 | Chris | |
| 6150 | def send_notification |
||
| 6151 | if Setting.notified_events.include?('news_added')
|
||
| 6152 | Mailer.news_added(self).deliver |
||
| 6153 | end |
||
| 6154 | end |
||
| 6155 | 1484:51364c0cd58f | Chris | |
| 6156 | 374:dcfde3922ec2 | chris | # returns latest news for a specific project |
| 6157 | def self.latest_for(project, count = 5) |
||
| 6158 | find(:all, :limit => count, :conditions => [ "#{News.table_name}.project_id = #{project.id}", Project.allowed_to_condition(User.current, :view_news) ], :include => [ :author, :project ], :order => "#{News.table_name}.created_on DESC")
|
||
| 6159 | end |
||
| 6160 | 0:513646585e45 | Chris | end |
| 6161 | # Redmine - project management software |
||
| 6162 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 6163 | 0:513646585e45 | Chris | # |
| 6164 | # This program is free software; you can redistribute it and/or |
||
| 6165 | # modify it under the terms of the GNU General Public License |
||
| 6166 | # as published by the Free Software Foundation; either version 2 |
||
| 6167 | # of the License, or (at your option) any later version. |
||
| 6168 | 909:cbb26bc654de | Chris | # |
| 6169 | 0:513646585e45 | Chris | # This program is distributed in the hope that it will be useful, |
| 6170 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 6171 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 6172 | # GNU General Public License for more details. |
||
| 6173 | 909:cbb26bc654de | Chris | # |
| 6174 | 0:513646585e45 | Chris | # You should have received a copy of the GNU General Public License |
| 6175 | # along with this program; if not, write to the Free Software |
||
| 6176 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 6177 | |||
| 6178 | class Principal < ActiveRecord::Base |
||
| 6179 | 1115:433d4f72a19b | Chris | self.table_name = "#{table_name_prefix}users#{table_name_suffix}"
|
| 6180 | 0:513646585e45 | Chris | |
| 6181 | 1464:261b3d9a4903 | Chris | # Account statuses |
| 6182 | STATUS_ANONYMOUS = 0 |
||
| 6183 | STATUS_ACTIVE = 1 |
||
| 6184 | STATUS_REGISTERED = 2 |
||
| 6185 | STATUS_LOCKED = 3 |
||
| 6186 | |||
| 6187 | 0:513646585e45 | Chris | has_many :members, :foreign_key => 'user_id', :dependent => :destroy |
| 6188 | 1517:dffacf8a6908 | Chris | has_many :memberships, :class_name => 'Member', |
| 6189 | :foreign_key => 'user_id', |
||
| 6190 | :include => [:project, :roles], |
||
| 6191 | :conditions => "#{Project.table_name}.status<>#{Project::STATUS_ARCHIVED}",
|
||
| 6192 | :order => "#{Project.table_name}.name"
|
||
| 6193 | 0:513646585e45 | Chris | has_many :projects, :through => :memberships |
| 6194 | 909:cbb26bc654de | Chris | has_many :issue_categories, :foreign_key => 'assigned_to_id', :dependent => :nullify |
| 6195 | 0:513646585e45 | Chris | |
| 6196 | # Groups and active users |
||
| 6197 | 1464:261b3d9a4903 | Chris | scope :active, lambda { where(:status => STATUS_ACTIVE) }
|
| 6198 | 909:cbb26bc654de | Chris | |
| 6199 | 1115:433d4f72a19b | Chris | scope :like, lambda {|q|
|
| 6200 | q = q.to_s |
||
| 6201 | if q.blank? |
||
| 6202 | where({})
|
||
| 6203 | else |
||
| 6204 | pattern = "%#{q}%"
|
||
| 6205 | sql = %w(login firstname lastname mail).map {|column| "LOWER(#{table_name}.#{column}) LIKE LOWER(:p)"}.join(" OR ")
|
||
| 6206 | params = {:p => pattern}
|
||
| 6207 | if q =~ /^(.+)\s+(.+)$/ |
||
| 6208 | a, b = "#{$1}%", "#{$2}%"
|
||
| 6209 | sql << " OR (LOWER(#{table_name}.firstname) LIKE LOWER(:a) AND LOWER(#{table_name}.lastname) LIKE LOWER(:b))"
|
||
| 6210 | sql << " OR (LOWER(#{table_name}.firstname) LIKE LOWER(:b) AND LOWER(#{table_name}.lastname) LIKE LOWER(:a))"
|
||
| 6211 | params.merge!(:a => a, :b => b) |
||
| 6212 | end |
||
| 6213 | where(sql, params) |
||
| 6214 | end |
||
| 6215 | } |
||
| 6216 | |||
| 6217 | # Principals that are members of a collection of projects |
||
| 6218 | scope :member_of, lambda {|projects|
|
||
| 6219 | projects = [projects] unless projects.is_a?(Array) |
||
| 6220 | if projects.empty? |
||
| 6221 | where("1=0")
|
||
| 6222 | else |
||
| 6223 | ids = projects.map(&:id) |
||
| 6224 | 1464:261b3d9a4903 | Chris | active.uniq.joins(:members).where("#{Member.table_name}.project_id IN (?)", ids)
|
| 6225 | 1115:433d4f72a19b | Chris | end |
| 6226 | 0:513646585e45 | Chris | } |
| 6227 | 929:5f33065ddc4b | Chris | # Principals that are not members of projects |
| 6228 | 1115:433d4f72a19b | Chris | scope :not_member_of, lambda {|projects|
|
| 6229 | 929:5f33065ddc4b | Chris | projects = [projects] unless projects.is_a?(Array) |
| 6230 | if projects.empty? |
||
| 6231 | 1115:433d4f72a19b | Chris | where("1=0")
|
| 6232 | 929:5f33065ddc4b | Chris | else |
| 6233 | ids = projects.map(&:id) |
||
| 6234 | 1115:433d4f72a19b | Chris | where("#{Principal.table_name}.id NOT IN (SELECT DISTINCT user_id FROM #{Member.table_name} WHERE project_id IN (?))", ids)
|
| 6235 | 929:5f33065ddc4b | Chris | end |
| 6236 | } |
||
| 6237 | 1464:261b3d9a4903 | Chris | scope :sorted, lambda { order(*Principal.fields_for_order_statement)}
|
| 6238 | 909:cbb26bc654de | Chris | |
| 6239 | 0:513646585e45 | Chris | before_create :set_default_empty_values |
| 6240 | 22:40f7cfd4df19 | chris | |
| 6241 | def name(formatter = nil) |
||
| 6242 | to_s |
||
| 6243 | end |
||
| 6244 | |||
| 6245 | 0:513646585e45 | Chris | def <=>(principal) |
| 6246 | 929:5f33065ddc4b | Chris | if principal.nil? |
| 6247 | -1 |
||
| 6248 | elsif self.class.name == principal.class.name |
||
| 6249 | 0:513646585e45 | Chris | self.to_s.downcase <=> principal.to_s.downcase |
| 6250 | else |
||
| 6251 | # groups after users |
||
| 6252 | principal.class.name <=> self.class.name |
||
| 6253 | end |
||
| 6254 | end |
||
| 6255 | 909:cbb26bc654de | Chris | |
| 6256 | 1464:261b3d9a4903 | Chris | # Returns an array of fields names than can be used to make an order statement for principals. |
| 6257 | # Users are sorted before Groups. |
||
| 6258 | # Examples: |
||
| 6259 | def self.fields_for_order_statement(table=nil) |
||
| 6260 | table ||= table_name |
||
| 6261 | columns = ['type DESC'] + (User.name_formatter[:order] - ['id']) + ['lastname', 'id'] |
||
| 6262 | columns.uniq.map {|field| "#{table}.#{field}"}
|
||
| 6263 | end |
||
| 6264 | |||
| 6265 | 0:513646585e45 | Chris | protected |
| 6266 | 909:cbb26bc654de | Chris | |
| 6267 | 0:513646585e45 | Chris | # Make sure we don't try to insert NULL values (see #4632) |
| 6268 | def set_default_empty_values |
||
| 6269 | self.login ||= '' |
||
| 6270 | self.hashed_password ||= '' |
||
| 6271 | self.firstname ||= '' |
||
| 6272 | self.lastname ||= '' |
||
| 6273 | self.mail ||= '' |
||
| 6274 | true |
||
| 6275 | end |
||
| 6276 | end |
||
| 6277 | 441:cbce1fd3b1b7 | Chris | # Redmine - project management software |
| 6278 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 6279 | 0:513646585e45 | Chris | # |
| 6280 | # This program is free software; you can redistribute it and/or |
||
| 6281 | # modify it under the terms of the GNU General Public License |
||
| 6282 | # as published by the Free Software Foundation; either version 2 |
||
| 6283 | # of the License, or (at your option) any later version. |
||
| 6284 | 909:cbb26bc654de | Chris | # |
| 6285 | 0:513646585e45 | Chris | # This program is distributed in the hope that it will be useful, |
| 6286 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 6287 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 6288 | # GNU General Public License for more details. |
||
| 6289 | 909:cbb26bc654de | Chris | # |
| 6290 | 0:513646585e45 | Chris | # You should have received a copy of the GNU General Public License |
| 6291 | # along with this program; if not, write to the Free Software |
||
| 6292 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 6293 | |||
| 6294 | class Project < ActiveRecord::Base |
||
| 6295 | 117:af80e5618e9b | Chris | include Redmine::SafeAttributes |
| 6296 | 909:cbb26bc654de | Chris | |
| 6297 | 0:513646585e45 | Chris | # Project statuses |
| 6298 | STATUS_ACTIVE = 1 |
||
| 6299 | 1115:433d4f72a19b | Chris | STATUS_CLOSED = 5 |
| 6300 | 0:513646585e45 | Chris | STATUS_ARCHIVED = 9 |
| 6301 | 909:cbb26bc654de | Chris | |
| 6302 | 37:94944d00e43c | chris | # Maximum length for project identifiers |
| 6303 | IDENTIFIER_MAX_LENGTH = 100 |
||
| 6304 | 909:cbb26bc654de | Chris | |
| 6305 | 0:513646585e45 | Chris | # Specific overidden Activities |
| 6306 | has_many :time_entry_activities |
||
| 6307 | 1464:261b3d9a4903 | Chris | has_many :members, :include => [:principal, :roles], :conditions => "#{Principal.table_name}.type='User' AND #{Principal.table_name}.status=#{Principal::STATUS_ACTIVE}"
|
| 6308 | 0:513646585e45 | Chris | has_many :memberships, :class_name => 'Member' |
| 6309 | 909:cbb26bc654de | Chris | has_many :member_principals, :class_name => 'Member', |
| 6310 | 0:513646585e45 | Chris | :include => :principal, |
| 6311 | 1464:261b3d9a4903 | Chris | :conditions => "#{Principal.table_name}.type='Group' OR (#{Principal.table_name}.type='User' AND #{Principal.table_name}.status=#{Principal::STATUS_ACTIVE})"
|
| 6312 | 909:cbb26bc654de | Chris | |
| 6313 | 0:513646585e45 | Chris | has_many :enabled_modules, :dependent => :delete_all |
| 6314 | has_and_belongs_to_many :trackers, :order => "#{Tracker.table_name}.position"
|
||
| 6315 | 1115:433d4f72a19b | Chris | has_many :issues, :dependent => :destroy, :include => [:status, :tracker] |
| 6316 | 0:513646585e45 | Chris | has_many :issue_changes, :through => :issues, :source => :journals |
| 6317 | has_many :versions, :dependent => :destroy, :order => "#{Version.table_name}.effective_date DESC, #{Version.table_name}.name DESC"
|
||
| 6318 | 1517:dffacf8a6908 | Chris | has_many :time_entries, :dependent => :destroy |
| 6319 | 1464:261b3d9a4903 | Chris | has_many :queries, :class_name => 'IssueQuery', :dependent => :delete_all |
| 6320 | 0:513646585e45 | Chris | has_many :documents, :dependent => :destroy |
| 6321 | 441:cbce1fd3b1b7 | Chris | has_many :news, :dependent => :destroy, :include => :author |
| 6322 | 0:513646585e45 | Chris | has_many :issue_categories, :dependent => :delete_all, :order => "#{IssueCategory.table_name}.name"
|
| 6323 | has_many :boards, :dependent => :destroy, :order => "position ASC" |
||
| 6324 | 1115:433d4f72a19b | Chris | has_one :repository, :conditions => ["is_default = ?", true] |
| 6325 | has_many :repositories, :dependent => :destroy |
||
| 6326 | 0:513646585e45 | Chris | has_many :changesets, :through => :repository |
| 6327 | has_one :wiki, :dependent => :destroy |
||
| 6328 | # Custom field for the project issues |
||
| 6329 | 909:cbb26bc654de | Chris | has_and_belongs_to_many :issue_custom_fields, |
| 6330 | 0:513646585e45 | Chris | :class_name => 'IssueCustomField', |
| 6331 | :order => "#{CustomField.table_name}.position",
|
||
| 6332 | :join_table => "#{table_name_prefix}custom_fields_projects#{table_name_suffix}",
|
||
| 6333 | :association_foreign_key => 'custom_field_id' |
||
| 6334 | 909:cbb26bc654de | Chris | |
| 6335 | 1517:dffacf8a6908 | Chris | acts_as_nested_set :dependent => :destroy |
| 6336 | 0:513646585e45 | Chris | acts_as_attachable :view_permission => :view_files, |
| 6337 | :delete_permission => :manage_files |
||
| 6338 | |||
| 6339 | acts_as_customizable |
||
| 6340 | acts_as_searchable :columns => ['name', 'identifier', 'description'], :project_key => 'id', :permission => nil |
||
| 6341 | acts_as_event :title => Proc.new {|o| "#{l(:label_project)}: #{o.name}"},
|
||
| 6342 | :url => Proc.new {|o| {:controller => 'projects', :action => 'show', :id => o}},
|
||
| 6343 | :author => nil |
||
| 6344 | |||
| 6345 | 117:af80e5618e9b | Chris | attr_protected :status |
| 6346 | 909:cbb26bc654de | Chris | |
| 6347 | 0:513646585e45 | Chris | validates_presence_of :name, :identifier |
| 6348 | 37:94944d00e43c | chris | validates_uniqueness_of :identifier |
| 6349 | 0:513646585e45 | Chris | validates_associated :repository, :wiki |
| 6350 | 37:94944d00e43c | chris | validates_length_of :name, :maximum => 255 |
| 6351 | 0:513646585e45 | Chris | validates_length_of :homepage, :maximum => 255 |
| 6352 | 37:94944d00e43c | chris | validates_length_of :identifier, :in => 1..IDENTIFIER_MAX_LENGTH |
| 6353 | 0:513646585e45 | Chris | # donwcase letters, digits, dashes but not digits only |
| 6354 | 1464:261b3d9a4903 | Chris | validates_format_of :identifier, :with => /\A(?!\d+$)[a-z0-9\-_]*\z/, :if => Proc.new { |p| p.identifier_changed? }
|
| 6355 | 0:513646585e45 | Chris | # reserved words |
| 6356 | validates_exclusion_of :identifier, :in => %w( new ) |
||
| 6357 | |||
| 6358 | 1115:433d4f72a19b | Chris | after_save :update_position_under_parent, :if => Proc.new {|project| project.name_changed?}
|
| 6359 | 1464:261b3d9a4903 | Chris | after_save :update_inherited_members, :if => Proc.new {|project| project.inherit_members_changed?}
|
| 6360 | 441:cbce1fd3b1b7 | Chris | before_destroy :delete_all_members |
| 6361 | 0:513646585e45 | Chris | |
| 6362 | 1464:261b3d9a4903 | Chris | scope :has_module, lambda {|mod|
|
| 6363 | where("#{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name=?)", mod.to_s)
|
||
| 6364 | } |
||
| 6365 | scope :active, lambda { where(:status => STATUS_ACTIVE) }
|
||
| 6366 | scope :status, lambda {|arg| where(arg.blank? ? nil : {:status => arg.to_i}) }
|
||
| 6367 | scope :all_public, lambda { where(:is_public => true) }
|
||
| 6368 | scope :visible, lambda {|*args| where(Project.visible_condition(args.shift || User.current, *args)) }
|
||
| 6369 | 1484:51364c0cd58f | Chris | scope :visible_roots, lambda {|*args| where(Project.root_visible_by(args.shift || User.current, *args)) }
|
| 6370 | 1464:261b3d9a4903 | Chris | scope :allowed_to, lambda {|*args|
|
| 6371 | 1115:433d4f72a19b | Chris | user = User.current |
| 6372 | permission = nil |
||
| 6373 | if args.first.is_a?(Symbol) |
||
| 6374 | permission = args.shift |
||
| 6375 | else |
||
| 6376 | user = args.shift |
||
| 6377 | permission = args.shift |
||
| 6378 | end |
||
| 6379 | 1464:261b3d9a4903 | Chris | where(Project.allowed_to_condition(user, permission, *args)) |
| 6380 | 1115:433d4f72a19b | Chris | } |
| 6381 | scope :like, lambda {|arg|
|
||
| 6382 | if arg.blank? |
||
| 6383 | 1464:261b3d9a4903 | Chris | where(nil) |
| 6384 | 1115:433d4f72a19b | Chris | else |
| 6385 | pattern = "%#{arg.to_s.strip.downcase}%"
|
||
| 6386 | 1464:261b3d9a4903 | Chris | where("LOWER(identifier) LIKE :p OR LOWER(name) LIKE :p", :p => pattern)
|
| 6387 | 1115:433d4f72a19b | Chris | end |
| 6388 | } |
||
| 6389 | 909:cbb26bc654de | Chris | |
| 6390 | 1115:433d4f72a19b | Chris | def initialize(attributes=nil, *args) |
| 6391 | 117:af80e5618e9b | Chris | super |
| 6392 | 909:cbb26bc654de | Chris | |
| 6393 | 117:af80e5618e9b | Chris | initialized = (attributes || {}).stringify_keys
|
| 6394 | 909:cbb26bc654de | Chris | if !initialized.key?('identifier') && Setting.sequential_project_identifiers?
|
| 6395 | 117:af80e5618e9b | Chris | self.identifier = Project.next_identifier |
| 6396 | end |
||
| 6397 | if !initialized.key?('is_public')
|
||
| 6398 | self.is_public = Setting.default_projects_public? |
||
| 6399 | end |
||
| 6400 | if !initialized.key?('enabled_module_names')
|
||
| 6401 | self.enabled_module_names = Setting.default_projects_modules |
||
| 6402 | end |
||
| 6403 | if !initialized.key?('trackers') && !initialized.key?('tracker_ids')
|
||
| 6404 | 1464:261b3d9a4903 | Chris | default = Setting.default_projects_tracker_ids |
| 6405 | if default.is_a?(Array) |
||
| 6406 | self.trackers = Tracker.where(:id => default.map(&:to_i)).sorted.all |
||
| 6407 | else |
||
| 6408 | self.trackers = Tracker.sorted.all |
||
| 6409 | end |
||
| 6410 | 117:af80e5618e9b | Chris | end |
| 6411 | end |
||
| 6412 | 909:cbb26bc654de | Chris | |
| 6413 | 0:513646585e45 | Chris | def identifier=(identifier) |
| 6414 | super unless identifier_frozen? |
||
| 6415 | end |
||
| 6416 | 909:cbb26bc654de | Chris | |
| 6417 | 0:513646585e45 | Chris | def identifier_frozen? |
| 6418 | 1115:433d4f72a19b | Chris | errors[:identifier].blank? && !(new_record? || identifier.blank?) |
| 6419 | 0:513646585e45 | Chris | end |
| 6420 | |||
| 6421 | # returns latest created projects |
||
| 6422 | # non public projects will be returned only if user is a member of those |
||
| 6423 | def self.latest(user=nil, count=5) |
||
| 6424 | 1464:261b3d9a4903 | Chris | visible(user).limit(count).order("created_on DESC").all
|
| 6425 | end |
||
| 6426 | 0:513646585e45 | Chris | |
| 6427 | 507:0c939c159af4 | Chris | # Returns true if the project is visible to +user+ or to the current user. |
| 6428 | def visible?(user=User.current) |
||
| 6429 | user.allowed_to?(:view_project, self) |
||
| 6430 | end |
||
| 6431 | 909:cbb26bc654de | Chris | |
| 6432 | 441:cbce1fd3b1b7 | Chris | # Returns a SQL conditions string used to find all projects visible by the specified user. |
| 6433 | 0:513646585e45 | Chris | # |
| 6434 | # Examples: |
||
| 6435 | 441:cbce1fd3b1b7 | Chris | # Project.visible_condition(admin) => "projects.status = 1" |
| 6436 | # Project.visible_condition(normal_user) => "((projects.status = 1) AND (projects.is_public = 1 OR projects.id IN (1,3,4)))" |
||
| 6437 | # Project.visible_condition(anonymous) => "((projects.status = 1) AND (projects.is_public = 1))" |
||
| 6438 | def self.visible_condition(user, options={})
|
||
| 6439 | allowed_to_condition(user, :view_project, options) |
||
| 6440 | 0:513646585e45 | Chris | end |
| 6441 | 909:cbb26bc654de | Chris | |
| 6442 | 1501:467282ce64a4 | Chris | def self.root_visible_by(user, options={})
|
| 6443 | return "#{Project.table_name}.parent_id IS NULL AND " + visible_condition(user, options)
|
||
| 6444 | 205:05f9a2a9c753 | chris | end |
| 6445 | |||
| 6446 | 441:cbce1fd3b1b7 | Chris | # Returns a SQL conditions string used to find all projects for which +user+ has the given +permission+ |
| 6447 | # |
||
| 6448 | # Valid options: |
||
| 6449 | # * :project => limit the condition to project |
||
| 6450 | # * :with_subprojects => limit the condition to project and its subprojects |
||
| 6451 | # * :member => limit the condition to the user projects |
||
| 6452 | 0:513646585e45 | Chris | def self.allowed_to_condition(user, permission, options={})
|
| 6453 | 1115:433d4f72a19b | Chris | perm = Redmine::AccessControl.permission(permission) |
| 6454 | base_statement = (perm && perm.read? ? "#{Project.table_name}.status <> #{Project::STATUS_ARCHIVED}" : "#{Project.table_name}.status = #{Project::STATUS_ACTIVE}")
|
||
| 6455 | if perm && perm.project_module |
||
| 6456 | # If the permission belongs to a project module, make sure the module is enabled |
||
| 6457 | base_statement << " AND #{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name='#{perm.project_module}')"
|
||
| 6458 | 0:513646585e45 | Chris | end |
| 6459 | if options[:project] |
||
| 6460 | project_statement = "#{Project.table_name}.id = #{options[:project].id}"
|
||
| 6461 | project_statement << " OR (#{Project.table_name}.lft > #{options[:project].lft} AND #{Project.table_name}.rgt < #{options[:project].rgt})" if options[:with_subprojects]
|
||
| 6462 | base_statement = "(#{project_statement}) AND (#{base_statement})"
|
||
| 6463 | end |
||
| 6464 | 909:cbb26bc654de | Chris | |
| 6465 | 0:513646585e45 | Chris | if user.admin? |
| 6466 | 441:cbce1fd3b1b7 | Chris | base_statement |
| 6467 | 0:513646585e45 | Chris | else |
| 6468 | 441:cbce1fd3b1b7 | Chris | statement_by_role = {}
|
| 6469 | unless options[:member] |
||
| 6470 | 1464:261b3d9a4903 | Chris | role = user.builtin_role |
| 6471 | 441:cbce1fd3b1b7 | Chris | if role.allowed_to?(permission) |
| 6472 | statement_by_role[role] = "#{Project.table_name}.is_public = #{connection.quoted_true}"
|
||
| 6473 | end |
||
| 6474 | end |
||
| 6475 | 0:513646585e45 | Chris | if user.logged? |
| 6476 | 441:cbce1fd3b1b7 | Chris | user.projects_by_role.each do |role, projects| |
| 6477 | 1115:433d4f72a19b | Chris | if role.allowed_to?(permission) && projects.any? |
| 6478 | 441:cbce1fd3b1b7 | Chris | statement_by_role[role] = "#{Project.table_name}.id IN (#{projects.collect(&:id).join(',')})"
|
| 6479 | end |
||
| 6480 | 0:513646585e45 | Chris | end |
| 6481 | 441:cbce1fd3b1b7 | Chris | end |
| 6482 | if statement_by_role.empty? |
||
| 6483 | "1=0" |
||
| 6484 | 0:513646585e45 | Chris | else |
| 6485 | 441:cbce1fd3b1b7 | Chris | if block_given? |
| 6486 | statement_by_role.each do |role, statement| |
||
| 6487 | if s = yield(role, user) |
||
| 6488 | statement_by_role[role] = "(#{statement} AND (#{s}))"
|
||
| 6489 | end |
||
| 6490 | end |
||
| 6491 | end |
||
| 6492 | "((#{base_statement}) AND (#{statement_by_role.values.join(' OR ')}))"
|
||
| 6493 | 0:513646585e45 | Chris | end |
| 6494 | end |
||
| 6495 | end |
||
| 6496 | |||
| 6497 | 1464:261b3d9a4903 | Chris | def principals |
| 6498 | @principals ||= Principal.active.joins(:members).where("#{Member.table_name}.project_id = ?", id).uniq
|
||
| 6499 | end |
||
| 6500 | |||
| 6501 | def users |
||
| 6502 | @users ||= User.active.joins(:members).where("#{Member.table_name}.project_id = ?", id).uniq
|
||
| 6503 | end |
||
| 6504 | |||
| 6505 | 0:513646585e45 | Chris | # Returns the Systemwide and project specific activities |
| 6506 | def activities(include_inactive=false) |
||
| 6507 | if include_inactive |
||
| 6508 | return all_activities |
||
| 6509 | else |
||
| 6510 | return active_activities |
||
| 6511 | end |
||
| 6512 | end |
||
| 6513 | |||
| 6514 | # Will create a new Project specific Activity or update an existing one |
||
| 6515 | # |
||
| 6516 | # This will raise a ActiveRecord::Rollback if the TimeEntryActivity |
||
| 6517 | # does not successfully save. |
||
| 6518 | def update_or_create_time_entry_activity(id, activity_hash) |
||
| 6519 | if activity_hash.respond_to?(:has_key?) && activity_hash.has_key?('parent_id')
|
||
| 6520 | self.create_time_entry_activity_if_needed(activity_hash) |
||
| 6521 | else |
||
| 6522 | activity = project.time_entry_activities.find_by_id(id.to_i) |
||
| 6523 | activity.update_attributes(activity_hash) if activity |
||
| 6524 | end |
||
| 6525 | end |
||
| 6526 | 909:cbb26bc654de | Chris | |
| 6527 | 0:513646585e45 | Chris | # Create a new TimeEntryActivity if it overrides a system TimeEntryActivity |
| 6528 | # |
||
| 6529 | # This will raise a ActiveRecord::Rollback if the TimeEntryActivity |
||
| 6530 | # does not successfully save. |
||
| 6531 | def create_time_entry_activity_if_needed(activity) |
||
| 6532 | if activity['parent_id'] |
||
| 6533 | parent_activity = TimeEntryActivity.find(activity['parent_id']) |
||
| 6534 | activity['name'] = parent_activity.name |
||
| 6535 | activity['position'] = parent_activity.position |
||
| 6536 | if Enumeration.overridding_change?(activity, parent_activity) |
||
| 6537 | project_activity = self.time_entry_activities.create(activity) |
||
| 6538 | if project_activity.new_record? |
||
| 6539 | raise ActiveRecord::Rollback, "Overridding TimeEntryActivity was not successfully saved" |
||
| 6540 | else |
||
| 6541 | 1517:dffacf8a6908 | Chris | self.time_entries. |
| 6542 | where(["activity_id = ?", parent_activity.id]). |
||
| 6543 | update_all("activity_id = #{project_activity.id}")
|
||
| 6544 | 0:513646585e45 | Chris | end |
| 6545 | end |
||
| 6546 | end |
||
| 6547 | end |
||
| 6548 | |||
| 6549 | # Returns a :conditions SQL string that can be used to find the issues associated with this project. |
||
| 6550 | # |
||
| 6551 | # Examples: |
||
| 6552 | # project.project_condition(true) => "(projects.id = 1 OR (projects.lft > 1 AND projects.rgt < 10))" |
||
| 6553 | # project.project_condition(false) => "projects.id = 1" |
||
| 6554 | def project_condition(with_subprojects) |
||
| 6555 | cond = "#{Project.table_name}.id = #{id}"
|
||
| 6556 | cond = "(#{cond} OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt}))" if with_subprojects
|
||
| 6557 | cond |
||
| 6558 | end |
||
| 6559 | 909:cbb26bc654de | Chris | |
| 6560 | 0:513646585e45 | Chris | def self.find(*args) |
| 6561 | if args.first && args.first.is_a?(String) && !args.first.match(/^\d*$/) |
||
| 6562 | project = find_by_identifier(*args) |
||
| 6563 | raise ActiveRecord::RecordNotFound, "Couldn't find Project with identifier=#{args.first}" if project.nil?
|
||
| 6564 | project |
||
| 6565 | else |
||
| 6566 | super |
||
| 6567 | end |
||
| 6568 | end |
||
| 6569 | 909:cbb26bc654de | Chris | |
| 6570 | 1115:433d4f72a19b | Chris | def self.find_by_param(*args) |
| 6571 | self.find(*args) |
||
| 6572 | end |
||
| 6573 | |||
| 6574 | 1464:261b3d9a4903 | Chris | alias :base_reload :reload |
| 6575 | 1115:433d4f72a19b | Chris | def reload(*args) |
| 6576 | 1464:261b3d9a4903 | Chris | @principals = nil |
| 6577 | @users = nil |
||
| 6578 | 1115:433d4f72a19b | Chris | @shared_versions = nil |
| 6579 | @rolled_up_versions = nil |
||
| 6580 | @rolled_up_trackers = nil |
||
| 6581 | @all_issue_custom_fields = nil |
||
| 6582 | @all_time_entry_custom_fields = nil |
||
| 6583 | @to_param = nil |
||
| 6584 | @allowed_parents = nil |
||
| 6585 | @allowed_permissions = nil |
||
| 6586 | @actions_allowed = nil |
||
| 6587 | 1464:261b3d9a4903 | Chris | @start_date = nil |
| 6588 | @due_date = nil |
||
| 6589 | base_reload(*args) |
||
| 6590 | 1115:433d4f72a19b | Chris | end |
| 6591 | |||
| 6592 | 0:513646585e45 | Chris | def to_param |
| 6593 | # id is used for projects with a numeric identifier (compatibility) |
||
| 6594 | 929:5f33065ddc4b | Chris | @to_param ||= (identifier.to_s =~ %r{^\d*$} ? id.to_s : identifier)
|
| 6595 | 0:513646585e45 | Chris | end |
| 6596 | 909:cbb26bc654de | Chris | |
| 6597 | 0:513646585e45 | Chris | def active? |
| 6598 | self.status == STATUS_ACTIVE |
||
| 6599 | end |
||
| 6600 | 909:cbb26bc654de | Chris | |
| 6601 | 37:94944d00e43c | chris | def archived? |
| 6602 | self.status == STATUS_ARCHIVED |
||
| 6603 | end |
||
| 6604 | 909:cbb26bc654de | Chris | |
| 6605 | 0:513646585e45 | Chris | # Archives the project and its descendants |
| 6606 | def archive |
||
| 6607 | # Check that there is no issue of a non descendant project that is assigned |
||
| 6608 | # to one of the project or descendant versions |
||
| 6609 | v_ids = self_and_descendants.collect {|p| p.version_ids}.flatten
|
||
| 6610 | 1464:261b3d9a4903 | Chris | if v_ids.any? && |
| 6611 | Issue. |
||
| 6612 | includes(:project). |
||
| 6613 | where("#{Project.table_name}.lft < ? OR #{Project.table_name}.rgt > ?", lft, rgt).
|
||
| 6614 | where("#{Issue.table_name}.fixed_version_id IN (?)", v_ids).
|
||
| 6615 | exists? |
||
| 6616 | 0:513646585e45 | Chris | return false |
| 6617 | end |
||
| 6618 | Project.transaction do |
||
| 6619 | archive! |
||
| 6620 | end |
||
| 6621 | true |
||
| 6622 | end |
||
| 6623 | 909:cbb26bc654de | Chris | |
| 6624 | 0:513646585e45 | Chris | # Unarchives the project |
| 6625 | # All its ancestors must be active |
||
| 6626 | def unarchive |
||
| 6627 | return false if ancestors.detect {|a| !a.active?}
|
||
| 6628 | update_attribute :status, STATUS_ACTIVE |
||
| 6629 | end |
||
| 6630 | 909:cbb26bc654de | Chris | |
| 6631 | 1115:433d4f72a19b | Chris | def close |
| 6632 | self_and_descendants.status(STATUS_ACTIVE).update_all :status => STATUS_CLOSED |
||
| 6633 | end |
||
| 6634 | |||
| 6635 | def reopen |
||
| 6636 | self_and_descendants.status(STATUS_CLOSED).update_all :status => STATUS_ACTIVE |
||
| 6637 | end |
||
| 6638 | |||
| 6639 | 0:513646585e45 | Chris | # Returns an array of projects the project can be moved to |
| 6640 | # by the current user |
||
| 6641 | def allowed_parents |
||
| 6642 | return @allowed_parents if @allowed_parents |
||
| 6643 | 1464:261b3d9a4903 | Chris | @allowed_parents = Project.where(Project.allowed_to_condition(User.current, :add_subprojects)).all |
| 6644 | 0:513646585e45 | Chris | @allowed_parents = @allowed_parents - self_and_descendants |
| 6645 | if User.current.allowed_to?(:add_project, nil, :global => true) || (!new_record? && parent.nil?) |
||
| 6646 | @allowed_parents << nil |
||
| 6647 | end |
||
| 6648 | unless parent.nil? || @allowed_parents.empty? || @allowed_parents.include?(parent) |
||
| 6649 | @allowed_parents << parent |
||
| 6650 | end |
||
| 6651 | @allowed_parents |
||
| 6652 | end |
||
| 6653 | 909:cbb26bc654de | Chris | |
| 6654 | 0:513646585e45 | Chris | # Sets the parent of the project with authorization check |
| 6655 | def set_allowed_parent!(p) |
||
| 6656 | unless p.nil? || p.is_a?(Project) |
||
| 6657 | if p.to_s.blank? |
||
| 6658 | p = nil |
||
| 6659 | else |
||
| 6660 | p = Project.find_by_id(p) |
||
| 6661 | return false unless p |
||
| 6662 | end |
||
| 6663 | end |
||
| 6664 | if p.nil? |
||
| 6665 | if !new_record? && allowed_parents.empty? |
||
| 6666 | return false |
||
| 6667 | end |
||
| 6668 | elsif !allowed_parents.include?(p) |
||
| 6669 | return false |
||
| 6670 | end |
||
| 6671 | set_parent!(p) |
||
| 6672 | end |
||
| 6673 | 909:cbb26bc654de | Chris | |
| 6674 | 0:513646585e45 | Chris | # Sets the parent of the project |
| 6675 | # Argument can be either a Project, a String, a Fixnum or nil |
||
| 6676 | def set_parent!(p) |
||
| 6677 | unless p.nil? || p.is_a?(Project) |
||
| 6678 | if p.to_s.blank? |
||
| 6679 | p = nil |
||
| 6680 | else |
||
| 6681 | p = Project.find_by_id(p) |
||
| 6682 | return false unless p |
||
| 6683 | end |
||
| 6684 | end |
||
| 6685 | if p == parent && !p.nil? |
||
| 6686 | # Nothing to do |
||
| 6687 | true |
||
| 6688 | elsif p.nil? || (p.active? && move_possible?(p)) |
||
| 6689 | 1115:433d4f72a19b | Chris | set_or_update_position_under(p) |
| 6690 | 0:513646585e45 | Chris | Issue.update_versions_from_hierarchy_change(self) |
| 6691 | true |
||
| 6692 | else |
||
| 6693 | # Can not move to the given target |
||
| 6694 | false |
||
| 6695 | end |
||
| 6696 | end |
||
| 6697 | 909:cbb26bc654de | Chris | |
| 6698 | 1115:433d4f72a19b | Chris | # Recalculates all lft and rgt values based on project names |
| 6699 | # Unlike Project.rebuild!, these values are recalculated even if the tree "looks" valid |
||
| 6700 | # Used in BuildProjectsTree migration |
||
| 6701 | def self.rebuild_tree! |
||
| 6702 | transaction do |
||
| 6703 | update_all "lft = NULL, rgt = NULL" |
||
| 6704 | rebuild!(false) |
||
| 6705 | 1517:dffacf8a6908 | Chris | all.each { |p| p.set_or_update_position_under(p.parent) }
|
| 6706 | 1115:433d4f72a19b | Chris | end |
| 6707 | end |
||
| 6708 | |||
| 6709 | 0:513646585e45 | Chris | # Returns an array of the trackers used by the project and its active sub projects |
| 6710 | def rolled_up_trackers |
||
| 6711 | @rolled_up_trackers ||= |
||
| 6712 | 1464:261b3d9a4903 | Chris | Tracker. |
| 6713 | joins(:projects). |
||
| 6714 | joins("JOIN #{EnabledModule.table_name} ON #{EnabledModule.table_name}.project_id = #{Project.table_name}.id AND #{EnabledModule.table_name}.name = 'issue_tracking'").
|
||
| 6715 | select("DISTINCT #{Tracker.table_name}.*").
|
||
| 6716 | where("#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ? AND #{Project.table_name}.status <> #{STATUS_ARCHIVED}", lft, rgt).
|
||
| 6717 | sorted. |
||
| 6718 | all |
||
| 6719 | 0:513646585e45 | Chris | end |
| 6720 | 909:cbb26bc654de | Chris | |
| 6721 | 0:513646585e45 | Chris | # Closes open and locked project versions that are completed |
| 6722 | def close_completed_versions |
||
| 6723 | Version.transaction do |
||
| 6724 | 1517:dffacf8a6908 | Chris | versions.where(:status => %w(open locked)).each do |version| |
| 6725 | 0:513646585e45 | Chris | if version.completed? |
| 6726 | version.update_attribute(:status, 'closed') |
||
| 6727 | end |
||
| 6728 | end |
||
| 6729 | end |
||
| 6730 | end |
||
| 6731 | |||
| 6732 | # Returns a scope of the Versions on subprojects |
||
| 6733 | def rolled_up_versions |
||
| 6734 | @rolled_up_versions ||= |
||
| 6735 | 1464:261b3d9a4903 | Chris | Version. |
| 6736 | includes(:project). |
||
| 6737 | where("#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ? AND #{Project.table_name}.status <> ?", lft, rgt, STATUS_ARCHIVED)
|
||
| 6738 | 0:513646585e45 | Chris | end |
| 6739 | 909:cbb26bc654de | Chris | |
| 6740 | 0:513646585e45 | Chris | # Returns a scope of the Versions used by the project |
| 6741 | def shared_versions |
||
| 6742 | 929:5f33065ddc4b | Chris | if new_record? |
| 6743 | 1464:261b3d9a4903 | Chris | Version. |
| 6744 | includes(:project). |
||
| 6745 | where("#{Project.table_name}.status <> ? AND #{Version.table_name}.sharing = 'system'", STATUS_ARCHIVED)
|
||
| 6746 | 929:5f33065ddc4b | Chris | else |
| 6747 | @shared_versions ||= begin |
||
| 6748 | r = root? ? self : root |
||
| 6749 | 1464:261b3d9a4903 | Chris | Version. |
| 6750 | includes(:project). |
||
| 6751 | where("#{Project.table_name}.id = #{id}" +
|
||
| 6752 | " OR (#{Project.table_name}.status <> #{Project::STATUS_ARCHIVED} AND (" +
|
||
| 6753 | " #{Version.table_name}.sharing = 'system'" +
|
||
| 6754 | " OR (#{Project.table_name}.lft >= #{r.lft} AND #{Project.table_name}.rgt <= #{r.rgt} AND #{Version.table_name}.sharing = 'tree')" +
|
||
| 6755 | " OR (#{Project.table_name}.lft < #{lft} AND #{Project.table_name}.rgt > #{rgt} AND #{Version.table_name}.sharing IN ('hierarchy', 'descendants'))" +
|
||
| 6756 | " OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt} AND #{Version.table_name}.sharing = 'hierarchy')" +
|
||
| 6757 | "))") |
||
| 6758 | 929:5f33065ddc4b | Chris | end |
| 6759 | 441:cbce1fd3b1b7 | Chris | end |
| 6760 | 0:513646585e45 | Chris | end |
| 6761 | |||
| 6762 | # Returns a hash of project users grouped by role |
||
| 6763 | def users_by_role |
||
| 6764 | 1517:dffacf8a6908 | Chris | members.includes(:user, :roles).inject({}) do |h, m|
|
| 6765 | 0:513646585e45 | Chris | m.roles.each do |r| |
| 6766 | h[r] ||= [] |
||
| 6767 | h[r] << m.user |
||
| 6768 | end |
||
| 6769 | h |
||
| 6770 | end |
||
| 6771 | end |
||
| 6772 | 909:cbb26bc654de | Chris | |
| 6773 | 0:513646585e45 | Chris | # Deletes all project's members |
| 6774 | def delete_all_members |
||
| 6775 | me, mr = Member.table_name, MemberRole.table_name |
||
| 6776 | connection.delete("DELETE FROM #{mr} WHERE #{mr}.member_id IN (SELECT #{me}.id FROM #{me} WHERE #{me}.project_id = #{id})")
|
||
| 6777 | Member.delete_all(['project_id = ?', id]) |
||
| 6778 | end |
||
| 6779 | 909:cbb26bc654de | Chris | |
| 6780 | # Users/groups issues can be assigned to |
||
| 6781 | 0:513646585e45 | Chris | def assignable_users |
| 6782 | 909:cbb26bc654de | Chris | assignable = Setting.issue_group_assignment? ? member_principals : members |
| 6783 | assignable.select {|m| m.roles.detect {|role| role.assignable?}}.collect {|m| m.principal}.sort
|
||
| 6784 | 0:513646585e45 | Chris | end |
| 6785 | 909:cbb26bc654de | Chris | |
| 6786 | 0:513646585e45 | Chris | # Returns the mail adresses of users that should be always notified on project events |
| 6787 | def recipients |
||
| 6788 | 37:94944d00e43c | chris | notified_users.collect {|user| user.mail}
|
| 6789 | 0:513646585e45 | Chris | end |
| 6790 | 909:cbb26bc654de | Chris | |
| 6791 | 0:513646585e45 | Chris | # Returns the users that should be notified on project events |
| 6792 | def notified_users |
||
| 6793 | 37:94944d00e43c | chris | # TODO: User part should be extracted to User#notify_about? |
| 6794 | 1115:433d4f72a19b | Chris | members.select {|m| m.principal.present? && (m.mail_notification? || m.principal.mail_notification == 'all')}.collect {|m| m.principal}
|
| 6795 | 0:513646585e45 | Chris | end |
| 6796 | 909:cbb26bc654de | Chris | |
| 6797 | 1464:261b3d9a4903 | Chris | # Returns a scope of all custom fields enabled for project issues |
| 6798 | 0:513646585e45 | Chris | # (explictly associated custom fields and custom fields enabled for all projects) |
| 6799 | def all_issue_custom_fields |
||
| 6800 | 1464:261b3d9a4903 | Chris | @all_issue_custom_fields ||= IssueCustomField. |
| 6801 | sorted. |
||
| 6802 | where("is_for_all = ? OR id IN (SELECT DISTINCT cfp.custom_field_id" +
|
||
| 6803 | " FROM #{table_name_prefix}custom_fields_projects#{table_name_suffix} cfp" +
|
||
| 6804 | " WHERE cfp.project_id = ?)", true, id) |
||
| 6805 | 0:513646585e45 | Chris | end |
| 6806 | 441:cbce1fd3b1b7 | Chris | |
| 6807 | # Returns an array of all custom fields enabled for project time entries |
||
| 6808 | # (explictly associated custom fields and custom fields enabled for all projects) |
||
| 6809 | def all_time_entry_custom_fields |
||
| 6810 | @all_time_entry_custom_fields ||= (TimeEntryCustomField.for_all + time_entry_custom_fields).uniq.sort |
||
| 6811 | end |
||
| 6812 | 909:cbb26bc654de | Chris | |
| 6813 | 0:513646585e45 | Chris | def project |
| 6814 | self |
||
| 6815 | end |
||
| 6816 | 909:cbb26bc654de | Chris | |
| 6817 | 0:513646585e45 | Chris | def <=>(project) |
| 6818 | name.downcase <=> project.name.downcase |
||
| 6819 | end |
||
| 6820 | 909:cbb26bc654de | Chris | |
| 6821 | 0:513646585e45 | Chris | def to_s |
| 6822 | name |
||
| 6823 | end |
||
| 6824 | 909:cbb26bc654de | Chris | |
| 6825 | 0:513646585e45 | Chris | # Returns a short description of the projects (first lines) |
| 6826 | 1215:2101a7c906b3 | chris | def short_description(length = 200) |
| 6827 | 335:7acd282bee3c | chris | |
| 6828 | ## The short description is used in lists, e.g. Latest projects, |
||
| 6829 | ## My projects etc. It should be no more than a line or two with |
||
| 6830 | ## no text formatting. |
||
| 6831 | |||
| 6832 | 130:db0caa9f0ff4 | chris | ## Original Redmine code: this truncates to the CR that is more |
| 6833 | ## than "length" characters from the start. |
||
| 6834 | # description.gsub(/^(.{#{length}}[^\n\r]*).*$/m, '\1...').strip if description
|
||
| 6835 | 335:7acd282bee3c | chris | |
| 6836 | ## That can leave too much text for us, and also we want to omit |
||
| 6837 | ## images and the like. Truncate instead to the first CR that |
||
| 6838 | ## follows _any_ non-blank text, and to the next word break beyond |
||
| 6839 | ## "length" characters if the result is still longer than that. |
||
| 6840 | ## |
||
| 6841 | 1215:2101a7c906b3 | chris | description.gsub(/![^\s]+!/, '').gsub(/^(\s*[^\n\r]*).*$/m, '\1').gsub(/^(.{#{length}}[^\.;:,-]*).*$/m, '\1 ...').strip if description
|
| 6842 | 0:513646585e45 | Chris | end |
| 6843 | 22:40f7cfd4df19 | chris | |
| 6844 | def css_classes |
||
| 6845 | s = 'project' |
||
| 6846 | s << ' root' if root? |
||
| 6847 | s << ' child' if child? |
||
| 6848 | s << (leaf? ? ' leaf' : ' parent') |
||
| 6849 | 1115:433d4f72a19b | Chris | unless active? |
| 6850 | if archived? |
||
| 6851 | s << ' archived' |
||
| 6852 | else |
||
| 6853 | s << ' closed' |
||
| 6854 | end |
||
| 6855 | end |
||
| 6856 | 22:40f7cfd4df19 | chris | s |
| 6857 | end |
||
| 6858 | |||
| 6859 | # The earliest start date of a project, based on it's issues and versions |
||
| 6860 | def start_date |
||
| 6861 | 1464:261b3d9a4903 | Chris | @start_date ||= [ |
| 6862 | 117:af80e5618e9b | Chris | issues.minimum('start_date'),
|
| 6863 | 1464:261b3d9a4903 | Chris | shared_versions.minimum('effective_date'),
|
| 6864 | Issue.fixed_version(shared_versions).minimum('start_date')
|
||
| 6865 | ].compact.min |
||
| 6866 | 22:40f7cfd4df19 | chris | end |
| 6867 | |||
| 6868 | # The latest due date of an issue or version |
||
| 6869 | def due_date |
||
| 6870 | 1464:261b3d9a4903 | Chris | @due_date ||= [ |
| 6871 | 117:af80e5618e9b | Chris | issues.maximum('due_date'),
|
| 6872 | 1464:261b3d9a4903 | Chris | shared_versions.maximum('effective_date'),
|
| 6873 | Issue.fixed_version(shared_versions).maximum('due_date')
|
||
| 6874 | ].compact.max |
||
| 6875 | 22:40f7cfd4df19 | chris | end |
| 6876 | |||
| 6877 | def overdue? |
||
| 6878 | active? && !due_date.nil? && (due_date < Date.today) |
||
| 6879 | end |
||
| 6880 | |||
| 6881 | # Returns the percent completed for this project, based on the |
||
| 6882 | # progress on it's versions. |
||
| 6883 | def completed_percent(options={:include_subprojects => false})
|
||
| 6884 | if options.delete(:include_subprojects) |
||
| 6885 | total = self_and_descendants.collect(&:completed_percent).sum |
||
| 6886 | |||
| 6887 | total / self_and_descendants.count |
||
| 6888 | else |
||
| 6889 | if versions.count > 0 |
||
| 6890 | 1464:261b3d9a4903 | Chris | total = versions.collect(&:completed_percent).sum |
| 6891 | 22:40f7cfd4df19 | chris | |
| 6892 | total / versions.count |
||
| 6893 | else |
||
| 6894 | 100 |
||
| 6895 | end |
||
| 6896 | end |
||
| 6897 | end |
||
| 6898 | 909:cbb26bc654de | Chris | |
| 6899 | 1115:433d4f72a19b | Chris | # Return true if this project allows to do the specified action. |
| 6900 | 0:513646585e45 | Chris | # action can be: |
| 6901 | # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit') |
||
| 6902 | # * a permission Symbol (eg. :edit_project) |
||
| 6903 | def allows_to?(action) |
||
| 6904 | 1115:433d4f72a19b | Chris | if archived? |
| 6905 | # No action allowed on archived projects |
||
| 6906 | return false |
||
| 6907 | end |
||
| 6908 | unless active? || Redmine::AccessControl.read_action?(action) |
||
| 6909 | # No write action allowed on closed projects |
||
| 6910 | return false |
||
| 6911 | end |
||
| 6912 | # No action allowed on disabled modules |
||
| 6913 | 0:513646585e45 | Chris | if action.is_a? Hash |
| 6914 | allowed_actions.include? "#{action[:controller]}/#{action[:action]}"
|
||
| 6915 | else |
||
| 6916 | allowed_permissions.include? action |
||
| 6917 | end |
||
| 6918 | end |
||
| 6919 | 909:cbb26bc654de | Chris | |
| 6920 | 1517:dffacf8a6908 | Chris | # Return the enabled module with the given name |
| 6921 | # or nil if the module is not enabled for the project |
||
| 6922 | def enabled_module(name) |
||
| 6923 | name = name.to_s |
||
| 6924 | enabled_modules.detect {|m| m.name == name}
|
||
| 6925 | end |
||
| 6926 | |||
| 6927 | # Return true if the module with the given name is enabled |
||
| 6928 | def module_enabled?(name) |
||
| 6929 | enabled_module(name).present? |
||
| 6930 | 0:513646585e45 | Chris | end |
| 6931 | 909:cbb26bc654de | Chris | |
| 6932 | 0:513646585e45 | Chris | def enabled_module_names=(module_names) |
| 6933 | if module_names && module_names.is_a?(Array) |
||
| 6934 | 117:af80e5618e9b | Chris | module_names = module_names.collect(&:to_s).reject(&:blank?) |
| 6935 | 441:cbce1fd3b1b7 | Chris | self.enabled_modules = module_names.collect {|name| enabled_modules.detect {|mod| mod.name == name} || EnabledModule.new(:name => name)}
|
| 6936 | 0:513646585e45 | Chris | else |
| 6937 | enabled_modules.clear |
||
| 6938 | end |
||
| 6939 | end |
||
| 6940 | 909:cbb26bc654de | Chris | |
| 6941 | 117:af80e5618e9b | Chris | # Returns an array of the enabled modules names |
| 6942 | def enabled_module_names |
||
| 6943 | enabled_modules.collect(&:name) |
||
| 6944 | end |
||
| 6945 | 507:0c939c159af4 | Chris | |
| 6946 | # Enable a specific module |
||
| 6947 | # |
||
| 6948 | # Examples: |
||
| 6949 | # project.enable_module!(:issue_tracking) |
||
| 6950 | # project.enable_module!("issue_tracking")
|
||
| 6951 | def enable_module!(name) |
||
| 6952 | enabled_modules << EnabledModule.new(:name => name.to_s) unless module_enabled?(name) |
||
| 6953 | end |
||
| 6954 | |||
| 6955 | # Disable a module if it exists |
||
| 6956 | # |
||
| 6957 | # Examples: |
||
| 6958 | # project.disable_module!(:issue_tracking) |
||
| 6959 | # project.disable_module!("issue_tracking")
|
||
| 6960 | # project.disable_module!(project.enabled_modules.first) |
||
| 6961 | def disable_module!(target) |
||
| 6962 | target = enabled_modules.detect{|mod| target.to_s == mod.name} unless enabled_modules.include?(target)
|
||
| 6963 | target.destroy unless target.blank? |
||
| 6964 | end |
||
| 6965 | |||
| 6966 | 117:af80e5618e9b | Chris | safe_attributes 'name', |
| 6967 | 'description', |
||
| 6968 | 'homepage', |
||
| 6969 | 'is_public', |
||
| 6970 | 'identifier', |
||
| 6971 | 'custom_field_values', |
||
| 6972 | 'custom_fields', |
||
| 6973 | 'tracker_ids', |
||
| 6974 | 680:65abc6b39292 | chris | 'issue_custom_field_ids', |
| 6975 | 'has_welcome_page' |
||
| 6976 | 22:40f7cfd4df19 | chris | |
| 6977 | 117:af80e5618e9b | Chris | safe_attributes 'enabled_module_names', |
| 6978 | :if => lambda {|project, user| project.new_record? || user.allowed_to?(:select_project_modules, project) }
|
||
| 6979 | 909:cbb26bc654de | Chris | |
| 6980 | 1464:261b3d9a4903 | Chris | safe_attributes 'inherit_members', |
| 6981 | :if => lambda {|project, user| project.parent.nil? || project.parent.visible?(user)}
|
||
| 6982 | |||
| 6983 | 22:40f7cfd4df19 | chris | # Returns an array of projects that are in this project's hierarchy |
| 6984 | # |
||
| 6985 | # Example: parents, children, siblings |
||
| 6986 | def hierarchy |
||
| 6987 | parents = project.self_and_ancestors || [] |
||
| 6988 | descendants = project.descendants || [] |
||
| 6989 | project_hierarchy = parents | descendants # Set union |
||
| 6990 | end |
||
| 6991 | 909:cbb26bc654de | Chris | |
| 6992 | 0:513646585e45 | Chris | # Returns an auto-generated project identifier based on the last identifier used |
| 6993 | def self.next_identifier |
||
| 6994 | 1464:261b3d9a4903 | Chris | p = Project.order('id DESC').first
|
| 6995 | 0:513646585e45 | Chris | p.nil? ? nil : p.identifier.to_s.succ |
| 6996 | end |
||
| 6997 | |||
| 6998 | # Copies and saves the Project instance based on the +project+. |
||
| 6999 | # Duplicates the source project's: |
||
| 7000 | # * Wiki |
||
| 7001 | # * Versions |
||
| 7002 | # * Categories |
||
| 7003 | # * Issues |
||
| 7004 | # * Members |
||
| 7005 | # * Queries |
||
| 7006 | # |
||
| 7007 | # Accepts an +options+ argument to specify what to copy |
||
| 7008 | # |
||
| 7009 | # Examples: |
||
| 7010 | # project.copy(1) # => copies everything |
||
| 7011 | # project.copy(1, :only => 'members') # => copies members only |
||
| 7012 | # project.copy(1, :only => ['members', 'versions']) # => copies members and versions |
||
| 7013 | def copy(project, options={})
|
||
| 7014 | project = project.is_a?(Project) ? project : Project.find(project) |
||
| 7015 | 909:cbb26bc654de | Chris | |
| 7016 | 0:513646585e45 | Chris | to_be_copied = %w(wiki versions issue_categories issues members queries boards) |
| 7017 | to_be_copied = to_be_copied & options[:only].to_a unless options[:only].nil? |
||
| 7018 | 909:cbb26bc654de | Chris | |
| 7019 | 0:513646585e45 | Chris | Project.transaction do |
| 7020 | if save |
||
| 7021 | reload |
||
| 7022 | to_be_copied.each do |name| |
||
| 7023 | send "copy_#{name}", project
|
||
| 7024 | end |
||
| 7025 | Redmine::Hook.call_hook(:model_project_copy_before_save, :source_project => project, :destination_project => self) |
||
| 7026 | save |
||
| 7027 | end |
||
| 7028 | end |
||
| 7029 | end |
||
| 7030 | |||
| 7031 | 1464:261b3d9a4903 | Chris | # Returns a new unsaved Project instance with attributes copied from +project+ |
| 7032 | 0:513646585e45 | Chris | def self.copy_from(project) |
| 7033 | 1464:261b3d9a4903 | Chris | project = project.is_a?(Project) ? project : Project.find(project) |
| 7034 | # clear unique attributes |
||
| 7035 | attributes = project.attributes.dup.except('id', 'name', 'identifier', 'status', 'parent_id', 'lft', 'rgt')
|
||
| 7036 | copy = Project.new(attributes) |
||
| 7037 | copy.enabled_modules = project.enabled_modules |
||
| 7038 | copy.trackers = project.trackers |
||
| 7039 | copy.custom_values = project.custom_values.collect {|v| v.clone}
|
||
| 7040 | copy.issue_custom_fields = project.issue_custom_fields |
||
| 7041 | copy |
||
| 7042 | 0:513646585e45 | Chris | end |
| 7043 | 37:94944d00e43c | chris | |
| 7044 | # Yields the given block for each project with its level in the tree |
||
| 7045 | def self.project_tree(projects, &block) |
||
| 7046 | ancestors = [] |
||
| 7047 | projects.sort_by(&:lft).each do |project| |
||
| 7048 | 909:cbb26bc654de | Chris | while (ancestors.any? && !project.is_descendant_of?(ancestors.last)) |
| 7049 | 37:94944d00e43c | chris | ancestors.pop |
| 7050 | end |
||
| 7051 | yield project, ancestors.size |
||
| 7052 | ancestors << project |
||
| 7053 | end |
||
| 7054 | end |
||
| 7055 | 909:cbb26bc654de | Chris | |
| 7056 | 0:513646585e45 | Chris | private |
| 7057 | 909:cbb26bc654de | Chris | |
| 7058 | 1464:261b3d9a4903 | Chris | def after_parent_changed(parent_was) |
| 7059 | remove_inherited_member_roles |
||
| 7060 | add_inherited_member_roles |
||
| 7061 | end |
||
| 7062 | |||
| 7063 | def update_inherited_members |
||
| 7064 | if parent |
||
| 7065 | if inherit_members? && !inherit_members_was |
||
| 7066 | remove_inherited_member_roles |
||
| 7067 | add_inherited_member_roles |
||
| 7068 | elsif !inherit_members? && inherit_members_was |
||
| 7069 | remove_inherited_member_roles |
||
| 7070 | end |
||
| 7071 | end |
||
| 7072 | end |
||
| 7073 | |||
| 7074 | def remove_inherited_member_roles |
||
| 7075 | member_roles = memberships.map(&:member_roles).flatten |
||
| 7076 | member_role_ids = member_roles.map(&:id) |
||
| 7077 | member_roles.each do |member_role| |
||
| 7078 | if member_role.inherited_from && !member_role_ids.include?(member_role.inherited_from) |
||
| 7079 | member_role.destroy |
||
| 7080 | end |
||
| 7081 | end |
||
| 7082 | end |
||
| 7083 | |||
| 7084 | def add_inherited_member_roles |
||
| 7085 | if inherit_members? && parent |
||
| 7086 | parent.memberships.each do |parent_member| |
||
| 7087 | member = Member.find_or_new(self.id, parent_member.user_id) |
||
| 7088 | parent_member.member_roles.each do |parent_member_role| |
||
| 7089 | member.member_roles << MemberRole.new(:role => parent_member_role.role, :inherited_from => parent_member_role.id) |
||
| 7090 | end |
||
| 7091 | member.save! |
||
| 7092 | end |
||
| 7093 | end |
||
| 7094 | end |
||
| 7095 | |||
| 7096 | 0:513646585e45 | Chris | # Copies wiki from +project+ |
| 7097 | def copy_wiki(project) |
||
| 7098 | # Check that the source project has a wiki first |
||
| 7099 | unless project.wiki.nil? |
||
| 7100 | 1294:3e4c3460b6ca | Chris | wiki = self.wiki || Wiki.new |
| 7101 | 0:513646585e45 | Chris | wiki.attributes = project.wiki.attributes.dup.except("id", "project_id")
|
| 7102 | wiki_pages_map = {}
|
||
| 7103 | project.wiki.pages.each do |page| |
||
| 7104 | # Skip pages without content |
||
| 7105 | next if page.content.nil? |
||
| 7106 | new_wiki_content = WikiContent.new(page.content.attributes.dup.except("id", "page_id", "updated_on"))
|
||
| 7107 | new_wiki_page = WikiPage.new(page.attributes.dup.except("id", "wiki_id", "created_on", "parent_id"))
|
||
| 7108 | new_wiki_page.content = new_wiki_content |
||
| 7109 | wiki.pages << new_wiki_page |
||
| 7110 | wiki_pages_map[page.id] = new_wiki_page |
||
| 7111 | end |
||
| 7112 | 1294:3e4c3460b6ca | Chris | |
| 7113 | self.wiki = wiki |
||
| 7114 | 0:513646585e45 | Chris | wiki.save |
| 7115 | # Reproduce page hierarchy |
||
| 7116 | project.wiki.pages.each do |page| |
||
| 7117 | if page.parent_id && wiki_pages_map[page.id] |
||
| 7118 | wiki_pages_map[page.id].parent = wiki_pages_map[page.parent_id] |
||
| 7119 | wiki_pages_map[page.id].save |
||
| 7120 | end |
||
| 7121 | end |
||
| 7122 | end |
||
| 7123 | end |
||
| 7124 | |||
| 7125 | # Copies versions from +project+ |
||
| 7126 | def copy_versions(project) |
||
| 7127 | project.versions.each do |version| |
||
| 7128 | new_version = Version.new |
||
| 7129 | new_version.attributes = version.attributes.dup.except("id", "project_id", "created_on", "updated_on")
|
||
| 7130 | self.versions << new_version |
||
| 7131 | end |
||
| 7132 | end |
||
| 7133 | |||
| 7134 | # Copies issue categories from +project+ |
||
| 7135 | def copy_issue_categories(project) |
||
| 7136 | project.issue_categories.each do |issue_category| |
||
| 7137 | new_issue_category = IssueCategory.new |
||
| 7138 | new_issue_category.attributes = issue_category.attributes.dup.except("id", "project_id")
|
||
| 7139 | self.issue_categories << new_issue_category |
||
| 7140 | end |
||
| 7141 | end |
||
| 7142 | 909:cbb26bc654de | Chris | |
| 7143 | 0:513646585e45 | Chris | # Copies issues from +project+ |
| 7144 | def copy_issues(project) |
||
| 7145 | # Stores the source issue id as a key and the copied issues as the |
||
| 7146 | # value. Used to map the two togeather for issue relations. |
||
| 7147 | issues_map = {}
|
||
| 7148 | 909:cbb26bc654de | Chris | |
| 7149 | 1115:433d4f72a19b | Chris | # Store status and reopen locked/closed versions |
| 7150 | version_statuses = versions.reject(&:open?).map {|version| [version, version.status]}
|
||
| 7151 | version_statuses.each do |version, status| |
||
| 7152 | version.update_attribute :status, 'open' |
||
| 7153 | end |
||
| 7154 | |||
| 7155 | 0:513646585e45 | Chris | # Get issues sorted by root_id, lft so that parent issues |
| 7156 | # get copied before their children |
||
| 7157 | 1517:dffacf8a6908 | Chris | project.issues.reorder('root_id, lft').each do |issue|
|
| 7158 | 0:513646585e45 | Chris | new_issue = Issue.new |
| 7159 | 1115:433d4f72a19b | Chris | new_issue.copy_from(issue, :subtasks => false, :link => false) |
| 7160 | 0:513646585e45 | Chris | new_issue.project = self |
| 7161 | 1464:261b3d9a4903 | Chris | # Changing project resets the custom field values |
| 7162 | # TODO: handle this in Issue#project= |
||
| 7163 | new_issue.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
|
||
| 7164 | 1115:433d4f72a19b | Chris | # Reassign fixed_versions by name, since names are unique per project |
| 7165 | if issue.fixed_version && issue.fixed_version.project == project |
||
| 7166 | new_issue.fixed_version = self.versions.detect {|v| v.name == issue.fixed_version.name}
|
||
| 7167 | 0:513646585e45 | Chris | end |
| 7168 | 1115:433d4f72a19b | Chris | # Reassign the category by name, since names are unique per project |
| 7169 | 0:513646585e45 | Chris | if issue.category |
| 7170 | 1115:433d4f72a19b | Chris | new_issue.category = self.issue_categories.detect {|c| c.name == issue.category.name}
|
| 7171 | 0:513646585e45 | Chris | end |
| 7172 | # Parent issue |
||
| 7173 | if issue.parent_id |
||
| 7174 | if copied_parent = issues_map[issue.parent_id] |
||
| 7175 | new_issue.parent_issue_id = copied_parent.id |
||
| 7176 | end |
||
| 7177 | end |
||
| 7178 | 909:cbb26bc654de | Chris | |
| 7179 | 0:513646585e45 | Chris | self.issues << new_issue |
| 7180 | 117:af80e5618e9b | Chris | if new_issue.new_record? |
| 7181 | logger.info "Project#copy_issues: issue ##{issue.id} could not be copied: #{new_issue.errors.full_messages}" if logger && logger.info
|
||
| 7182 | else |
||
| 7183 | issues_map[issue.id] = new_issue unless new_issue.new_record? |
||
| 7184 | end |
||
| 7185 | 0:513646585e45 | Chris | end |
| 7186 | |||
| 7187 | 1115:433d4f72a19b | Chris | # Restore locked/closed version statuses |
| 7188 | version_statuses.each do |version, status| |
||
| 7189 | version.update_attribute :status, status |
||
| 7190 | end |
||
| 7191 | |||
| 7192 | 0:513646585e45 | Chris | # Relations after in case issues related each other |
| 7193 | project.issues.each do |issue| |
||
| 7194 | new_issue = issues_map[issue.id] |
||
| 7195 | 117:af80e5618e9b | Chris | unless new_issue |
| 7196 | # Issue was not copied |
||
| 7197 | next |
||
| 7198 | end |
||
| 7199 | 909:cbb26bc654de | Chris | |
| 7200 | 0:513646585e45 | Chris | # Relations |
| 7201 | issue.relations_from.each do |source_relation| |
||
| 7202 | new_issue_relation = IssueRelation.new |
||
| 7203 | new_issue_relation.attributes = source_relation.attributes.dup.except("id", "issue_from_id", "issue_to_id")
|
||
| 7204 | new_issue_relation.issue_to = issues_map[source_relation.issue_to_id] |
||
| 7205 | if new_issue_relation.issue_to.nil? && Setting.cross_project_issue_relations? |
||
| 7206 | new_issue_relation.issue_to = source_relation.issue_to |
||
| 7207 | end |
||
| 7208 | new_issue.relations_from << new_issue_relation |
||
| 7209 | end |
||
| 7210 | 909:cbb26bc654de | Chris | |
| 7211 | 0:513646585e45 | Chris | issue.relations_to.each do |source_relation| |
| 7212 | new_issue_relation = IssueRelation.new |
||
| 7213 | new_issue_relation.attributes = source_relation.attributes.dup.except("id", "issue_from_id", "issue_to_id")
|
||
| 7214 | new_issue_relation.issue_from = issues_map[source_relation.issue_from_id] |
||
| 7215 | if new_issue_relation.issue_from.nil? && Setting.cross_project_issue_relations? |
||
| 7216 | new_issue_relation.issue_from = source_relation.issue_from |
||
| 7217 | end |
||
| 7218 | new_issue.relations_to << new_issue_relation |
||
| 7219 | end |
||
| 7220 | end |
||
| 7221 | end |
||
| 7222 | |||
| 7223 | # Copies members from +project+ |
||
| 7224 | def copy_members(project) |
||
| 7225 | 117:af80e5618e9b | Chris | # Copy users first, then groups to handle members with inherited and given roles |
| 7226 | members_to_copy = [] |
||
| 7227 | members_to_copy += project.memberships.select {|m| m.principal.is_a?(User)}
|
||
| 7228 | members_to_copy += project.memberships.select {|m| !m.principal.is_a?(User)}
|
||
| 7229 | 909:cbb26bc654de | Chris | |
| 7230 | 117:af80e5618e9b | Chris | members_to_copy.each do |member| |
| 7231 | 0:513646585e45 | Chris | new_member = Member.new |
| 7232 | new_member.attributes = member.attributes.dup.except("id", "project_id", "created_on")
|
||
| 7233 | # only copy non inherited roles |
||
| 7234 | # inherited roles will be added when copying the group membership |
||
| 7235 | role_ids = member.member_roles.reject(&:inherited?).collect(&:role_id) |
||
| 7236 | next if role_ids.empty? |
||
| 7237 | new_member.role_ids = role_ids |
||
| 7238 | new_member.project = self |
||
| 7239 | self.members << new_member |
||
| 7240 | end |
||
| 7241 | end |
||
| 7242 | |||
| 7243 | # Copies queries from +project+ |
||
| 7244 | def copy_queries(project) |
||
| 7245 | project.queries.each do |query| |
||
| 7246 | 1464:261b3d9a4903 | Chris | new_query = IssueQuery.new |
| 7247 | 0:513646585e45 | Chris | new_query.attributes = query.attributes.dup.except("id", "project_id", "sort_criteria")
|
| 7248 | new_query.sort_criteria = query.sort_criteria if query.sort_criteria |
||
| 7249 | new_query.project = self |
||
| 7250 | 909:cbb26bc654de | Chris | new_query.user_id = query.user_id |
| 7251 | 0:513646585e45 | Chris | self.queries << new_query |
| 7252 | end |
||
| 7253 | end |
||
| 7254 | |||
| 7255 | # Copies boards from +project+ |
||
| 7256 | def copy_boards(project) |
||
| 7257 | project.boards.each do |board| |
||
| 7258 | new_board = Board.new |
||
| 7259 | new_board.attributes = board.attributes.dup.except("id", "project_id", "topics_count", "messages_count", "last_message_id")
|
||
| 7260 | new_board.project = self |
||
| 7261 | self.boards << new_board |
||
| 7262 | end |
||
| 7263 | end |
||
| 7264 | 909:cbb26bc654de | Chris | |
| 7265 | 0:513646585e45 | Chris | def allowed_permissions |
| 7266 | @allowed_permissions ||= begin |
||
| 7267 | 1464:261b3d9a4903 | Chris | module_names = enabled_modules.loaded? ? enabled_modules.map(&:name) : enabled_modules.pluck(:name) |
| 7268 | 0:513646585e45 | Chris | Redmine::AccessControl.modules_permissions(module_names).collect {|p| p.name}
|
| 7269 | end |
||
| 7270 | end |
||
| 7271 | |||
| 7272 | def allowed_actions |
||
| 7273 | @actions_allowed ||= allowed_permissions.inject([]) { |actions, permission| actions += Redmine::AccessControl.allowed_actions(permission) }.flatten
|
||
| 7274 | end |
||
| 7275 | |||
| 7276 | # Returns all the active Systemwide and project specific activities |
||
| 7277 | def active_activities |
||
| 7278 | overridden_activity_ids = self.time_entry_activities.collect(&:parent_id) |
||
| 7279 | 909:cbb26bc654de | Chris | |
| 7280 | 0:513646585e45 | Chris | if overridden_activity_ids.empty? |
| 7281 | return TimeEntryActivity.shared.active |
||
| 7282 | else |
||
| 7283 | return system_activities_and_project_overrides |
||
| 7284 | end |
||
| 7285 | end |
||
| 7286 | |||
| 7287 | # Returns all the Systemwide and project specific activities |
||
| 7288 | # (inactive and active) |
||
| 7289 | def all_activities |
||
| 7290 | overridden_activity_ids = self.time_entry_activities.collect(&:parent_id) |
||
| 7291 | |||
| 7292 | if overridden_activity_ids.empty? |
||
| 7293 | return TimeEntryActivity.shared |
||
| 7294 | else |
||
| 7295 | return system_activities_and_project_overrides(true) |
||
| 7296 | end |
||
| 7297 | end |
||
| 7298 | |||
| 7299 | # Returns the systemwide active activities merged with the project specific overrides |
||
| 7300 | def system_activities_and_project_overrides(include_inactive=false) |
||
| 7301 | 1517:dffacf8a6908 | Chris | t = TimeEntryActivity.table_name |
| 7302 | scope = TimeEntryActivity.where( |
||
| 7303 | "(#{t}.project_id IS NULL AND #{t}.id NOT IN (?)) OR (#{t}.project_id = ?)",
|
||
| 7304 | time_entry_activities.map(&:parent_id), id |
||
| 7305 | ) |
||
| 7306 | unless include_inactive |
||
| 7307 | scope = scope.active |
||
| 7308 | 0:513646585e45 | Chris | end |
| 7309 | 1517:dffacf8a6908 | Chris | scope |
| 7310 | 0:513646585e45 | Chris | end |
| 7311 | 909:cbb26bc654de | Chris | |
| 7312 | 0:513646585e45 | Chris | # Archives subprojects recursively |
| 7313 | def archive! |
||
| 7314 | children.each do |subproject| |
||
| 7315 | subproject.send :archive! |
||
| 7316 | end |
||
| 7317 | update_attribute :status, STATUS_ARCHIVED |
||
| 7318 | end |
||
| 7319 | 1115:433d4f72a19b | Chris | |
| 7320 | def update_position_under_parent |
||
| 7321 | set_or_update_position_under(parent) |
||
| 7322 | end |
||
| 7323 | |||
| 7324 | 1517:dffacf8a6908 | Chris | public |
| 7325 | |||
| 7326 | 1115:433d4f72a19b | Chris | # Inserts/moves the project so that target's children or root projects stay alphabetically sorted |
| 7327 | def set_or_update_position_under(target_parent) |
||
| 7328 | 1464:261b3d9a4903 | Chris | parent_was = parent |
| 7329 | 1115:433d4f72a19b | Chris | sibs = (target_parent.nil? ? self.class.roots : target_parent.children) |
| 7330 | to_be_inserted_before = sibs.sort_by {|c| c.name.to_s.downcase}.detect {|c| c.name.to_s.downcase > name.to_s.downcase }
|
||
| 7331 | |||
| 7332 | if to_be_inserted_before |
||
| 7333 | move_to_left_of(to_be_inserted_before) |
||
| 7334 | elsif target_parent.nil? |
||
| 7335 | if sibs.empty? |
||
| 7336 | # move_to_root adds the project in first (ie. left) position |
||
| 7337 | move_to_root |
||
| 7338 | else |
||
| 7339 | move_to_right_of(sibs.last) unless self == sibs.last |
||
| 7340 | end |
||
| 7341 | else |
||
| 7342 | # move_to_child_of adds the project in last (ie.right) position |
||
| 7343 | move_to_child_of(target_parent) |
||
| 7344 | end |
||
| 7345 | 1464:261b3d9a4903 | Chris | if parent_was != target_parent |
| 7346 | after_parent_changed(parent_was) |
||
| 7347 | end |
||
| 7348 | 1115:433d4f72a19b | Chris | end |
| 7349 | 0:513646585e45 | Chris | end |
| 7350 | 909:cbb26bc654de | Chris | # Redmine - project management software |
| 7351 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 7352 | 0:513646585e45 | Chris | # |
| 7353 | # This program is free software; you can redistribute it and/or |
||
| 7354 | # modify it under the terms of the GNU General Public License |
||
| 7355 | # as published by the Free Software Foundation; either version 2 |
||
| 7356 | # of the License, or (at your option) any later version. |
||
| 7357 | 909:cbb26bc654de | Chris | # |
| 7358 | 0:513646585e45 | Chris | # This program is distributed in the hope that it will be useful, |
| 7359 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 7360 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 7361 | # GNU General Public License for more details. |
||
| 7362 | 909:cbb26bc654de | Chris | # |
| 7363 | 0:513646585e45 | Chris | # You should have received a copy of the GNU General Public License |
| 7364 | # along with this program; if not, write to the Free Software |
||
| 7365 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 7366 | |||
| 7367 | class ProjectCustomField < CustomField |
||
| 7368 | def type_name |
||
| 7369 | :label_project_plural |
||
| 7370 | end |
||
| 7371 | end |
||
| 7372 | # Redmine - project management software |
||
| 7373 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 7374 | 0:513646585e45 | Chris | # |
| 7375 | # This program is free software; you can redistribute it and/or |
||
| 7376 | # modify it under the terms of the GNU General Public License |
||
| 7377 | # as published by the Free Software Foundation; either version 2 |
||
| 7378 | # of the License, or (at your option) any later version. |
||
| 7379 | 441:cbce1fd3b1b7 | Chris | # |
| 7380 | 0:513646585e45 | Chris | # This program is distributed in the hope that it will be useful, |
| 7381 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 7382 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 7383 | # GNU General Public License for more details. |
||
| 7384 | 441:cbce1fd3b1b7 | Chris | # |
| 7385 | 0:513646585e45 | Chris | # You should have received a copy of the GNU General Public License |
| 7386 | # along with this program; if not, write to the Free Software |
||
| 7387 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 7388 | |||
| 7389 | 441:cbce1fd3b1b7 | Chris | class QueryColumn |
| 7390 | 0:513646585e45 | Chris | attr_accessor :name, :sortable, :groupable, :default_order |
| 7391 | include Redmine::I18n |
||
| 7392 | 441:cbce1fd3b1b7 | Chris | |
| 7393 | 0:513646585e45 | Chris | def initialize(name, options={})
|
| 7394 | self.name = name |
||
| 7395 | self.sortable = options[:sortable] |
||
| 7396 | self.groupable = options[:groupable] || false |
||
| 7397 | if groupable == true |
||
| 7398 | self.groupable = name.to_s |
||
| 7399 | end |
||
| 7400 | self.default_order = options[:default_order] |
||
| 7401 | 1115:433d4f72a19b | Chris | @inline = options.key?(:inline) ? options[:inline] : true |
| 7402 | 1464:261b3d9a4903 | Chris | @caption_key = options[:caption] || "field_#{name}".to_sym
|
| 7403 | @frozen = options[:frozen] |
||
| 7404 | 0:513646585e45 | Chris | end |
| 7405 | 441:cbce1fd3b1b7 | Chris | |
| 7406 | 0:513646585e45 | Chris | def caption |
| 7407 | 1464:261b3d9a4903 | Chris | @caption_key.is_a?(Symbol) ? l(@caption_key) : @caption_key |
| 7408 | 0:513646585e45 | Chris | end |
| 7409 | 441:cbce1fd3b1b7 | Chris | |
| 7410 | 0:513646585e45 | Chris | # Returns true if the column is sortable, otherwise false |
| 7411 | def sortable? |
||
| 7412 | 909:cbb26bc654de | Chris | !@sortable.nil? |
| 7413 | end |
||
| 7414 | 1115:433d4f72a19b | Chris | |
| 7415 | 909:cbb26bc654de | Chris | def sortable |
| 7416 | @sortable.is_a?(Proc) ? @sortable.call : @sortable |
||
| 7417 | 0:513646585e45 | Chris | end |
| 7418 | 441:cbce1fd3b1b7 | Chris | |
| 7419 | 1115:433d4f72a19b | Chris | def inline? |
| 7420 | @inline |
||
| 7421 | end |
||
| 7422 | |||
| 7423 | 1464:261b3d9a4903 | Chris | def frozen? |
| 7424 | @frozen |
||
| 7425 | end |
||
| 7426 | |||
| 7427 | def value(object) |
||
| 7428 | object.send name |
||
| 7429 | 0:513646585e45 | Chris | end |
| 7430 | 441:cbce1fd3b1b7 | Chris | |
| 7431 | def css_classes |
||
| 7432 | name |
||
| 7433 | end |
||
| 7434 | 0:513646585e45 | Chris | end |
| 7435 | |||
| 7436 | class QueryCustomFieldColumn < QueryColumn |
||
| 7437 | |||
| 7438 | def initialize(custom_field) |
||
| 7439 | self.name = "cf_#{custom_field.id}".to_sym
|
||
| 7440 | self.sortable = custom_field.order_statement || false |
||
| 7441 | 1115:433d4f72a19b | Chris | self.groupable = custom_field.group_statement || false |
| 7442 | @inline = true |
||
| 7443 | 0:513646585e45 | Chris | @cf = custom_field |
| 7444 | end |
||
| 7445 | 441:cbce1fd3b1b7 | Chris | |
| 7446 | 0:513646585e45 | Chris | def caption |
| 7447 | @cf.name |
||
| 7448 | end |
||
| 7449 | 441:cbce1fd3b1b7 | Chris | |
| 7450 | 0:513646585e45 | Chris | def custom_field |
| 7451 | @cf |
||
| 7452 | end |
||
| 7453 | 441:cbce1fd3b1b7 | Chris | |
| 7454 | 1464:261b3d9a4903 | Chris | def value(object) |
| 7455 | if custom_field.visible_by?(object.project, User.current) |
||
| 7456 | cv = object.custom_values.select {|v| v.custom_field_id == @cf.id}.collect {|v| @cf.cast_value(v.value)}
|
||
| 7457 | cv.size > 1 ? cv.sort {|a,b| a.to_s <=> b.to_s} : cv.first
|
||
| 7458 | else |
||
| 7459 | nil |
||
| 7460 | end |
||
| 7461 | 0:513646585e45 | Chris | end |
| 7462 | 441:cbce1fd3b1b7 | Chris | |
| 7463 | def css_classes |
||
| 7464 | @css_classes ||= "#{name} #{@cf.field_format}"
|
||
| 7465 | end |
||
| 7466 | 0:513646585e45 | Chris | end |
| 7467 | |||
| 7468 | 1464:261b3d9a4903 | Chris | class QueryAssociationCustomFieldColumn < QueryCustomFieldColumn |
| 7469 | |||
| 7470 | def initialize(association, custom_field) |
||
| 7471 | super(custom_field) |
||
| 7472 | self.name = "#{association}.cf_#{custom_field.id}".to_sym
|
||
| 7473 | # TODO: support sorting/grouping by association custom field |
||
| 7474 | self.sortable = false |
||
| 7475 | self.groupable = false |
||
| 7476 | @association = association |
||
| 7477 | end |
||
| 7478 | |||
| 7479 | def value(object) |
||
| 7480 | if assoc = object.send(@association) |
||
| 7481 | super(assoc) |
||
| 7482 | end |
||
| 7483 | end |
||
| 7484 | |||
| 7485 | def css_classes |
||
| 7486 | @css_classes ||= "#{@association}_cf_#{@cf.id} #{@cf.field_format}"
|
||
| 7487 | end |
||
| 7488 | end |
||
| 7489 | |||
| 7490 | 0:513646585e45 | Chris | class Query < ActiveRecord::Base |
| 7491 | class StatementInvalid < ::ActiveRecord::StatementInvalid |
||
| 7492 | end |
||
| 7493 | 441:cbce1fd3b1b7 | Chris | |
| 7494 | 1464:261b3d9a4903 | Chris | VISIBILITY_PRIVATE = 0 |
| 7495 | VISIBILITY_ROLES = 1 |
||
| 7496 | VISIBILITY_PUBLIC = 2 |
||
| 7497 | |||
| 7498 | 0:513646585e45 | Chris | belongs_to :project |
| 7499 | belongs_to :user |
||
| 7500 | 1464:261b3d9a4903 | Chris | has_and_belongs_to_many :roles, :join_table => "#{table_name_prefix}queries_roles#{table_name_suffix}", :foreign_key => "query_id"
|
| 7501 | 0:513646585e45 | Chris | serialize :filters |
| 7502 | serialize :column_names |
||
| 7503 | serialize :sort_criteria, Array |
||
| 7504 | 1464:261b3d9a4903 | Chris | serialize :options, Hash |
| 7505 | 441:cbce1fd3b1b7 | Chris | |
| 7506 | 0:513646585e45 | Chris | attr_protected :project_id, :user_id |
| 7507 | 441:cbce1fd3b1b7 | Chris | |
| 7508 | 1115:433d4f72a19b | Chris | validates_presence_of :name |
| 7509 | 0:513646585e45 | Chris | validates_length_of :name, :maximum => 255 |
| 7510 | 1464:261b3d9a4903 | Chris | validates :visibility, :inclusion => { :in => [VISIBILITY_PUBLIC, VISIBILITY_ROLES, VISIBILITY_PRIVATE] }
|
| 7511 | 909:cbb26bc654de | Chris | validate :validate_query_filters |
| 7512 | 1464:261b3d9a4903 | Chris | validate do |query| |
| 7513 | errors.add(:base, l(:label_role_plural) + ' ' + l('activerecord.errors.messages.blank')) if query.visibility == VISIBILITY_ROLES && roles.blank?
|
||
| 7514 | end |
||
| 7515 | 441:cbce1fd3b1b7 | Chris | |
| 7516 | 1464:261b3d9a4903 | Chris | after_save do |query| |
| 7517 | if query.visibility_changed? && query.visibility != VISIBILITY_ROLES |
||
| 7518 | query.roles.clear |
||
| 7519 | end |
||
| 7520 | end |
||
| 7521 | 0:513646585e45 | Chris | |
| 7522 | 1464:261b3d9a4903 | Chris | class_attribute :operators |
| 7523 | self.operators = {
|
||
| 7524 | "=" => :label_equals, |
||
| 7525 | "!" => :label_not_equals, |
||
| 7526 | "o" => :label_open_issues, |
||
| 7527 | "c" => :label_closed_issues, |
||
| 7528 | "!*" => :label_none, |
||
| 7529 | "*" => :label_any, |
||
| 7530 | ">=" => :label_greater_or_equal, |
||
| 7531 | "<=" => :label_less_or_equal, |
||
| 7532 | "><" => :label_between, |
||
| 7533 | "<t+" => :label_in_less_than, |
||
| 7534 | ">t+" => :label_in_more_than, |
||
| 7535 | "><t+"=> :label_in_the_next_days, |
||
| 7536 | "t+" => :label_in, |
||
| 7537 | "t" => :label_today, |
||
| 7538 | "ld" => :label_yesterday, |
||
| 7539 | "w" => :label_this_week, |
||
| 7540 | "lw" => :label_last_week, |
||
| 7541 | "l2w" => [:label_last_n_weeks, {:count => 2}],
|
||
| 7542 | "m" => :label_this_month, |
||
| 7543 | "lm" => :label_last_month, |
||
| 7544 | "y" => :label_this_year, |
||
| 7545 | ">t-" => :label_less_than_ago, |
||
| 7546 | "<t-" => :label_more_than_ago, |
||
| 7547 | "><t-"=> :label_in_the_past_days, |
||
| 7548 | "t-" => :label_ago, |
||
| 7549 | "~" => :label_contains, |
||
| 7550 | "!~" => :label_not_contains, |
||
| 7551 | "=p" => :label_any_issues_in_project, |
||
| 7552 | "=!p" => :label_any_issues_not_in_project, |
||
| 7553 | "!p" => :label_no_issues_in_project |
||
| 7554 | } |
||
| 7555 | 441:cbce1fd3b1b7 | Chris | |
| 7556 | 1464:261b3d9a4903 | Chris | class_attribute :operators_by_filter_type |
| 7557 | self.operators_by_filter_type = {
|
||
| 7558 | :list => [ "=", "!" ], |
||
| 7559 | :list_status => [ "o", "=", "!", "c", "*" ], |
||
| 7560 | :list_optional => [ "=", "!", "!*", "*" ], |
||
| 7561 | :list_subprojects => [ "*", "!*", "=" ], |
||
| 7562 | :date => [ "=", ">=", "<=", "><", "<t+", ">t+", "><t+", "t+", "t", "ld", "w", "lw", "l2w", "m", "lm", "y", ">t-", "<t-", "><t-", "t-", "!*", "*" ], |
||
| 7563 | :date_past => [ "=", ">=", "<=", "><", ">t-", "<t-", "><t-", "t-", "t", "ld", "w", "lw", "l2w", "m", "lm", "y", "!*", "*" ], |
||
| 7564 | :string => [ "=", "~", "!", "!~", "!*", "*" ], |
||
| 7565 | :text => [ "~", "!~", "!*", "*" ], |
||
| 7566 | :integer => [ "=", ">=", "<=", "><", "!*", "*" ], |
||
| 7567 | :float => [ "=", ">=", "<=", "><", "!*", "*" ], |
||
| 7568 | :relation => ["=", "=p", "=!p", "!p", "!*", "*"] |
||
| 7569 | } |
||
| 7570 | 0:513646585e45 | Chris | |
| 7571 | 1464:261b3d9a4903 | Chris | class_attribute :available_columns |
| 7572 | self.available_columns = [] |
||
| 7573 | 0:513646585e45 | Chris | |
| 7574 | 1464:261b3d9a4903 | Chris | class_attribute :queried_class |
| 7575 | 441:cbce1fd3b1b7 | Chris | |
| 7576 | 1464:261b3d9a4903 | Chris | def queried_table_name |
| 7577 | @queried_table_name ||= self.class.queried_class.table_name |
||
| 7578 | end |
||
| 7579 | 909:cbb26bc654de | Chris | |
| 7580 | 1115:433d4f72a19b | Chris | def initialize(attributes=nil, *args) |
| 7581 | 0:513646585e45 | Chris | super attributes |
| 7582 | @is_for_all = project.nil? |
||
| 7583 | end |
||
| 7584 | 441:cbce1fd3b1b7 | Chris | |
| 7585 | 1464:261b3d9a4903 | Chris | # Builds the query from the given params |
| 7586 | def build_from_params(params) |
||
| 7587 | if params[:fields] || params[:f] |
||
| 7588 | self.filters = {}
|
||
| 7589 | add_filters(params[:fields] || params[:f], params[:operators] || params[:op], params[:values] || params[:v]) |
||
| 7590 | else |
||
| 7591 | available_filters.keys.each do |field| |
||
| 7592 | add_short_filter(field, params[field]) if params[field] |
||
| 7593 | end |
||
| 7594 | end |
||
| 7595 | self.group_by = params[:group_by] || (params[:query] && params[:query][:group_by]) |
||
| 7596 | self.column_names = params[:c] || (params[:query] && params[:query][:column_names]) |
||
| 7597 | self |
||
| 7598 | end |
||
| 7599 | |||
| 7600 | # Builds a new query from the given params and attributes |
||
| 7601 | def self.build_from_params(params, attributes={})
|
||
| 7602 | new(attributes).build_from_params(params) |
||
| 7603 | end |
||
| 7604 | |||
| 7605 | 909:cbb26bc654de | Chris | def validate_query_filters |
| 7606 | 0:513646585e45 | Chris | filters.each_key do |field| |
| 7607 | 909:cbb26bc654de | Chris | if values_for(field) |
| 7608 | case type_for(field) |
||
| 7609 | when :integer |
||
| 7610 | 1115:433d4f72a19b | Chris | add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && !v.match(/^[+-]?\d+$/) }
|
| 7611 | 909:cbb26bc654de | Chris | when :float |
| 7612 | 1115:433d4f72a19b | Chris | add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && !v.match(/^[+-]?\d+(\.\d*)?$/) }
|
| 7613 | 909:cbb26bc654de | Chris | when :date, :date_past |
| 7614 | case operator_for(field) |
||
| 7615 | when "=", ">=", "<=", "><" |
||
| 7616 | 1517:dffacf8a6908 | Chris | add_filter_error(field, :invalid) if values_for(field).detect {|v|
|
| 7617 | v.present? && (!v.match(/\A\d{4}-\d{2}-\d{2}(T\d{2}((:)?\d{2}){0,2}(Z|\d{2}:?\d{2})?)?\z/) || parse_date(v).nil?)
|
||
| 7618 | } |
||
| 7619 | 1115:433d4f72a19b | Chris | when ">t-", "<t-", "t-", ">t+", "<t+", "t+", "><t+", "><t-" |
| 7620 | add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && !v.match(/^\d+$/) }
|
||
| 7621 | 909:cbb26bc654de | Chris | end |
| 7622 | end |
||
| 7623 | end |
||
| 7624 | |||
| 7625 | 1115:433d4f72a19b | Chris | add_filter_error(field, :blank) unless |
| 7626 | 0:513646585e45 | Chris | # filter requires one or more values |
| 7627 | 441:cbce1fd3b1b7 | Chris | (values_for(field) and !values_for(field).first.blank?) or |
| 7628 | 0:513646585e45 | Chris | # filter doesn't require any value |
| 7629 | 1464:261b3d9a4903 | Chris | ["o", "c", "!*", "*", "t", "ld", "w", "lw", "l2w", "m", "lm", "y"].include? operator_for(field) |
| 7630 | 0:513646585e45 | Chris | end if filters |
| 7631 | end |
||
| 7632 | 909:cbb26bc654de | Chris | |
| 7633 | 1115:433d4f72a19b | Chris | def add_filter_error(field, message) |
| 7634 | m = label_for(field) + " " + l(message, :scope => 'activerecord.errors.messages') |
||
| 7635 | errors.add(:base, m) |
||
| 7636 | end |
||
| 7637 | |||
| 7638 | 0:513646585e45 | Chris | def editable_by?(user) |
| 7639 | return false unless user |
||
| 7640 | # Admin can edit them all and regular users can edit their private queries |
||
| 7641 | 1464:261b3d9a4903 | Chris | return true if user.admin? || (is_private? && self.user_id == user.id) |
| 7642 | 0:513646585e45 | Chris | # Members can not edit public queries that are for all project (only admin is allowed to) |
| 7643 | 1464:261b3d9a4903 | Chris | is_public? && !@is_for_all && user.allowed_to?(:manage_public_queries, project) |
| 7644 | 0:513646585e45 | Chris | end |
| 7645 | 441:cbce1fd3b1b7 | Chris | |
| 7646 | 1115:433d4f72a19b | Chris | def trackers |
| 7647 | 1464:261b3d9a4903 | Chris | @trackers ||= project.nil? ? Tracker.sorted.all : project.rolled_up_trackers |
| 7648 | 1115:433d4f72a19b | Chris | end |
| 7649 | |||
| 7650 | # Returns a hash of localized labels for all filter operators |
||
| 7651 | def self.operators_labels |
||
| 7652 | 1464:261b3d9a4903 | Chris | operators.inject({}) {|h, operator| h[operator.first] = l(*operator.last); h}
|
| 7653 | 0:513646585e45 | Chris | end |
| 7654 | 441:cbce1fd3b1b7 | Chris | |
| 7655 | 1115:433d4f72a19b | Chris | # Returns a representation of the available filters for JSON serialization |
| 7656 | def available_filters_as_json |
||
| 7657 | json = {}
|
||
| 7658 | available_filters.each do |field, options| |
||
| 7659 | json[field] = options.slice(:type, :name, :values).stringify_keys |
||
| 7660 | end |
||
| 7661 | json |
||
| 7662 | end |
||
| 7663 | |||
| 7664 | def all_projects |
||
| 7665 | @all_projects ||= Project.visible.all |
||
| 7666 | end |
||
| 7667 | |||
| 7668 | def all_projects_values |
||
| 7669 | return @all_projects_values if @all_projects_values |
||
| 7670 | |||
| 7671 | values = [] |
||
| 7672 | Project.project_tree(all_projects) do |p, level| |
||
| 7673 | prefix = (level > 0 ? ('--' * level + ' ') : '')
|
||
| 7674 | values << ["#{prefix}#{p.name}", p.id.to_s]
|
||
| 7675 | end |
||
| 7676 | @all_projects_values = values |
||
| 7677 | end |
||
| 7678 | |||
| 7679 | 1464:261b3d9a4903 | Chris | # Adds available filters |
| 7680 | def initialize_available_filters |
||
| 7681 | # implemented by sub-classes |
||
| 7682 | end |
||
| 7683 | protected :initialize_available_filters |
||
| 7684 | |||
| 7685 | # Adds an available filter |
||
| 7686 | def add_available_filter(field, options) |
||
| 7687 | @available_filters ||= ActiveSupport::OrderedHash.new |
||
| 7688 | @available_filters[field] = options |
||
| 7689 | @available_filters |
||
| 7690 | end |
||
| 7691 | |||
| 7692 | # Removes an available filter |
||
| 7693 | def delete_available_filter(field) |
||
| 7694 | if @available_filters |
||
| 7695 | @available_filters.delete(field) |
||
| 7696 | end |
||
| 7697 | end |
||
| 7698 | |||
| 7699 | # Return a hash of available filters |
||
| 7700 | def available_filters |
||
| 7701 | unless @available_filters |
||
| 7702 | initialize_available_filters |
||
| 7703 | @available_filters.each do |field, options| |
||
| 7704 | options[:name] ||= l(options[:label] || "field_#{field}".gsub(/_id$/, ''))
|
||
| 7705 | end |
||
| 7706 | end |
||
| 7707 | @available_filters |
||
| 7708 | end |
||
| 7709 | |||
| 7710 | def add_filter(field, operator, values=nil) |
||
| 7711 | 0:513646585e45 | Chris | # values must be an array |
| 7712 | 909:cbb26bc654de | Chris | return unless values.nil? || values.is_a?(Array) |
| 7713 | 0:513646585e45 | Chris | # check if field is defined as an available filter |
| 7714 | if available_filters.has_key? field |
||
| 7715 | filter_options = available_filters[field] |
||
| 7716 | 909:cbb26bc654de | Chris | filters[field] = {:operator => operator, :values => (values || [''])}
|
| 7717 | 0:513646585e45 | Chris | end |
| 7718 | end |
||
| 7719 | 441:cbce1fd3b1b7 | Chris | |
| 7720 | 0:513646585e45 | Chris | def add_short_filter(field, expression) |
| 7721 | 909:cbb26bc654de | Chris | return unless expression && available_filters.has_key?(field) |
| 7722 | field_type = available_filters[field][:type] |
||
| 7723 | 1464:261b3d9a4903 | Chris | operators_by_filter_type[field_type].sort.reverse.detect do |operator| |
| 7724 | 909:cbb26bc654de | Chris | next unless expression =~ /^#{Regexp.escape(operator)}(.*)$/
|
| 7725 | 1464:261b3d9a4903 | Chris | values = $1 |
| 7726 | add_filter field, operator, values.present? ? values.split('|') : ['']
|
||
| 7727 | 909:cbb26bc654de | Chris | end || add_filter(field, '=', expression.split('|'))
|
| 7728 | 0:513646585e45 | Chris | end |
| 7729 | |||
| 7730 | # Add multiple filters using +add_filter+ |
||
| 7731 | def add_filters(fields, operators, values) |
||
| 7732 | 909:cbb26bc654de | Chris | if fields.is_a?(Array) && operators.is_a?(Hash) && (values.nil? || values.is_a?(Hash)) |
| 7733 | 37:94944d00e43c | chris | fields.each do |field| |
| 7734 | 909:cbb26bc654de | Chris | add_filter(field, operators[field], values && values[field]) |
| 7735 | 37:94944d00e43c | chris | end |
| 7736 | 0:513646585e45 | Chris | end |
| 7737 | end |
||
| 7738 | 441:cbce1fd3b1b7 | Chris | |
| 7739 | 0:513646585e45 | Chris | def has_filter?(field) |
| 7740 | filters and filters[field] |
||
| 7741 | end |
||
| 7742 | 441:cbce1fd3b1b7 | Chris | |
| 7743 | 909:cbb26bc654de | Chris | def type_for(field) |
| 7744 | available_filters[field][:type] if available_filters.has_key?(field) |
||
| 7745 | end |
||
| 7746 | |||
| 7747 | 0:513646585e45 | Chris | def operator_for(field) |
| 7748 | has_filter?(field) ? filters[field][:operator] : nil |
||
| 7749 | end |
||
| 7750 | 441:cbce1fd3b1b7 | Chris | |
| 7751 | 0:513646585e45 | Chris | def values_for(field) |
| 7752 | has_filter?(field) ? filters[field][:values] : nil |
||
| 7753 | end |
||
| 7754 | 441:cbce1fd3b1b7 | Chris | |
| 7755 | 909:cbb26bc654de | Chris | def value_for(field, index=0) |
| 7756 | (values_for(field) || [])[index] |
||
| 7757 | end |
||
| 7758 | |||
| 7759 | 0:513646585e45 | Chris | def label_for(field) |
| 7760 | label = available_filters[field][:name] if available_filters.has_key?(field) |
||
| 7761 | 1115:433d4f72a19b | Chris | label ||= l("field_#{field.to_s.gsub(/_id$/, '')}", :default => field)
|
| 7762 | 0:513646585e45 | Chris | end |
| 7763 | |||
| 7764 | def self.add_available_column(column) |
||
| 7765 | self.available_columns << (column) if column.is_a?(QueryColumn) |
||
| 7766 | end |
||
| 7767 | 441:cbce1fd3b1b7 | Chris | |
| 7768 | 0:513646585e45 | Chris | # Returns an array of columns that can be used to group the results |
| 7769 | def groupable_columns |
||
| 7770 | available_columns.select {|c| c.groupable}
|
||
| 7771 | end |
||
| 7772 | |||
| 7773 | # Returns a Hash of columns and the key for sorting |
||
| 7774 | def sortable_columns |
||
| 7775 | 1464:261b3d9a4903 | Chris | available_columns.inject({}) {|h, column|
|
| 7776 | h[column.name.to_s] = column.sortable |
||
| 7777 | h |
||
| 7778 | } |
||
| 7779 | 0:513646585e45 | Chris | end |
| 7780 | 441:cbce1fd3b1b7 | Chris | |
| 7781 | 0:513646585e45 | Chris | def columns |
| 7782 | 909:cbb26bc654de | Chris | # preserve the column_names order |
| 7783 | 1464:261b3d9a4903 | Chris | cols = (has_default_columns? ? default_columns_names : column_names).collect do |name| |
| 7784 | 909:cbb26bc654de | Chris | available_columns.find { |col| col.name == name }
|
| 7785 | end.compact |
||
| 7786 | 1464:261b3d9a4903 | Chris | available_columns.select(&:frozen?) | cols |
| 7787 | 909:cbb26bc654de | Chris | end |
| 7788 | |||
| 7789 | 1115:433d4f72a19b | Chris | def inline_columns |
| 7790 | columns.select(&:inline?) |
||
| 7791 | end |
||
| 7792 | |||
| 7793 | def block_columns |
||
| 7794 | columns.reject(&:inline?) |
||
| 7795 | end |
||
| 7796 | |||
| 7797 | def available_inline_columns |
||
| 7798 | available_columns.select(&:inline?) |
||
| 7799 | end |
||
| 7800 | |||
| 7801 | def available_block_columns |
||
| 7802 | available_columns.reject(&:inline?) |
||
| 7803 | end |
||
| 7804 | |||
| 7805 | 909:cbb26bc654de | Chris | def default_columns_names |
| 7806 | 1464:261b3d9a4903 | Chris | [] |
| 7807 | 0:513646585e45 | Chris | end |
| 7808 | 441:cbce1fd3b1b7 | Chris | |
| 7809 | 0:513646585e45 | Chris | def column_names=(names) |
| 7810 | if names |
||
| 7811 | names = names.select {|n| n.is_a?(Symbol) || !n.blank? }
|
||
| 7812 | names = names.collect {|n| n.is_a?(Symbol) ? n : n.to_sym }
|
||
| 7813 | # Set column_names to nil if default columns |
||
| 7814 | 909:cbb26bc654de | Chris | if names == default_columns_names |
| 7815 | 0:513646585e45 | Chris | names = nil |
| 7816 | end |
||
| 7817 | end |
||
| 7818 | write_attribute(:column_names, names) |
||
| 7819 | end |
||
| 7820 | 441:cbce1fd3b1b7 | Chris | |
| 7821 | 0:513646585e45 | Chris | def has_column?(column) |
| 7822 | 1115:433d4f72a19b | Chris | column_names && column_names.include?(column.is_a?(QueryColumn) ? column.name : column) |
| 7823 | 0:513646585e45 | Chris | end |
| 7824 | 441:cbce1fd3b1b7 | Chris | |
| 7825 | 1464:261b3d9a4903 | Chris | def has_custom_field_column? |
| 7826 | columns.any? {|column| column.is_a? QueryCustomFieldColumn}
|
||
| 7827 | end |
||
| 7828 | |||
| 7829 | 0:513646585e45 | Chris | def has_default_columns? |
| 7830 | column_names.nil? || column_names.empty? |
||
| 7831 | end |
||
| 7832 | 441:cbce1fd3b1b7 | Chris | |
| 7833 | 0:513646585e45 | Chris | def sort_criteria=(arg) |
| 7834 | c = [] |
||
| 7835 | if arg.is_a?(Hash) |
||
| 7836 | arg = arg.keys.sort.collect {|k| arg[k]}
|
||
| 7837 | end |
||
| 7838 | 1115:433d4f72a19b | Chris | c = arg.select {|k,o| !k.to_s.blank?}.slice(0,3).collect {|k,o| [k.to_s, (o == 'desc' || o == false) ? 'desc' : 'asc']}
|
| 7839 | 0:513646585e45 | Chris | write_attribute(:sort_criteria, c) |
| 7840 | end |
||
| 7841 | 441:cbce1fd3b1b7 | Chris | |
| 7842 | 0:513646585e45 | Chris | def sort_criteria |
| 7843 | read_attribute(:sort_criteria) || [] |
||
| 7844 | end |
||
| 7845 | 441:cbce1fd3b1b7 | Chris | |
| 7846 | 0:513646585e45 | Chris | def sort_criteria_key(arg) |
| 7847 | sort_criteria && sort_criteria[arg] && sort_criteria[arg].first |
||
| 7848 | end |
||
| 7849 | 441:cbce1fd3b1b7 | Chris | |
| 7850 | 0:513646585e45 | Chris | def sort_criteria_order(arg) |
| 7851 | sort_criteria && sort_criteria[arg] && sort_criteria[arg].last |
||
| 7852 | end |
||
| 7853 | 441:cbce1fd3b1b7 | Chris | |
| 7854 | 1115:433d4f72a19b | Chris | def sort_criteria_order_for(key) |
| 7855 | sort_criteria.detect {|k, order| key.to_s == k}.try(:last)
|
||
| 7856 | end |
||
| 7857 | |||
| 7858 | 0:513646585e45 | Chris | # Returns the SQL sort order that should be prepended for grouping |
| 7859 | def group_by_sort_order |
||
| 7860 | if grouped? && (column = group_by_column) |
||
| 7861 | 1115:433d4f72a19b | Chris | order = sort_criteria_order_for(column.name) || column.default_order |
| 7862 | 0:513646585e45 | Chris | column.sortable.is_a?(Array) ? |
| 7863 | 1115:433d4f72a19b | Chris | column.sortable.collect {|s| "#{s} #{order}"}.join(',') :
|
| 7864 | "#{column.sortable} #{order}"
|
||
| 7865 | 0:513646585e45 | Chris | end |
| 7866 | end |
||
| 7867 | 441:cbce1fd3b1b7 | Chris | |
| 7868 | 0:513646585e45 | Chris | # Returns true if the query is a grouped query |
| 7869 | def grouped? |
||
| 7870 | 119:8661b858af72 | Chris | !group_by_column.nil? |
| 7871 | 0:513646585e45 | Chris | end |
| 7872 | 441:cbce1fd3b1b7 | Chris | |
| 7873 | 0:513646585e45 | Chris | def group_by_column |
| 7874 | 119:8661b858af72 | Chris | groupable_columns.detect {|c| c.groupable && c.name.to_s == group_by}
|
| 7875 | 0:513646585e45 | Chris | end |
| 7876 | 441:cbce1fd3b1b7 | Chris | |
| 7877 | 0:513646585e45 | Chris | def group_by_statement |
| 7878 | 119:8661b858af72 | Chris | group_by_column.try(:groupable) |
| 7879 | 0:513646585e45 | Chris | end |
| 7880 | 441:cbce1fd3b1b7 | Chris | |
| 7881 | 0:513646585e45 | Chris | def project_statement |
| 7882 | project_clauses = [] |
||
| 7883 | 909:cbb26bc654de | Chris | if project && !project.descendants.active.empty? |
| 7884 | 0:513646585e45 | Chris | ids = [project.id] |
| 7885 | if has_filter?("subproject_id")
|
||
| 7886 | case operator_for("subproject_id")
|
||
| 7887 | when '=' |
||
| 7888 | # include the selected subprojects |
||
| 7889 | ids += values_for("subproject_id").each(&:to_i)
|
||
| 7890 | when '!*' |
||
| 7891 | # main project only |
||
| 7892 | else |
||
| 7893 | # all subprojects |
||
| 7894 | ids += project.descendants.collect(&:id) |
||
| 7895 | end |
||
| 7896 | elsif Setting.display_subprojects_issues? |
||
| 7897 | ids += project.descendants.collect(&:id) |
||
| 7898 | end |
||
| 7899 | project_clauses << "#{Project.table_name}.id IN (%s)" % ids.join(',')
|
||
| 7900 | elsif project |
||
| 7901 | project_clauses << "#{Project.table_name}.id = %d" % project.id
|
||
| 7902 | end |
||
| 7903 | 441:cbce1fd3b1b7 | Chris | project_clauses.any? ? project_clauses.join(' AND ') : nil
|
| 7904 | 0:513646585e45 | Chris | end |
| 7905 | |||
| 7906 | def statement |
||
| 7907 | # filters clauses |
||
| 7908 | filters_clauses = [] |
||
| 7909 | filters.each_key do |field| |
||
| 7910 | next if field == "subproject_id" |
||
| 7911 | v = values_for(field).clone |
||
| 7912 | next unless v and !v.empty? |
||
| 7913 | operator = operator_for(field) |
||
| 7914 | 441:cbce1fd3b1b7 | Chris | |
| 7915 | 0:513646585e45 | Chris | # "me" value subsitution |
| 7916 | 1464:261b3d9a4903 | Chris | if %w(assigned_to_id author_id user_id watcher_id).include?(field) |
| 7917 | 909:cbb26bc654de | Chris | if v.delete("me")
|
| 7918 | if User.current.logged? |
||
| 7919 | v.push(User.current.id.to_s) |
||
| 7920 | v += User.current.group_ids.map(&:to_s) if field == 'assigned_to_id' |
||
| 7921 | else |
||
| 7922 | v.push("0")
|
||
| 7923 | end |
||
| 7924 | end |
||
| 7925 | 0:513646585e45 | Chris | end |
| 7926 | 441:cbce1fd3b1b7 | Chris | |
| 7927 | 1115:433d4f72a19b | Chris | if field == 'project_id' |
| 7928 | if v.delete('mine')
|
||
| 7929 | v += User.current.memberships.map(&:project_id).map(&:to_s) |
||
| 7930 | end |
||
| 7931 | end |
||
| 7932 | |||
| 7933 | if field =~ /cf_(\d+)$/ |
||
| 7934 | 0:513646585e45 | Chris | # custom field |
| 7935 | 909:cbb26bc654de | Chris | filters_clauses << sql_for_custom_field(field, operator, v, $1) |
| 7936 | elsif respond_to?("sql_for_#{field}_field")
|
||
| 7937 | # specific statement |
||
| 7938 | filters_clauses << send("sql_for_#{field}_field", field, operator, v)
|
||
| 7939 | 0:513646585e45 | Chris | else |
| 7940 | # regular field |
||
| 7941 | 1464:261b3d9a4903 | Chris | filters_clauses << '(' + sql_for_field(field, operator, v, queried_table_name, field) + ')'
|
| 7942 | 0:513646585e45 | Chris | end |
| 7943 | end if filters and valid? |
||
| 7944 | 441:cbce1fd3b1b7 | Chris | |
| 7945 | 1464:261b3d9a4903 | Chris | if (c = group_by_column) && c.is_a?(QueryCustomFieldColumn) |
| 7946 | # Excludes results for which the grouped custom field is not visible |
||
| 7947 | filters_clauses << c.custom_field.visibility_by_project_condition |
||
| 7948 | end |
||
| 7949 | |||
| 7950 | 441:cbce1fd3b1b7 | Chris | filters_clauses << project_statement |
| 7951 | filters_clauses.reject!(&:blank?) |
||
| 7952 | |||
| 7953 | filters_clauses.any? ? filters_clauses.join(' AND ') : nil
|
||
| 7954 | 0:513646585e45 | Chris | end |
| 7955 | 441:cbce1fd3b1b7 | Chris | |
| 7956 | 0:513646585e45 | Chris | private |
| 7957 | 441:cbce1fd3b1b7 | Chris | |
| 7958 | 909:cbb26bc654de | Chris | def sql_for_custom_field(field, operator, value, custom_field_id) |
| 7959 | db_table = CustomValue.table_name |
||
| 7960 | db_field = 'value' |
||
| 7961 | 1115:433d4f72a19b | Chris | filter = @available_filters[field] |
| 7962 | return nil unless filter |
||
| 7963 | 1517:dffacf8a6908 | Chris | if filter[:field].format.target_class && filter[:field].format.target_class <= User |
| 7964 | 1115:433d4f72a19b | Chris | if value.delete('me')
|
| 7965 | value.push User.current.id.to_s |
||
| 7966 | end |
||
| 7967 | end |
||
| 7968 | not_in = nil |
||
| 7969 | if operator == '!' |
||
| 7970 | # Makes ! operator work for custom fields with multiple values |
||
| 7971 | operator = '=' |
||
| 7972 | not_in = 'NOT' |
||
| 7973 | end |
||
| 7974 | customized_key = "id" |
||
| 7975 | 1464:261b3d9a4903 | Chris | customized_class = queried_class |
| 7976 | 1115:433d4f72a19b | Chris | if field =~ /^(.+)\.cf_/ |
| 7977 | assoc = $1 |
||
| 7978 | customized_key = "#{assoc}_id"
|
||
| 7979 | 1464:261b3d9a4903 | Chris | customized_class = queried_class.reflect_on_association(assoc.to_sym).klass.base_class rescue nil |
| 7980 | raise "Unknown #{queried_class.name} association #{assoc}" unless customized_class
|
||
| 7981 | 1115:433d4f72a19b | Chris | end |
| 7982 | 1464:261b3d9a4903 | Chris | where = sql_for_field(field, operator, value, db_table, db_field, true) |
| 7983 | if operator =~ /[<>]/ |
||
| 7984 | where = "(#{where}) AND #{db_table}.#{db_field} <> ''"
|
||
| 7985 | end |
||
| 7986 | "#{queried_table_name}.#{customized_key} #{not_in} IN (" +
|
||
| 7987 | "SELECT #{customized_class.table_name}.id FROM #{customized_class.table_name}" +
|
||
| 7988 | " LEFT OUTER JOIN #{db_table} ON #{db_table}.customized_type='#{customized_class}' AND #{db_table}.customized_id=#{customized_class.table_name}.id AND #{db_table}.custom_field_id=#{custom_field_id}" +
|
||
| 7989 | " WHERE (#{where}) AND (#{filter[:field].visibility_by_project_condition}))"
|
||
| 7990 | 909:cbb26bc654de | Chris | end |
| 7991 | |||
| 7992 | 0:513646585e45 | Chris | # Helper method to generate the WHERE sql for a +field+, +operator+ and a +value+ |
| 7993 | def sql_for_field(field, operator, value, db_table, db_field, is_custom_filter=false) |
||
| 7994 | sql = '' |
||
| 7995 | case operator |
||
| 7996 | when "=" |
||
| 7997 | 245:051f544170fe | Chris | if value.any? |
| 7998 | 909:cbb26bc654de | Chris | case type_for(field) |
| 7999 | when :date, :date_past |
||
| 8000 | 1517:dffacf8a6908 | Chris | sql = date_clause(db_table, db_field, parse_date(value.first), parse_date(value.first)) |
| 8001 | 909:cbb26bc654de | Chris | when :integer |
| 8002 | 1115:433d4f72a19b | Chris | if is_custom_filter |
| 8003 | 1464:261b3d9a4903 | Chris | sql = "(#{db_table}.#{db_field} <> '' AND CAST(CASE #{db_table}.#{db_field} WHEN '' THEN '0' ELSE #{db_table}.#{db_field} END AS decimal(30,3)) = #{value.first.to_i})"
|
| 8004 | 1115:433d4f72a19b | Chris | else |
| 8005 | sql = "#{db_table}.#{db_field} = #{value.first.to_i}"
|
||
| 8006 | end |
||
| 8007 | 909:cbb26bc654de | Chris | when :float |
| 8008 | 1115:433d4f72a19b | Chris | if is_custom_filter |
| 8009 | 1464:261b3d9a4903 | Chris | sql = "(#{db_table}.#{db_field} <> '' AND CAST(CASE #{db_table}.#{db_field} WHEN '' THEN '0' ELSE #{db_table}.#{db_field} END AS decimal(30,3)) BETWEEN #{value.first.to_f - 1e-5} AND #{value.first.to_f + 1e-5})"
|
| 8010 | 1115:433d4f72a19b | Chris | else |
| 8011 | sql = "#{db_table}.#{db_field} BETWEEN #{value.first.to_f - 1e-5} AND #{value.first.to_f + 1e-5}"
|
||
| 8012 | end |
||
| 8013 | 909:cbb26bc654de | Chris | else |
| 8014 | sql = "#{db_table}.#{db_field} IN (" + value.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + ")"
|
||
| 8015 | end |
||
| 8016 | 245:051f544170fe | Chris | else |
| 8017 | # IN an empty set |
||
| 8018 | sql = "1=0" |
||
| 8019 | end |
||
| 8020 | 0:513646585e45 | Chris | when "!" |
| 8021 | 245:051f544170fe | Chris | if value.any? |
| 8022 | sql = "(#{db_table}.#{db_field} IS NULL OR #{db_table}.#{db_field} NOT IN (" + value.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + "))"
|
||
| 8023 | else |
||
| 8024 | # NOT IN an empty set |
||
| 8025 | sql = "1=1" |
||
| 8026 | end |
||
| 8027 | 0:513646585e45 | Chris | when "!*" |
| 8028 | sql = "#{db_table}.#{db_field} IS NULL"
|
||
| 8029 | sql << " OR #{db_table}.#{db_field} = ''" if is_custom_filter
|
||
| 8030 | when "*" |
||
| 8031 | sql = "#{db_table}.#{db_field} IS NOT NULL"
|
||
| 8032 | sql << " AND #{db_table}.#{db_field} <> ''" if is_custom_filter
|
||
| 8033 | when ">=" |
||
| 8034 | 909:cbb26bc654de | Chris | if [:date, :date_past].include?(type_for(field)) |
| 8035 | 1517:dffacf8a6908 | Chris | sql = date_clause(db_table, db_field, parse_date(value.first), nil) |
| 8036 | 909:cbb26bc654de | Chris | else |
| 8037 | if is_custom_filter |
||
| 8038 | 1464:261b3d9a4903 | Chris | sql = "(#{db_table}.#{db_field} <> '' AND CAST(CASE #{db_table}.#{db_field} WHEN '' THEN '0' ELSE #{db_table}.#{db_field} END AS decimal(30,3)) >= #{value.first.to_f})"
|
| 8039 | 909:cbb26bc654de | Chris | else |
| 8040 | sql = "#{db_table}.#{db_field} >= #{value.first.to_f}"
|
||
| 8041 | end |
||
| 8042 | end |
||
| 8043 | 0:513646585e45 | Chris | when "<=" |
| 8044 | 909:cbb26bc654de | Chris | if [:date, :date_past].include?(type_for(field)) |
| 8045 | 1517:dffacf8a6908 | Chris | sql = date_clause(db_table, db_field, nil, parse_date(value.first)) |
| 8046 | 909:cbb26bc654de | Chris | else |
| 8047 | if is_custom_filter |
||
| 8048 | 1464:261b3d9a4903 | Chris | sql = "(#{db_table}.#{db_field} <> '' AND CAST(CASE #{db_table}.#{db_field} WHEN '' THEN '0' ELSE #{db_table}.#{db_field} END AS decimal(30,3)) <= #{value.first.to_f})"
|
| 8049 | 909:cbb26bc654de | Chris | else |
| 8050 | sql = "#{db_table}.#{db_field} <= #{value.first.to_f}"
|
||
| 8051 | end |
||
| 8052 | end |
||
| 8053 | when "><" |
||
| 8054 | if [:date, :date_past].include?(type_for(field)) |
||
| 8055 | 1517:dffacf8a6908 | Chris | sql = date_clause(db_table, db_field, parse_date(value[0]), parse_date(value[1])) |
| 8056 | 909:cbb26bc654de | Chris | else |
| 8057 | if is_custom_filter |
||
| 8058 | 1464:261b3d9a4903 | Chris | sql = "(#{db_table}.#{db_field} <> '' AND CAST(CASE #{db_table}.#{db_field} WHEN '' THEN '0' ELSE #{db_table}.#{db_field} END AS decimal(30,3)) BETWEEN #{value[0].to_f} AND #{value[1].to_f})"
|
| 8059 | 909:cbb26bc654de | Chris | else |
| 8060 | sql = "#{db_table}.#{db_field} BETWEEN #{value[0].to_f} AND #{value[1].to_f}"
|
||
| 8061 | end |
||
| 8062 | end |
||
| 8063 | 0:513646585e45 | Chris | when "o" |
| 8064 | 1464:261b3d9a4903 | Chris | sql = "#{queried_table_name}.status_id IN (SELECT id FROM #{IssueStatus.table_name} WHERE is_closed=#{connection.quoted_false})" if field == "status_id"
|
| 8065 | 0:513646585e45 | Chris | when "c" |
| 8066 | 1464:261b3d9a4903 | Chris | sql = "#{queried_table_name}.status_id IN (SELECT id FROM #{IssueStatus.table_name} WHERE is_closed=#{connection.quoted_true})" if field == "status_id"
|
| 8067 | 1115:433d4f72a19b | Chris | when "><t-" |
| 8068 | # between today - n days and today |
||
| 8069 | sql = relative_date_clause(db_table, db_field, - value.first.to_i, 0) |
||
| 8070 | 0:513646585e45 | Chris | when ">t-" |
| 8071 | 1115:433d4f72a19b | Chris | # >= today - n days |
| 8072 | sql = relative_date_clause(db_table, db_field, - value.first.to_i, nil) |
||
| 8073 | 0:513646585e45 | Chris | when "<t-" |
| 8074 | 1115:433d4f72a19b | Chris | # <= today - n days |
| 8075 | 909:cbb26bc654de | Chris | sql = relative_date_clause(db_table, db_field, nil, - value.first.to_i) |
| 8076 | 0:513646585e45 | Chris | when "t-" |
| 8077 | 1115:433d4f72a19b | Chris | # = n days in past |
| 8078 | 909:cbb26bc654de | Chris | sql = relative_date_clause(db_table, db_field, - value.first.to_i, - value.first.to_i) |
| 8079 | 1115:433d4f72a19b | Chris | when "><t+" |
| 8080 | # between today and today + n days |
||
| 8081 | sql = relative_date_clause(db_table, db_field, 0, value.first.to_i) |
||
| 8082 | 0:513646585e45 | Chris | when ">t+" |
| 8083 | 1115:433d4f72a19b | Chris | # >= today + n days |
| 8084 | 909:cbb26bc654de | Chris | sql = relative_date_clause(db_table, db_field, value.first.to_i, nil) |
| 8085 | 0:513646585e45 | Chris | when "<t+" |
| 8086 | 1115:433d4f72a19b | Chris | # <= today + n days |
| 8087 | sql = relative_date_clause(db_table, db_field, nil, value.first.to_i) |
||
| 8088 | 0:513646585e45 | Chris | when "t+" |
| 8089 | 1115:433d4f72a19b | Chris | # = today + n days |
| 8090 | 909:cbb26bc654de | Chris | sql = relative_date_clause(db_table, db_field, value.first.to_i, value.first.to_i) |
| 8091 | 0:513646585e45 | Chris | when "t" |
| 8092 | 1115:433d4f72a19b | Chris | # = today |
| 8093 | 909:cbb26bc654de | Chris | sql = relative_date_clause(db_table, db_field, 0, 0) |
| 8094 | 1464:261b3d9a4903 | Chris | when "ld" |
| 8095 | # = yesterday |
||
| 8096 | sql = relative_date_clause(db_table, db_field, -1, -1) |
||
| 8097 | 0:513646585e45 | Chris | when "w" |
| 8098 | 1115:433d4f72a19b | Chris | # = this week |
| 8099 | 441:cbce1fd3b1b7 | Chris | first_day_of_week = l(:general_first_day_of_week).to_i |
| 8100 | day_of_week = Date.today.cwday |
||
| 8101 | days_ago = (day_of_week >= first_day_of_week ? day_of_week - first_day_of_week : day_of_week + 7 - first_day_of_week) |
||
| 8102 | 909:cbb26bc654de | Chris | sql = relative_date_clause(db_table, db_field, - days_ago, - days_ago + 6) |
| 8103 | 1464:261b3d9a4903 | Chris | when "lw" |
| 8104 | # = last week |
||
| 8105 | first_day_of_week = l(:general_first_day_of_week).to_i |
||
| 8106 | day_of_week = Date.today.cwday |
||
| 8107 | days_ago = (day_of_week >= first_day_of_week ? day_of_week - first_day_of_week : day_of_week + 7 - first_day_of_week) |
||
| 8108 | sql = relative_date_clause(db_table, db_field, - days_ago - 7, - days_ago - 1) |
||
| 8109 | when "l2w" |
||
| 8110 | # = last 2 weeks |
||
| 8111 | first_day_of_week = l(:general_first_day_of_week).to_i |
||
| 8112 | day_of_week = Date.today.cwday |
||
| 8113 | days_ago = (day_of_week >= first_day_of_week ? day_of_week - first_day_of_week : day_of_week + 7 - first_day_of_week) |
||
| 8114 | sql = relative_date_clause(db_table, db_field, - days_ago - 14, - days_ago - 1) |
||
| 8115 | when "m" |
||
| 8116 | # = this month |
||
| 8117 | date = Date.today |
||
| 8118 | sql = date_clause(db_table, db_field, date.beginning_of_month, date.end_of_month) |
||
| 8119 | when "lm" |
||
| 8120 | # = last month |
||
| 8121 | date = Date.today.prev_month |
||
| 8122 | sql = date_clause(db_table, db_field, date.beginning_of_month, date.end_of_month) |
||
| 8123 | when "y" |
||
| 8124 | # = this year |
||
| 8125 | date = Date.today |
||
| 8126 | sql = date_clause(db_table, db_field, date.beginning_of_year, date.end_of_year) |
||
| 8127 | 0:513646585e45 | Chris | when "~" |
| 8128 | sql = "LOWER(#{db_table}.#{db_field}) LIKE '%#{connection.quote_string(value.first.to_s.downcase)}%'"
|
||
| 8129 | when "!~" |
||
| 8130 | sql = "LOWER(#{db_table}.#{db_field}) NOT LIKE '%#{connection.quote_string(value.first.to_s.downcase)}%'"
|
||
| 8131 | 909:cbb26bc654de | Chris | else |
| 8132 | raise "Unknown query operator #{operator}"
|
||
| 8133 | 0:513646585e45 | Chris | end |
| 8134 | 441:cbce1fd3b1b7 | Chris | |
| 8135 | 0:513646585e45 | Chris | return sql |
| 8136 | end |
||
| 8137 | 441:cbce1fd3b1b7 | Chris | |
| 8138 | 1464:261b3d9a4903 | Chris | # Adds a filter for the given custom field |
| 8139 | def add_custom_field_filter(field, assoc=nil) |
||
| 8140 | 1517:dffacf8a6908 | Chris | options = field.format.query_filter_options(field, self) |
| 8141 | if field.format.target_class && field.format.target_class <= User |
||
| 8142 | if options[:values].is_a?(Array) && User.current.logged? |
||
| 8143 | options[:values].unshift ["<< #{l(:label_me)} >>", "me"]
|
||
| 8144 | 1464:261b3d9a4903 | Chris | end |
| 8145 | end |
||
| 8146 | 1517:dffacf8a6908 | Chris | |
| 8147 | 1464:261b3d9a4903 | Chris | filter_id = "cf_#{field.id}"
|
| 8148 | filter_name = field.name |
||
| 8149 | if assoc.present? |
||
| 8150 | filter_id = "#{assoc}.#{filter_id}"
|
||
| 8151 | filter_name = l("label_attribute_of_#{assoc}", :name => filter_name)
|
||
| 8152 | end |
||
| 8153 | add_available_filter filter_id, options.merge({
|
||
| 8154 | :name => filter_name, |
||
| 8155 | :field => field |
||
| 8156 | }) |
||
| 8157 | end |
||
| 8158 | 441:cbce1fd3b1b7 | Chris | |
| 8159 | 1464:261b3d9a4903 | Chris | # Adds filters for the given custom fields scope |
| 8160 | def add_custom_fields_filters(scope, assoc=nil) |
||
| 8161 | scope.visible.where(:is_filter => true).sorted.each do |field| |
||
| 8162 | add_custom_field_filter(field, assoc) |
||
| 8163 | 1115:433d4f72a19b | Chris | end |
| 8164 | end |
||
| 8165 | |||
| 8166 | 1464:261b3d9a4903 | Chris | # Adds filters for the given associations custom fields |
| 8167 | 1115:433d4f72a19b | Chris | def add_associations_custom_fields_filters(*associations) |
| 8168 | 1464:261b3d9a4903 | Chris | fields_by_class = CustomField.visible.where(:is_filter => true).group_by(&:class) |
| 8169 | 1115:433d4f72a19b | Chris | associations.each do |assoc| |
| 8170 | 1464:261b3d9a4903 | Chris | association_klass = queried_class.reflect_on_association(assoc).klass |
| 8171 | 1115:433d4f72a19b | Chris | fields_by_class.each do |field_class, fields| |
| 8172 | if field_class.customized_class <= association_klass |
||
| 8173 | 1464:261b3d9a4903 | Chris | fields.sort.each do |field| |
| 8174 | add_custom_field_filter(field, assoc) |
||
| 8175 | end |
||
| 8176 | 1115:433d4f72a19b | Chris | end |
| 8177 | end |
||
| 8178 | 0:513646585e45 | Chris | end |
| 8179 | end |
||
| 8180 | 441:cbce1fd3b1b7 | Chris | |
| 8181 | 0:513646585e45 | Chris | # Returns a SQL clause for a date or datetime field. |
| 8182 | 909:cbb26bc654de | Chris | def date_clause(table, field, from, to) |
| 8183 | 0:513646585e45 | Chris | s = [] |
| 8184 | if from |
||
| 8185 | 1517:dffacf8a6908 | Chris | if from.is_a?(Date) |
| 8186 | from = Time.local(from.year, from.month, from.day).yesterday.end_of_day |
||
| 8187 | else |
||
| 8188 | from = from - 1 # second |
||
| 8189 | end |
||
| 8190 | 1115:433d4f72a19b | Chris | if self.class.default_timezone == :utc |
| 8191 | 1517:dffacf8a6908 | Chris | from = from.utc |
| 8192 | 1115:433d4f72a19b | Chris | end |
| 8193 | 1517:dffacf8a6908 | Chris | s << ("#{table}.#{field} > '%s'" % [connection.quoted_date(from)])
|
| 8194 | 0:513646585e45 | Chris | end |
| 8195 | if to |
||
| 8196 | 1517:dffacf8a6908 | Chris | if to.is_a?(Date) |
| 8197 | to = Time.local(to.year, to.month, to.day).end_of_day |
||
| 8198 | end |
||
| 8199 | 1115:433d4f72a19b | Chris | if self.class.default_timezone == :utc |
| 8200 | 1517:dffacf8a6908 | Chris | to = to.utc |
| 8201 | 1115:433d4f72a19b | Chris | end |
| 8202 | 1517:dffacf8a6908 | Chris | s << ("#{table}.#{field} <= '%s'" % [connection.quoted_date(to)])
|
| 8203 | 0:513646585e45 | Chris | end |
| 8204 | s.join(' AND ')
|
||
| 8205 | end |
||
| 8206 | 909:cbb26bc654de | Chris | |
| 8207 | # Returns a SQL clause for a date or datetime field using relative dates. |
||
| 8208 | def relative_date_clause(table, field, days_from, days_to) |
||
| 8209 | date_clause(table, field, (days_from ? Date.today + days_from : nil), (days_to ? Date.today + days_to : nil)) |
||
| 8210 | end |
||
| 8211 | 1115:433d4f72a19b | Chris | |
| 8212 | 1517:dffacf8a6908 | Chris | # Returns a Date or Time from the given filter value |
| 8213 | def parse_date(arg) |
||
| 8214 | if arg.to_s =~ /\A\d{4}-\d{2}-\d{2}T/
|
||
| 8215 | Time.parse(arg) rescue nil |
||
| 8216 | else |
||
| 8217 | Date.parse(arg) rescue nil |
||
| 8218 | end |
||
| 8219 | end |
||
| 8220 | |||
| 8221 | 1115:433d4f72a19b | Chris | # Additional joins required for the given sort options |
| 8222 | def joins_for_order_statement(order_options) |
||
| 8223 | joins = [] |
||
| 8224 | |||
| 8225 | if order_options |
||
| 8226 | if order_options.include?('authors')
|
||
| 8227 | 1464:261b3d9a4903 | Chris | joins << "LEFT OUTER JOIN #{User.table_name} authors ON authors.id = #{queried_table_name}.author_id"
|
| 8228 | 1115:433d4f72a19b | Chris | end |
| 8229 | order_options.scan(/cf_\d+/).uniq.each do |name| |
||
| 8230 | column = available_columns.detect {|c| c.name.to_s == name}
|
||
| 8231 | join = column && column.custom_field.join_for_order_statement |
||
| 8232 | if join |
||
| 8233 | joins << join |
||
| 8234 | end |
||
| 8235 | end |
||
| 8236 | end |
||
| 8237 | |||
| 8238 | joins.any? ? joins.join(' ') : nil
|
||
| 8239 | end |
||
| 8240 | 0:513646585e45 | Chris | end |
| 8241 | 441:cbce1fd3b1b7 | Chris | # Redmine - project management software |
| 8242 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 8243 | 0:513646585e45 | Chris | # |
| 8244 | # This program is free software; you can redistribute it and/or |
||
| 8245 | # modify it under the terms of the GNU General Public License |
||
| 8246 | # as published by the Free Software Foundation; either version 2 |
||
| 8247 | # of the License, or (at your option) any later version. |
||
| 8248 | 441:cbce1fd3b1b7 | Chris | # |
| 8249 | 0:513646585e45 | Chris | # This program is distributed in the hope that it will be useful, |
| 8250 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 8251 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 8252 | # GNU General Public License for more details. |
||
| 8253 | 441:cbce1fd3b1b7 | Chris | # |
| 8254 | 0:513646585e45 | Chris | # You should have received a copy of the GNU General Public License |
| 8255 | # along with this program; if not, write to the Free Software |
||
| 8256 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 8257 | |||
| 8258 | 909:cbb26bc654de | Chris | class ScmFetchError < Exception; end |
| 8259 | |||
| 8260 | 0:513646585e45 | Chris | class Repository < ActiveRecord::Base |
| 8261 | 245:051f544170fe | Chris | include Redmine::Ciphering |
| 8262 | 1115:433d4f72a19b | Chris | include Redmine::SafeAttributes |
| 8263 | |||
| 8264 | # Maximum length for repository identifiers |
||
| 8265 | IDENTIFIER_MAX_LENGTH = 255 |
||
| 8266 | 441:cbce1fd3b1b7 | Chris | |
| 8267 | 0:513646585e45 | Chris | belongs_to :project |
| 8268 | has_many :changesets, :order => "#{Changeset.table_name}.committed_on DESC, #{Changeset.table_name}.id DESC"
|
||
| 8269 | 1115:433d4f72a19b | Chris | has_many :filechanges, :class_name => 'Change', :through => :changesets |
| 8270 | 441:cbce1fd3b1b7 | Chris | |
| 8271 | serialize :extra_info |
||
| 8272 | |||
| 8273 | 1115:433d4f72a19b | Chris | before_save :check_default |
| 8274 | |||
| 8275 | 0:513646585e45 | Chris | # Raw SQL to delete changesets and changes in the database |
| 8276 | # has_many :changesets, :dependent => :destroy is too slow for big repositories |
||
| 8277 | before_destroy :clear_changesets |
||
| 8278 | 441:cbce1fd3b1b7 | Chris | |
| 8279 | 245:051f544170fe | Chris | validates_length_of :password, :maximum => 255, :allow_nil => true |
| 8280 | 1115:433d4f72a19b | Chris | validates_length_of :identifier, :maximum => IDENTIFIER_MAX_LENGTH, :allow_blank => true |
| 8281 | validates_presence_of :identifier, :unless => Proc.new { |r| r.is_default? || r.set_as_default? }
|
||
| 8282 | validates_uniqueness_of :identifier, :scope => :project_id, :allow_blank => true |
||
| 8283 | 1517:dffacf8a6908 | Chris | validates_exclusion_of :identifier, :in => %w(browse show entry raw changes annotate diff statistics graph revisions revision) |
| 8284 | 1115:433d4f72a19b | Chris | # donwcase letters, digits, dashes, underscores but not digits only |
| 8285 | 1464:261b3d9a4903 | Chris | validates_format_of :identifier, :with => /\A(?!\d+$)[a-z0-9\-_]*\z/, :allow_blank => true |
| 8286 | 0:513646585e45 | Chris | # Checks if the SCM is enabled when creating a repository |
| 8287 | 909:cbb26bc654de | Chris | validate :repo_create_validation, :on => :create |
| 8288 | |||
| 8289 | 1115:433d4f72a19b | Chris | safe_attributes 'identifier', |
| 8290 | 'login', |
||
| 8291 | 'password', |
||
| 8292 | 'path_encoding', |
||
| 8293 | 'log_encoding', |
||
| 8294 | 1355:3d01be97cb5a | chris | 'is_external', |
| 8295 | 'external_url', |
||
| 8296 | 1115:433d4f72a19b | Chris | 'is_default' |
| 8297 | |||
| 8298 | safe_attributes 'url', |
||
| 8299 | :if => lambda {|repository, user| repository.new_record?}
|
||
| 8300 | |||
| 8301 | 909:cbb26bc654de | Chris | def repo_create_validation |
| 8302 | unless Setting.enabled_scm.include?(self.class.name.demodulize) |
||
| 8303 | errors.add(:type, :invalid) |
||
| 8304 | end |
||
| 8305 | end |
||
| 8306 | 245:051f544170fe | Chris | |
| 8307 | 1115:433d4f72a19b | Chris | def self.human_attribute_name(attribute_key_name, *args) |
| 8308 | attr_name = attribute_key_name.to_s |
||
| 8309 | 441:cbce1fd3b1b7 | Chris | if attr_name == "log_encoding" |
| 8310 | attr_name = "commit_logs_encoding" |
||
| 8311 | end |
||
| 8312 | 1115:433d4f72a19b | Chris | super(attr_name, *args) |
| 8313 | 441:cbce1fd3b1b7 | Chris | end |
| 8314 | |||
| 8315 | 0:513646585e45 | Chris | # Removes leading and trailing whitespace |
| 8316 | def url=(arg) |
||
| 8317 | write_attribute(:url, arg ? arg.to_s.strip : nil) |
||
| 8318 | end |
||
| 8319 | 245:051f544170fe | Chris | |
| 8320 | 0:513646585e45 | Chris | # Removes leading and trailing whitespace |
| 8321 | def root_url=(arg) |
||
| 8322 | write_attribute(:root_url, arg ? arg.to_s.strip : nil) |
||
| 8323 | end |
||
| 8324 | 441:cbce1fd3b1b7 | Chris | |
| 8325 | 245:051f544170fe | Chris | def password |
| 8326 | read_ciphered_attribute(:password) |
||
| 8327 | end |
||
| 8328 | 441:cbce1fd3b1b7 | Chris | |
| 8329 | 245:051f544170fe | Chris | def password=(arg) |
| 8330 | write_ciphered_attribute(:password, arg) |
||
| 8331 | end |
||
| 8332 | |||
| 8333 | def scm_adapter |
||
| 8334 | self.class.scm_adapter_class |
||
| 8335 | end |
||
| 8336 | 0:513646585e45 | Chris | |
| 8337 | def scm |
||
| 8338 | 1115:433d4f72a19b | Chris | unless @scm |
| 8339 | @scm = self.scm_adapter.new(url, root_url, |
||
| 8340 | 245:051f544170fe | Chris | login, password, path_encoding) |
| 8341 | 1115:433d4f72a19b | Chris | if root_url.blank? && @scm.root_url.present? |
| 8342 | update_attribute(:root_url, @scm.root_url) |
||
| 8343 | end |
||
| 8344 | end |
||
| 8345 | 0:513646585e45 | Chris | @scm |
| 8346 | end |
||
| 8347 | 245:051f544170fe | Chris | |
| 8348 | 0:513646585e45 | Chris | def scm_name |
| 8349 | self.class.scm_name |
||
| 8350 | end |
||
| 8351 | 245:051f544170fe | Chris | |
| 8352 | 1115:433d4f72a19b | Chris | def name |
| 8353 | if identifier.present? |
||
| 8354 | identifier |
||
| 8355 | elsif is_default? |
||
| 8356 | l(:field_repository_is_default) |
||
| 8357 | else |
||
| 8358 | scm_name |
||
| 8359 | end |
||
| 8360 | end |
||
| 8361 | |||
| 8362 | def identifier=(identifier) |
||
| 8363 | super unless identifier_frozen? |
||
| 8364 | end |
||
| 8365 | |||
| 8366 | def identifier_frozen? |
||
| 8367 | errors[:identifier].blank? && !(new_record? || identifier.blank?) |
||
| 8368 | end |
||
| 8369 | |||
| 8370 | def identifier_param |
||
| 8371 | if is_default? |
||
| 8372 | nil |
||
| 8373 | elsif identifier.present? |
||
| 8374 | identifier |
||
| 8375 | else |
||
| 8376 | id.to_s |
||
| 8377 | end |
||
| 8378 | end |
||
| 8379 | |||
| 8380 | def <=>(repository) |
||
| 8381 | if is_default? |
||
| 8382 | -1 |
||
| 8383 | elsif repository.is_default? |
||
| 8384 | 1 |
||
| 8385 | else |
||
| 8386 | identifier.to_s <=> repository.identifier.to_s |
||
| 8387 | end |
||
| 8388 | end |
||
| 8389 | |||
| 8390 | def self.find_by_identifier_param(param) |
||
| 8391 | if param.to_s =~ /^\d+$/ |
||
| 8392 | find_by_id(param) |
||
| 8393 | else |
||
| 8394 | find_by_identifier(param) |
||
| 8395 | end |
||
| 8396 | end |
||
| 8397 | |||
| 8398 | 1494:e248c7af89ec | Chris | # TODO: should return an empty hash instead of nil to avoid many ||{}
|
| 8399 | def extra_info |
||
| 8400 | h = read_attribute(:extra_info) |
||
| 8401 | h.is_a?(Hash) ? h : nil |
||
| 8402 | end |
||
| 8403 | |||
| 8404 | 441:cbce1fd3b1b7 | Chris | def merge_extra_info(arg) |
| 8405 | h = extra_info || {}
|
||
| 8406 | return h if arg.nil? |
||
| 8407 | h.merge!(arg) |
||
| 8408 | write_attribute(:extra_info, h) |
||
| 8409 | end |
||
| 8410 | |||
| 8411 | def report_last_commit |
||
| 8412 | true |
||
| 8413 | end |
||
| 8414 | |||
| 8415 | 0:513646585e45 | Chris | def supports_cat? |
| 8416 | scm.supports_cat? |
||
| 8417 | end |
||
| 8418 | |||
| 8419 | def supports_annotate? |
||
| 8420 | scm.supports_annotate? |
||
| 8421 | end |
||
| 8422 | 441:cbce1fd3b1b7 | Chris | |
| 8423 | def supports_all_revisions? |
||
| 8424 | true |
||
| 8425 | end |
||
| 8426 | |||
| 8427 | def supports_directory_revisions? |
||
| 8428 | false |
||
| 8429 | end |
||
| 8430 | |||
| 8431 | 909:cbb26bc654de | Chris | def supports_revision_graph? |
| 8432 | false |
||
| 8433 | end |
||
| 8434 | |||
| 8435 | 0:513646585e45 | Chris | def entry(path=nil, identifier=nil) |
| 8436 | scm.entry(path, identifier) |
||
| 8437 | end |
||
| 8438 | 441:cbce1fd3b1b7 | Chris | |
| 8439 | 1517:dffacf8a6908 | Chris | def scm_entries(path=nil, identifier=nil) |
| 8440 | scm.entries(path, identifier) |
||
| 8441 | end |
||
| 8442 | protected :scm_entries |
||
| 8443 | |||
| 8444 | 0:513646585e45 | Chris | def entries(path=nil, identifier=nil) |
| 8445 | 1517:dffacf8a6908 | Chris | entries = scm_entries(path, identifier) |
| 8446 | 1115:433d4f72a19b | Chris | load_entries_changesets(entries) |
| 8447 | entries |
||
| 8448 | 0:513646585e45 | Chris | end |
| 8449 | |||
| 8450 | def branches |
||
| 8451 | scm.branches |
||
| 8452 | end |
||
| 8453 | |||
| 8454 | def tags |
||
| 8455 | scm.tags |
||
| 8456 | end |
||
| 8457 | |||
| 8458 | def default_branch |
||
| 8459 | 507:0c939c159af4 | Chris | nil |
| 8460 | 0:513646585e45 | Chris | end |
| 8461 | 441:cbce1fd3b1b7 | Chris | |
| 8462 | 0:513646585e45 | Chris | def properties(path, identifier=nil) |
| 8463 | scm.properties(path, identifier) |
||
| 8464 | end |
||
| 8465 | 441:cbce1fd3b1b7 | Chris | |
| 8466 | 0:513646585e45 | Chris | def cat(path, identifier=nil) |
| 8467 | scm.cat(path, identifier) |
||
| 8468 | end |
||
| 8469 | 441:cbce1fd3b1b7 | Chris | |
| 8470 | 0:513646585e45 | Chris | def diff(path, rev, rev_to) |
| 8471 | scm.diff(path, rev, rev_to) |
||
| 8472 | end |
||
| 8473 | 119:8661b858af72 | Chris | |
| 8474 | def diff_format_revisions(cs, cs_to, sep=':') |
||
| 8475 | text = "" |
||
| 8476 | text << cs_to.format_identifier + sep if cs_to |
||
| 8477 | text << cs.format_identifier if cs |
||
| 8478 | text |
||
| 8479 | end |
||
| 8480 | |||
| 8481 | 0:513646585e45 | Chris | # Returns a path relative to the url of the repository |
| 8482 | def relative_path(path) |
||
| 8483 | path |
||
| 8484 | end |
||
| 8485 | 128:07fa8a8b56a8 | Chris | |
| 8486 | 0:513646585e45 | Chris | # Finds and returns a revision with a number or the beginning of a hash |
| 8487 | def find_changeset_by_name(name) |
||
| 8488 | 128:07fa8a8b56a8 | Chris | return nil if name.blank? |
| 8489 | 1115:433d4f72a19b | Chris | s = name.to_s |
| 8490 | 1464:261b3d9a4903 | Chris | if s.match(/^\d*$/) |
| 8491 | changesets.where("revision = ?", s).first
|
||
| 8492 | else |
||
| 8493 | changesets.where("revision LIKE ?", s + '%').first
|
||
| 8494 | end |
||
| 8495 | 0:513646585e45 | Chris | end |
| 8496 | 128:07fa8a8b56a8 | Chris | |
| 8497 | 0:513646585e45 | Chris | def latest_changeset |
| 8498 | 1464:261b3d9a4903 | Chris | @latest_changeset ||= changesets.first |
| 8499 | 0:513646585e45 | Chris | end |
| 8500 | |||
| 8501 | # Returns the latest changesets for +path+ |
||
| 8502 | # Default behaviour is to search in cached changesets |
||
| 8503 | def latest_changesets(path, rev, limit=10) |
||
| 8504 | if path.blank? |
||
| 8505 | 1464:261b3d9a4903 | Chris | changesets. |
| 8506 | reorder("#{Changeset.table_name}.committed_on DESC, #{Changeset.table_name}.id DESC").
|
||
| 8507 | limit(limit). |
||
| 8508 | preload(:user). |
||
| 8509 | all |
||
| 8510 | 0:513646585e45 | Chris | else |
| 8511 | 1464:261b3d9a4903 | Chris | filechanges. |
| 8512 | where("path = ?", path.with_leading_slash).
|
||
| 8513 | reorder("#{Changeset.table_name}.committed_on DESC, #{Changeset.table_name}.id DESC").
|
||
| 8514 | limit(limit). |
||
| 8515 | preload(:changeset => :user). |
||
| 8516 | collect(&:changeset) |
||
| 8517 | 0:513646585e45 | Chris | end |
| 8518 | end |
||
| 8519 | 441:cbce1fd3b1b7 | Chris | |
| 8520 | 0:513646585e45 | Chris | def scan_changesets_for_issue_ids |
| 8521 | self.changesets.each(&:scan_comment_for_issue_ids) |
||
| 8522 | end |
||
| 8523 | |||
| 8524 | # Returns an array of committers usernames and associated user_id |
||
| 8525 | def committers |
||
| 8526 | 441:cbce1fd3b1b7 | Chris | @committers ||= Changeset.connection.select_rows( |
| 8527 | "SELECT DISTINCT committer, user_id FROM #{Changeset.table_name} WHERE repository_id = #{id}")
|
||
| 8528 | 0:513646585e45 | Chris | end |
| 8529 | 441:cbce1fd3b1b7 | Chris | |
| 8530 | 0:513646585e45 | Chris | # Maps committers username to a user ids |
| 8531 | def committer_ids=(h) |
||
| 8532 | if h.is_a?(Hash) |
||
| 8533 | committers.each do |committer, user_id| |
||
| 8534 | new_user_id = h[committer] |
||
| 8535 | if new_user_id && (new_user_id.to_i != user_id.to_i) |
||
| 8536 | new_user_id = (new_user_id.to_i > 0 ? new_user_id.to_i : nil) |
||
| 8537 | 1517:dffacf8a6908 | Chris | Changeset.where(["repository_id = ? AND committer = ?", id, committer]). |
| 8538 | update_all("user_id = #{new_user_id.nil? ? 'NULL' : new_user_id}")
|
||
| 8539 | 0:513646585e45 | Chris | end |
| 8540 | end |
||
| 8541 | 441:cbce1fd3b1b7 | Chris | @committers = nil |
| 8542 | 0:513646585e45 | Chris | @found_committer_users = nil |
| 8543 | true |
||
| 8544 | else |
||
| 8545 | false |
||
| 8546 | end |
||
| 8547 | end |
||
| 8548 | 441:cbce1fd3b1b7 | Chris | |
| 8549 | 0:513646585e45 | Chris | # Returns the Redmine User corresponding to the given +committer+ |
| 8550 | # It will return nil if the committer is not yet mapped and if no User |
||
| 8551 | # with the same username or email was found |
||
| 8552 | def find_committer_user(committer) |
||
| 8553 | unless committer.blank? |
||
| 8554 | @found_committer_users ||= {}
|
||
| 8555 | return @found_committer_users[committer] if @found_committer_users.has_key?(committer) |
||
| 8556 | 441:cbce1fd3b1b7 | Chris | |
| 8557 | 0:513646585e45 | Chris | user = nil |
| 8558 | 1464:261b3d9a4903 | Chris | c = changesets.where(:committer => committer).includes(:user).first |
| 8559 | 0:513646585e45 | Chris | if c && c.user |
| 8560 | user = c.user |
||
| 8561 | elsif committer.strip =~ /^([^<]+)(<(.*)>)?$/ |
||
| 8562 | username, email = $1.strip, $3 |
||
| 8563 | u = User.find_by_login(username) |
||
| 8564 | 1532:a0460a3d154f | Chris | if u.nil? |
| 8565 | if email.blank? |
||
| 8566 | if username.strip =~ /^([^ ]+) ([^ ]+)$/ |
||
| 8567 | first, last = $1, $2 |
||
| 8568 | 1533:59e13100ea95 | Chris | uu = User.where(:firstname => first, :lastname => last) |
| 8569 | if uu.length == 1 |
||
| 8570 | u = uu.first |
||
| 8571 | else |
||
| 8572 | logger.warn "find_committer_user: found more than one (#{uu.length}) results for user named #{username}, ignoring"
|
||
| 8573 | end |
||
| 8574 | 1532:a0460a3d154f | Chris | end |
| 8575 | else |
||
| 8576 | u = User.find_by_mail(email) |
||
| 8577 | end |
||
| 8578 | end |
||
| 8579 | 0:513646585e45 | Chris | user = u |
| 8580 | end |
||
| 8581 | @found_committer_users[committer] = user |
||
| 8582 | user |
||
| 8583 | end |
||
| 8584 | end |
||
| 8585 | 245:051f544170fe | Chris | |
| 8586 | def repo_log_encoding |
||
| 8587 | encoding = log_encoding.to_s.strip |
||
| 8588 | encoding.blank? ? 'UTF-8' : encoding |
||
| 8589 | end |
||
| 8590 | |||
| 8591 | 0:513646585e45 | Chris | # Fetches new changesets for all repositories of active projects |
| 8592 | # Can be called periodically by an external script |
||
| 8593 | # eg. ruby script/runner "Repository.fetch_changesets" |
||
| 8594 | def self.fetch_changesets |
||
| 8595 | 1115:433d4f72a19b | Chris | Project.active.has_module(:repository).all.each do |project| |
| 8596 | project.repositories.each do |repository| |
||
| 8597 | 245:051f544170fe | Chris | begin |
| 8598 | 1115:433d4f72a19b | Chris | repository.fetch_changesets |
| 8599 | 245:051f544170fe | Chris | rescue Redmine::Scm::Adapters::CommandFailed => e |
| 8600 | logger.error "scm: error during fetching changesets: #{e.message}"
|
||
| 8601 | end |
||
| 8602 | 0:513646585e45 | Chris | end |
| 8603 | end |
||
| 8604 | end |
||
| 8605 | 245:051f544170fe | Chris | |
| 8606 | 0:513646585e45 | Chris | # scan changeset comments to find related and fixed issues for all repositories |
| 8607 | def self.scan_changesets_for_issue_ids |
||
| 8608 | 1464:261b3d9a4903 | Chris | all.each(&:scan_changesets_for_issue_ids) |
| 8609 | 0:513646585e45 | Chris | end |
| 8610 | |||
| 8611 | def self.scm_name |
||
| 8612 | 'Abstract' |
||
| 8613 | end |
||
| 8614 | 441:cbce1fd3b1b7 | Chris | |
| 8615 | 0:513646585e45 | Chris | def self.available_scm |
| 8616 | subclasses.collect {|klass| [klass.scm_name, klass.name]}
|
||
| 8617 | end |
||
| 8618 | 245:051f544170fe | Chris | |
| 8619 | 0:513646585e45 | Chris | def self.factory(klass_name, *args) |
| 8620 | klass = "Repository::#{klass_name}".constantize
|
||
| 8621 | klass.new(*args) |
||
| 8622 | rescue |
||
| 8623 | nil |
||
| 8624 | end |
||
| 8625 | 245:051f544170fe | Chris | |
| 8626 | 437:102056ec2de9 | chris | def clear_cache |
| 8627 | clear_changesets |
||
| 8628 | end |
||
| 8629 | 443:350acce374a2 | Chris | |
| 8630 | 245:051f544170fe | Chris | def self.scm_adapter_class |
| 8631 | nil |
||
| 8632 | end |
||
| 8633 | |||
| 8634 | def self.scm_command |
||
| 8635 | ret = "" |
||
| 8636 | begin |
||
| 8637 | ret = self.scm_adapter_class.client_command if self.scm_adapter_class |
||
| 8638 | 441:cbce1fd3b1b7 | Chris | rescue Exception => e |
| 8639 | 245:051f544170fe | Chris | logger.error "scm: error during get command: #{e.message}"
|
| 8640 | end |
||
| 8641 | ret |
||
| 8642 | end |
||
| 8643 | |||
| 8644 | def self.scm_version_string |
||
| 8645 | ret = "" |
||
| 8646 | begin |
||
| 8647 | ret = self.scm_adapter_class.client_version_string if self.scm_adapter_class |
||
| 8648 | 441:cbce1fd3b1b7 | Chris | rescue Exception => e |
| 8649 | 245:051f544170fe | Chris | logger.error "scm: error during get version string: #{e.message}"
|
| 8650 | end |
||
| 8651 | ret |
||
| 8652 | end |
||
| 8653 | |||
| 8654 | def self.scm_available |
||
| 8655 | ret = false |
||
| 8656 | begin |
||
| 8657 | 441:cbce1fd3b1b7 | Chris | ret = self.scm_adapter_class.client_available if self.scm_adapter_class |
| 8658 | rescue Exception => e |
||
| 8659 | 245:051f544170fe | Chris | logger.error "scm: error during get scm available: #{e.message}"
|
| 8660 | end |
||
| 8661 | ret |
||
| 8662 | end |
||
| 8663 | |||
| 8664 | 1115:433d4f72a19b | Chris | def set_as_default? |
| 8665 | 1464:261b3d9a4903 | Chris | new_record? && project && Repository.where(:project_id => project.id).empty? |
| 8666 | 1115:433d4f72a19b | Chris | end |
| 8667 | |||
| 8668 | protected |
||
| 8669 | |||
| 8670 | def check_default |
||
| 8671 | if !is_default? && set_as_default? |
||
| 8672 | self.is_default = true |
||
| 8673 | end |
||
| 8674 | if is_default? && is_default_changed? |
||
| 8675 | 1517:dffacf8a6908 | Chris | Repository.where(["project_id = ?", project_id]).update_all(["is_default = ?", false]) |
| 8676 | 1115:433d4f72a19b | Chris | end |
| 8677 | end |
||
| 8678 | |||
| 8679 | def load_entries_changesets(entries) |
||
| 8680 | if entries |
||
| 8681 | entries.each do |entry| |
||
| 8682 | if entry.lastrev && entry.lastrev.identifier |
||
| 8683 | entry.changeset = find_changeset_by_name(entry.lastrev.identifier) |
||
| 8684 | end |
||
| 8685 | end |
||
| 8686 | end |
||
| 8687 | end |
||
| 8688 | |||
| 8689 | 0:513646585e45 | Chris | private |
| 8690 | 245:051f544170fe | Chris | |
| 8691 | 1115:433d4f72a19b | Chris | # Deletes repository data |
| 8692 | 0:513646585e45 | Chris | def clear_changesets |
| 8693 | 1115:433d4f72a19b | Chris | cs = Changeset.table_name |
| 8694 | ch = Change.table_name |
||
| 8695 | ci = "#{table_name_prefix}changesets_issues#{table_name_suffix}"
|
||
| 8696 | cp = "#{table_name_prefix}changeset_parents#{table_name_suffix}"
|
||
| 8697 | |||
| 8698 | 0:513646585e45 | Chris | connection.delete("DELETE FROM #{ch} WHERE #{ch}.changeset_id IN (SELECT #{cs}.id FROM #{cs} WHERE #{cs}.repository_id = #{id})")
|
| 8699 | connection.delete("DELETE FROM #{ci} WHERE #{ci}.changeset_id IN (SELECT #{cs}.id FROM #{cs} WHERE #{cs}.repository_id = #{id})")
|
||
| 8700 | 1115:433d4f72a19b | Chris | connection.delete("DELETE FROM #{cp} WHERE #{cp}.changeset_id IN (SELECT #{cs}.id FROM #{cs} WHERE #{cs}.repository_id = #{id})")
|
| 8701 | 0:513646585e45 | Chris | connection.delete("DELETE FROM #{cs} WHERE #{cs}.repository_id = #{id}")
|
| 8702 | 1115:433d4f72a19b | Chris | clear_extra_info_of_changesets |
| 8703 | end |
||
| 8704 | |||
| 8705 | def clear_extra_info_of_changesets |
||
| 8706 | 0:513646585e45 | Chris | end |
| 8707 | end |
||
| 8708 | 441:cbce1fd3b1b7 | Chris | # Redmine - project management software |
| 8709 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 8710 | 0:513646585e45 | Chris | # |
| 8711 | # This program is free software; you can redistribute it and/or |
||
| 8712 | # modify it under the terms of the GNU General Public License |
||
| 8713 | # as published by the Free Software Foundation; either version 2 |
||
| 8714 | # of the License, or (at your option) any later version. |
||
| 8715 | 441:cbce1fd3b1b7 | Chris | # |
| 8716 | 0:513646585e45 | Chris | # This program is distributed in the hope that it will be useful, |
| 8717 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 8718 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 8719 | # GNU General Public License for more details. |
||
| 8720 | 441:cbce1fd3b1b7 | Chris | # |
| 8721 | 0:513646585e45 | Chris | # You should have received a copy of the GNU General Public License |
| 8722 | # along with this program; if not, write to the Free Software |
||
| 8723 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 8724 | |||
| 8725 | 1136:51d7f3e06556 | chris | require_dependency 'redmine/scm/adapters/bazaar_adapter' |
| 8726 | 0:513646585e45 | Chris | |
| 8727 | class Repository::Bazaar < Repository |
||
| 8728 | attr_protected :root_url |
||
| 8729 | 245:051f544170fe | Chris | validates_presence_of :url, :log_encoding |
| 8730 | 0:513646585e45 | Chris | |
| 8731 | 1115:433d4f72a19b | Chris | def self.human_attribute_name(attribute_key_name, *args) |
| 8732 | attr_name = attribute_key_name.to_s |
||
| 8733 | 441:cbce1fd3b1b7 | Chris | if attr_name == "url" |
| 8734 | attr_name = "path_to_repository" |
||
| 8735 | end |
||
| 8736 | 1115:433d4f72a19b | Chris | super(attr_name, *args) |
| 8737 | 245:051f544170fe | Chris | end |
| 8738 | |||
| 8739 | def self.scm_adapter_class |
||
| 8740 | 0:513646585e45 | Chris | Redmine::Scm::Adapters::BazaarAdapter |
| 8741 | end |
||
| 8742 | 245:051f544170fe | Chris | |
| 8743 | 0:513646585e45 | Chris | def self.scm_name |
| 8744 | 'Bazaar' |
||
| 8745 | end |
||
| 8746 | 245:051f544170fe | Chris | |
| 8747 | 1115:433d4f72a19b | Chris | def entry(path=nil, identifier=nil) |
| 8748 | scm.bzr_path_encodig = log_encoding |
||
| 8749 | scm.entry(path, identifier) |
||
| 8750 | end |
||
| 8751 | |||
| 8752 | def cat(path, identifier=nil) |
||
| 8753 | scm.bzr_path_encodig = log_encoding |
||
| 8754 | scm.cat(path, identifier) |
||
| 8755 | end |
||
| 8756 | |||
| 8757 | def annotate(path, identifier=nil) |
||
| 8758 | scm.bzr_path_encodig = log_encoding |
||
| 8759 | scm.annotate(path, identifier) |
||
| 8760 | end |
||
| 8761 | |||
| 8762 | def diff(path, rev, rev_to) |
||
| 8763 | scm.bzr_path_encodig = log_encoding |
||
| 8764 | scm.diff(path, rev, rev_to) |
||
| 8765 | end |
||
| 8766 | |||
| 8767 | 1517:dffacf8a6908 | Chris | def scm_entries(path=nil, identifier=nil) |
| 8768 | 1115:433d4f72a19b | Chris | scm.bzr_path_encodig = log_encoding |
| 8769 | 0:513646585e45 | Chris | entries = scm.entries(path, identifier) |
| 8770 | if entries |
||
| 8771 | entries.each do |e| |
||
| 8772 | next if e.lastrev.revision.blank? |
||
| 8773 | # Set the filesize unless browsing a specific revision |
||
| 8774 | if identifier.nil? && e.is_file? |
||
| 8775 | full_path = File.join(root_url, e.path) |
||
| 8776 | e.size = File.stat(full_path).size if File.file?(full_path) |
||
| 8777 | end |
||
| 8778 | 1464:261b3d9a4903 | Chris | c = Change. |
| 8779 | includes(:changeset). |
||
| 8780 | where("#{Change.table_name}.revision = ? and #{Changeset.table_name}.repository_id = ?", e.lastrev.revision, id).
|
||
| 8781 | order("#{Changeset.table_name}.revision DESC").
|
||
| 8782 | first |
||
| 8783 | 0:513646585e45 | Chris | if c |
| 8784 | e.lastrev.identifier = c.changeset.revision |
||
| 8785 | 441:cbce1fd3b1b7 | Chris | e.lastrev.name = c.changeset.revision |
| 8786 | e.lastrev.author = c.changeset.committer |
||
| 8787 | 0:513646585e45 | Chris | end |
| 8788 | end |
||
| 8789 | end |
||
| 8790 | 1115:433d4f72a19b | Chris | entries |
| 8791 | 0:513646585e45 | Chris | end |
| 8792 | 1517:dffacf8a6908 | Chris | protected :scm_entries |
| 8793 | 441:cbce1fd3b1b7 | Chris | |
| 8794 | 0:513646585e45 | Chris | def fetch_changesets |
| 8795 | 1115:433d4f72a19b | Chris | scm.bzr_path_encodig = log_encoding |
| 8796 | 0:513646585e45 | Chris | scm_info = scm.info |
| 8797 | if scm_info |
||
| 8798 | # latest revision found in database |
||
| 8799 | db_revision = latest_changeset ? latest_changeset.revision.to_i : 0 |
||
| 8800 | # latest revision in the repository |
||
| 8801 | scm_revision = scm_info.lastrev.identifier.to_i |
||
| 8802 | if db_revision < scm_revision |
||
| 8803 | logger.debug "Fetching changesets for repository #{url}" if logger && logger.debug?
|
||
| 8804 | identifier_from = db_revision + 1 |
||
| 8805 | while (identifier_from <= scm_revision) |
||
| 8806 | # loads changesets by batches of 200 |
||
| 8807 | identifier_to = [identifier_from + 199, scm_revision].min |
||
| 8808 | 1115:433d4f72a19b | Chris | revisions = scm.revisions('', identifier_to, identifier_from)
|
| 8809 | 0:513646585e45 | Chris | transaction do |
| 8810 | revisions.reverse_each do |revision| |
||
| 8811 | 441:cbce1fd3b1b7 | Chris | changeset = Changeset.create(:repository => self, |
| 8812 | :revision => revision.identifier, |
||
| 8813 | :committer => revision.author, |
||
| 8814 | 0:513646585e45 | Chris | :committed_on => revision.time, |
| 8815 | 441:cbce1fd3b1b7 | Chris | :scmid => revision.scmid, |
| 8816 | :comments => revision.message) |
||
| 8817 | |||
| 8818 | 0:513646585e45 | Chris | revision.paths.each do |change| |
| 8819 | Change.create(:changeset => changeset, |
||
| 8820 | 441:cbce1fd3b1b7 | Chris | :action => change[:action], |
| 8821 | :path => change[:path], |
||
| 8822 | :revision => change[:revision]) |
||
| 8823 | 0:513646585e45 | Chris | end |
| 8824 | end |
||
| 8825 | end unless revisions.nil? |
||
| 8826 | identifier_from = identifier_to + 1 |
||
| 8827 | end |
||
| 8828 | end |
||
| 8829 | end |
||
| 8830 | end |
||
| 8831 | end |
||
| 8832 | 441:cbce1fd3b1b7 | Chris | # Redmine - project management software |
| 8833 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 8834 | 0:513646585e45 | Chris | # |
| 8835 | # This program is free software; you can redistribute it and/or |
||
| 8836 | # modify it under the terms of the GNU General Public License |
||
| 8837 | # as published by the Free Software Foundation; either version 2 |
||
| 8838 | # of the License, or (at your option) any later version. |
||
| 8839 | 441:cbce1fd3b1b7 | Chris | # |
| 8840 | 0:513646585e45 | Chris | # This program is distributed in the hope that it will be useful, |
| 8841 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 8842 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 8843 | # GNU General Public License for more details. |
||
| 8844 | 441:cbce1fd3b1b7 | Chris | # |
| 8845 | 0:513646585e45 | Chris | # You should have received a copy of the GNU General Public License |
| 8846 | # along with this program; if not, write to the Free Software |
||
| 8847 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 8848 | |||
| 8849 | 1136:51d7f3e06556 | chris | require_dependency 'redmine/scm/adapters/cvs_adapter' |
| 8850 | 0:513646585e45 | Chris | require 'digest/sha1' |
| 8851 | |||
| 8852 | class Repository::Cvs < Repository |
||
| 8853 | 245:051f544170fe | Chris | validates_presence_of :url, :root_url, :log_encoding |
| 8854 | 0:513646585e45 | Chris | |
| 8855 | 1115:433d4f72a19b | Chris | safe_attributes 'root_url', |
| 8856 | :if => lambda {|repository, user| repository.new_record?}
|
||
| 8857 | |||
| 8858 | def self.human_attribute_name(attribute_key_name, *args) |
||
| 8859 | attr_name = attribute_key_name.to_s |
||
| 8860 | 441:cbce1fd3b1b7 | Chris | if attr_name == "root_url" |
| 8861 | attr_name = "cvsroot" |
||
| 8862 | elsif attr_name == "url" |
||
| 8863 | attr_name = "cvs_module" |
||
| 8864 | end |
||
| 8865 | 1115:433d4f72a19b | Chris | super(attr_name, *args) |
| 8866 | 245:051f544170fe | Chris | end |
| 8867 | |||
| 8868 | def self.scm_adapter_class |
||
| 8869 | 0:513646585e45 | Chris | Redmine::Scm::Adapters::CvsAdapter |
| 8870 | end |
||
| 8871 | 245:051f544170fe | Chris | |
| 8872 | 0:513646585e45 | Chris | def self.scm_name |
| 8873 | 'CVS' |
||
| 8874 | end |
||
| 8875 | 245:051f544170fe | Chris | |
| 8876 | 0:513646585e45 | Chris | def entry(path=nil, identifier=nil) |
| 8877 | rev = identifier.nil? ? nil : changesets.find_by_revision(identifier) |
||
| 8878 | scm.entry(path, rev.nil? ? nil : rev.committed_on) |
||
| 8879 | end |
||
| 8880 | 441:cbce1fd3b1b7 | Chris | |
| 8881 | 1517:dffacf8a6908 | Chris | def scm_entries(path=nil, identifier=nil) |
| 8882 | 441:cbce1fd3b1b7 | Chris | rev = nil |
| 8883 | if ! identifier.nil? |
||
| 8884 | rev = changesets.find_by_revision(identifier) |
||
| 8885 | return nil if rev.nil? |
||
| 8886 | end |
||
| 8887 | 0:513646585e45 | Chris | entries = scm.entries(path, rev.nil? ? nil : rev.committed_on) |
| 8888 | if entries |
||
| 8889 | entries.each() do |entry| |
||
| 8890 | 441:cbce1fd3b1b7 | Chris | if ( ! entry.lastrev.nil? ) && ( ! entry.lastrev.revision.nil? ) |
| 8891 | 1115:433d4f72a19b | Chris | change = filechanges.find_by_revision_and_path( |
| 8892 | 441:cbce1fd3b1b7 | Chris | entry.lastrev.revision, |
| 8893 | scm.with_leading_slash(entry.path) ) |
||
| 8894 | 0:513646585e45 | Chris | if change |
| 8895 | 441:cbce1fd3b1b7 | Chris | entry.lastrev.identifier = change.changeset.revision |
| 8896 | entry.lastrev.revision = change.changeset.revision |
||
| 8897 | entry.lastrev.author = change.changeset.committer |
||
| 8898 | # entry.lastrev.branch = change.branch |
||
| 8899 | 0:513646585e45 | Chris | end |
| 8900 | end |
||
| 8901 | end |
||
| 8902 | end |
||
| 8903 | entries |
||
| 8904 | end |
||
| 8905 | 1517:dffacf8a6908 | Chris | protected :scm_entries |
| 8906 | 441:cbce1fd3b1b7 | Chris | |
| 8907 | 0:513646585e45 | Chris | def cat(path, identifier=nil) |
| 8908 | 441:cbce1fd3b1b7 | Chris | rev = nil |
| 8909 | if ! identifier.nil? |
||
| 8910 | rev = changesets.find_by_revision(identifier) |
||
| 8911 | return nil if rev.nil? |
||
| 8912 | end |
||
| 8913 | 0:513646585e45 | Chris | scm.cat(path, rev.nil? ? nil : rev.committed_on) |
| 8914 | end |
||
| 8915 | 441:cbce1fd3b1b7 | Chris | |
| 8916 | def annotate(path, identifier=nil) |
||
| 8917 | rev = nil |
||
| 8918 | if ! identifier.nil? |
||
| 8919 | rev = changesets.find_by_revision(identifier) |
||
| 8920 | return nil if rev.nil? |
||
| 8921 | end |
||
| 8922 | scm.annotate(path, rev.nil? ? nil : rev.committed_on) |
||
| 8923 | end |
||
| 8924 | |||
| 8925 | 0:513646585e45 | Chris | def diff(path, rev, rev_to) |
| 8926 | 441:cbce1fd3b1b7 | Chris | # convert rev to revision. CVS can't handle changesets here |
| 8927 | 0:513646585e45 | Chris | diff=[] |
| 8928 | 441:cbce1fd3b1b7 | Chris | changeset_from = changesets.find_by_revision(rev) |
| 8929 | if rev_to.to_i > 0 |
||
| 8930 | changeset_to = changesets.find_by_revision(rev_to) |
||
| 8931 | 0:513646585e45 | Chris | end |
| 8932 | 1115:433d4f72a19b | Chris | changeset_from.filechanges.each() do |change_from| |
| 8933 | 441:cbce1fd3b1b7 | Chris | revision_from = nil |
| 8934 | revision_to = nil |
||
| 8935 | if path.nil? || (change_from.path.starts_with? scm.with_leading_slash(path)) |
||
| 8936 | revision_from = change_from.revision |
||
| 8937 | end |
||
| 8938 | 0:513646585e45 | Chris | if revision_from |
| 8939 | if changeset_to |
||
| 8940 | 1115:433d4f72a19b | Chris | changeset_to.filechanges.each() do |change_to| |
| 8941 | 441:cbce1fd3b1b7 | Chris | revision_to = change_to.revision if change_to.path == change_from.path |
| 8942 | 0:513646585e45 | Chris | end |
| 8943 | end |
||
| 8944 | unless revision_to |
||
| 8945 | 441:cbce1fd3b1b7 | Chris | revision_to = scm.get_previous_revision(revision_from) |
| 8946 | 0:513646585e45 | Chris | end |
| 8947 | file_diff = scm.diff(change_from.path, revision_from, revision_to) |
||
| 8948 | diff = diff + file_diff unless file_diff.nil? |
||
| 8949 | end |
||
| 8950 | end |
||
| 8951 | return diff |
||
| 8952 | end |
||
| 8953 | 441:cbce1fd3b1b7 | Chris | |
| 8954 | 0:513646585e45 | Chris | def fetch_changesets |
| 8955 | # some nifty bits to introduce a commit-id with cvs |
||
| 8956 | 441:cbce1fd3b1b7 | Chris | # natively cvs doesn't provide any kind of changesets, |
| 8957 | # there is only a revision per file. |
||
| 8958 | 0:513646585e45 | Chris | # we now take a guess using the author, the commitlog and the commit-date. |
| 8959 | 441:cbce1fd3b1b7 | Chris | |
| 8960 | # last one is the next step to take. the commit-date is not equal for all |
||
| 8961 | 0:513646585e45 | Chris | # commits in one changeset. cvs update the commit-date when the *,v file was touched. so |
| 8962 | # we use a small delta here, to merge all changes belonging to _one_ changeset |
||
| 8963 | 441:cbce1fd3b1b7 | Chris | time_delta = 10.seconds |
| 8964 | 0:513646585e45 | Chris | fetch_since = latest_changeset ? latest_changeset.committed_on : nil |
| 8965 | transaction do |
||
| 8966 | tmp_rev_num = 1 |
||
| 8967 | 441:cbce1fd3b1b7 | Chris | scm.revisions('', fetch_since, nil, :log_encoding => repo_log_encoding) do |revision|
|
| 8968 | 0:513646585e45 | Chris | # only add the change to the database, if it doen't exists. the cvs log |
| 8969 | 441:cbce1fd3b1b7 | Chris | # is not exclusive at all. |
| 8970 | 210:0579821a129a | Chris | tmp_time = revision.time.clone |
| 8971 | 1115:433d4f72a19b | Chris | unless filechanges.find_by_path_and_revision( |
| 8972 | 441:cbce1fd3b1b7 | Chris | scm.with_leading_slash(revision.paths[0][:path]), |
| 8973 | revision.paths[0][:revision] |
||
| 8974 | ) |
||
| 8975 | 245:051f544170fe | Chris | cmt = Changeset.normalize_comments(revision.message, repo_log_encoding) |
| 8976 | 441:cbce1fd3b1b7 | Chris | author_utf8 = Changeset.to_utf8(revision.author, repo_log_encoding) |
| 8977 | 1464:261b3d9a4903 | Chris | cs = changesets.where( |
| 8978 | :committed_on => tmp_time - time_delta .. tmp_time + time_delta, |
||
| 8979 | :committer => author_utf8, |
||
| 8980 | :comments => cmt |
||
| 8981 | ).first |
||
| 8982 | 441:cbce1fd3b1b7 | Chris | # create a new changeset.... |
| 8983 | 0:513646585e45 | Chris | unless cs |
| 8984 | # we use a temporaray revision number here (just for inserting) |
||
| 8985 | # later on, we calculate a continous positive number |
||
| 8986 | 210:0579821a129a | Chris | tmp_time2 = tmp_time.clone.gmtime |
| 8987 | 441:cbce1fd3b1b7 | Chris | branch = revision.paths[0][:branch] |
| 8988 | scmid = branch + "-" + tmp_time2.strftime("%Y%m%d-%H%M%S")
|
||
| 8989 | cs = Changeset.create(:repository => self, |
||
| 8990 | :revision => "tmp#{tmp_rev_num}",
|
||
| 8991 | :scmid => scmid, |
||
| 8992 | :committer => revision.author, |
||
| 8993 | 210:0579821a129a | Chris | :committed_on => tmp_time, |
| 8994 | 441:cbce1fd3b1b7 | Chris | :comments => revision.message) |
| 8995 | 0:513646585e45 | Chris | tmp_rev_num += 1 |
| 8996 | end |
||
| 8997 | 441:cbce1fd3b1b7 | Chris | # convert CVS-File-States to internal Action-abbrevations |
| 8998 | # default action is (M)odified |
||
| 8999 | action = "M" |
||
| 9000 | if revision.paths[0][:action] == "Exp" && revision.paths[0][:revision] == "1.1" |
||
| 9001 | action = "A" # add-action always at first revision (= 1.1) |
||
| 9002 | elsif revision.paths[0][:action] == "dead" |
||
| 9003 | action = "D" # dead-state is similar to Delete |
||
| 9004 | 0:513646585e45 | Chris | end |
| 9005 | 441:cbce1fd3b1b7 | Chris | Change.create( |
| 9006 | :changeset => cs, |
||
| 9007 | :action => action, |
||
| 9008 | :path => scm.with_leading_slash(revision.paths[0][:path]), |
||
| 9009 | :revision => revision.paths[0][:revision], |
||
| 9010 | :branch => revision.paths[0][:branch] |
||
| 9011 | ) |
||
| 9012 | 0:513646585e45 | Chris | end |
| 9013 | end |
||
| 9014 | 441:cbce1fd3b1b7 | Chris | |
| 9015 | 0:513646585e45 | Chris | # Renumber new changesets in chronological order |
| 9016 | 1464:261b3d9a4903 | Chris | Changeset. |
| 9017 | order('committed_on ASC, id ASC').
|
||
| 9018 | where("repository_id = ? AND revision LIKE 'tmp%'", id).
|
||
| 9019 | each do |changeset| |
||
| 9020 | 1517:dffacf8a6908 | Chris | changeset.update_attribute :revision, next_revision_number |
| 9021 | 0:513646585e45 | Chris | end |
| 9022 | end # transaction |
||
| 9023 | 210:0579821a129a | Chris | @current_revision_number = nil |
| 9024 | 0:513646585e45 | Chris | end |
| 9025 | 441:cbce1fd3b1b7 | Chris | |
| 9026 | 0:513646585e45 | Chris | private |
| 9027 | 441:cbce1fd3b1b7 | Chris | |
| 9028 | 0:513646585e45 | Chris | # Returns the next revision number to assign to a CVS changeset |
| 9029 | def next_revision_number |
||
| 9030 | # Need to retrieve existing revision numbers to sort them as integers |
||
| 9031 | 210:0579821a129a | Chris | sql = "SELECT revision FROM #{Changeset.table_name} "
|
| 9032 | sql << "WHERE repository_id = #{id} AND revision NOT LIKE 'tmp%'"
|
||
| 9033 | @current_revision_number ||= (connection.select_values(sql).collect(&:to_i).max || 0) |
||
| 9034 | 0:513646585e45 | Chris | @current_revision_number += 1 |
| 9035 | end |
||
| 9036 | end |
||
| 9037 | 441:cbce1fd3b1b7 | Chris | # Redmine - project management software |
| 9038 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 9039 | 0:513646585e45 | Chris | # |
| 9040 | # This program is free software; you can redistribute it and/or |
||
| 9041 | # modify it under the terms of the GNU General Public License |
||
| 9042 | # as published by the Free Software Foundation; either version 2 |
||
| 9043 | # of the License, or (at your option) any later version. |
||
| 9044 | 441:cbce1fd3b1b7 | Chris | # |
| 9045 | 0:513646585e45 | Chris | # This program is distributed in the hope that it will be useful, |
| 9046 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 9047 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 9048 | # GNU General Public License for more details. |
||
| 9049 | 441:cbce1fd3b1b7 | Chris | # |
| 9050 | 0:513646585e45 | Chris | # You should have received a copy of the GNU General Public License |
| 9051 | # along with this program; if not, write to the Free Software |
||
| 9052 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 9053 | |||
| 9054 | 1136:51d7f3e06556 | chris | require_dependency 'redmine/scm/adapters/darcs_adapter' |
| 9055 | 0:513646585e45 | Chris | |
| 9056 | class Repository::Darcs < Repository |
||
| 9057 | 245:051f544170fe | Chris | validates_presence_of :url, :log_encoding |
| 9058 | 0:513646585e45 | Chris | |
| 9059 | 1115:433d4f72a19b | Chris | def self.human_attribute_name(attribute_key_name, *args) |
| 9060 | attr_name = attribute_key_name.to_s |
||
| 9061 | 441:cbce1fd3b1b7 | Chris | if attr_name == "url" |
| 9062 | attr_name = "path_to_repository" |
||
| 9063 | end |
||
| 9064 | 1115:433d4f72a19b | Chris | super(attr_name, *args) |
| 9065 | 245:051f544170fe | Chris | end |
| 9066 | |||
| 9067 | def self.scm_adapter_class |
||
| 9068 | 0:513646585e45 | Chris | Redmine::Scm::Adapters::DarcsAdapter |
| 9069 | end |
||
| 9070 | 245:051f544170fe | Chris | |
| 9071 | 0:513646585e45 | Chris | def self.scm_name |
| 9072 | 'Darcs' |
||
| 9073 | end |
||
| 9074 | 245:051f544170fe | Chris | |
| 9075 | 441:cbce1fd3b1b7 | Chris | def supports_directory_revisions? |
| 9076 | true |
||
| 9077 | end |
||
| 9078 | |||
| 9079 | 0:513646585e45 | Chris | def entry(path=nil, identifier=nil) |
| 9080 | patch = identifier.nil? ? nil : changesets.find_by_revision(identifier) |
||
| 9081 | scm.entry(path, patch.nil? ? nil : patch.scmid) |
||
| 9082 | end |
||
| 9083 | 441:cbce1fd3b1b7 | Chris | |
| 9084 | 1517:dffacf8a6908 | Chris | def scm_entries(path=nil, identifier=nil) |
| 9085 | 441:cbce1fd3b1b7 | Chris | patch = nil |
| 9086 | if ! identifier.nil? |
||
| 9087 | patch = changesets.find_by_revision(identifier) |
||
| 9088 | return nil if patch.nil? |
||
| 9089 | end |
||
| 9090 | 0:513646585e45 | Chris | entries = scm.entries(path, patch.nil? ? nil : patch.scmid) |
| 9091 | if entries |
||
| 9092 | entries.each do |entry| |
||
| 9093 | # Search the DB for the entry's last change |
||
| 9094 | 441:cbce1fd3b1b7 | Chris | if entry.lastrev && !entry.lastrev.scmid.blank? |
| 9095 | changeset = changesets.find_by_scmid(entry.lastrev.scmid) |
||
| 9096 | end |
||
| 9097 | 0:513646585e45 | Chris | if changeset |
| 9098 | entry.lastrev.identifier = changeset.revision |
||
| 9099 | 441:cbce1fd3b1b7 | Chris | entry.lastrev.name = changeset.revision |
| 9100 | entry.lastrev.time = changeset.committed_on |
||
| 9101 | entry.lastrev.author = changeset.committer |
||
| 9102 | 0:513646585e45 | Chris | end |
| 9103 | end |
||
| 9104 | end |
||
| 9105 | entries |
||
| 9106 | end |
||
| 9107 | 1517:dffacf8a6908 | Chris | protected :scm_entries |
| 9108 | 441:cbce1fd3b1b7 | Chris | |
| 9109 | 0:513646585e45 | Chris | def cat(path, identifier=nil) |
| 9110 | patch = identifier.nil? ? nil : changesets.find_by_revision(identifier.to_s) |
||
| 9111 | scm.cat(path, patch.nil? ? nil : patch.scmid) |
||
| 9112 | end |
||
| 9113 | 441:cbce1fd3b1b7 | Chris | |
| 9114 | 0:513646585e45 | Chris | def diff(path, rev, rev_to) |
| 9115 | patch_from = changesets.find_by_revision(rev) |
||
| 9116 | return nil if patch_from.nil? |
||
| 9117 | patch_to = changesets.find_by_revision(rev_to) if rev_to |
||
| 9118 | if path.blank? |
||
| 9119 | 1115:433d4f72a19b | Chris | path = patch_from.filechanges.collect{|change| change.path}.join(' ')
|
| 9120 | 0:513646585e45 | Chris | end |
| 9121 | patch_from ? scm.diff(path, patch_from.scmid, patch_to ? patch_to.scmid : nil) : nil |
||
| 9122 | end |
||
| 9123 | 441:cbce1fd3b1b7 | Chris | |
| 9124 | 0:513646585e45 | Chris | def fetch_changesets |
| 9125 | scm_info = scm.info |
||
| 9126 | if scm_info |
||
| 9127 | db_last_id = latest_changeset ? latest_changeset.scmid : nil |
||
| 9128 | 441:cbce1fd3b1b7 | Chris | next_rev = latest_changeset ? latest_changeset.revision.to_i + 1 : 1 |
| 9129 | 0:513646585e45 | Chris | # latest revision in the repository |
| 9130 | 441:cbce1fd3b1b7 | Chris | scm_revision = scm_info.lastrev.scmid |
| 9131 | 0:513646585e45 | Chris | unless changesets.find_by_scmid(scm_revision) |
| 9132 | revisions = scm.revisions('', db_last_id, nil, :with_path => true)
|
||
| 9133 | transaction do |
||
| 9134 | revisions.reverse_each do |revision| |
||
| 9135 | 441:cbce1fd3b1b7 | Chris | changeset = Changeset.create(:repository => self, |
| 9136 | :revision => next_rev, |
||
| 9137 | :scmid => revision.scmid, |
||
| 9138 | :committer => revision.author, |
||
| 9139 | 0:513646585e45 | Chris | :committed_on => revision.time, |
| 9140 | 441:cbce1fd3b1b7 | Chris | :comments => revision.message) |
| 9141 | 0:513646585e45 | Chris | revision.paths.each do |change| |
| 9142 | changeset.create_change(change) |
||
| 9143 | end |
||
| 9144 | next_rev += 1 |
||
| 9145 | end if revisions |
||
| 9146 | end |
||
| 9147 | end |
||
| 9148 | end |
||
| 9149 | end |
||
| 9150 | end |
||
| 9151 | 441:cbce1fd3b1b7 | Chris | # Redmine - project management software |
| 9152 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 9153 | 0:513646585e45 | Chris | # |
| 9154 | # FileSystem adapter |
||
| 9155 | # File written by Paul Rivier, at Demotera. |
||
| 9156 | # |
||
| 9157 | # This program is free software; you can redistribute it and/or |
||
| 9158 | # modify it under the terms of the GNU General Public License |
||
| 9159 | # as published by the Free Software Foundation; either version 2 |
||
| 9160 | # of the License, or (at your option) any later version. |
||
| 9161 | 441:cbce1fd3b1b7 | Chris | # |
| 9162 | 0:513646585e45 | Chris | # This program is distributed in the hope that it will be useful, |
| 9163 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 9164 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 9165 | # GNU General Public License for more details. |
||
| 9166 | 441:cbce1fd3b1b7 | Chris | # |
| 9167 | 0:513646585e45 | Chris | # You should have received a copy of the GNU General Public License |
| 9168 | # along with this program; if not, write to the Free Software |
||
| 9169 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 9170 | |||
| 9171 | 1136:51d7f3e06556 | chris | require_dependency 'redmine/scm/adapters/filesystem_adapter' |
| 9172 | 0:513646585e45 | Chris | |
| 9173 | class Repository::Filesystem < Repository |
||
| 9174 | attr_protected :root_url |
||
| 9175 | validates_presence_of :url |
||
| 9176 | |||
| 9177 | 1115:433d4f72a19b | Chris | def self.human_attribute_name(attribute_key_name, *args) |
| 9178 | attr_name = attribute_key_name.to_s |
||
| 9179 | 441:cbce1fd3b1b7 | Chris | if attr_name == "url" |
| 9180 | attr_name = "root_directory" |
||
| 9181 | end |
||
| 9182 | 1115:433d4f72a19b | Chris | super(attr_name, *args) |
| 9183 | 245:051f544170fe | Chris | end |
| 9184 | |||
| 9185 | def self.scm_adapter_class |
||
| 9186 | 0:513646585e45 | Chris | Redmine::Scm::Adapters::FilesystemAdapter |
| 9187 | end |
||
| 9188 | 245:051f544170fe | Chris | |
| 9189 | 0:513646585e45 | Chris | def self.scm_name |
| 9190 | 'Filesystem' |
||
| 9191 | end |
||
| 9192 | 245:051f544170fe | Chris | |
| 9193 | 441:cbce1fd3b1b7 | Chris | def supports_all_revisions? |
| 9194 | false |
||
| 9195 | end |
||
| 9196 | |||
| 9197 | 0:513646585e45 | Chris | def fetch_changesets |
| 9198 | nil |
||
| 9199 | end |
||
| 9200 | end |
||
| 9201 | 441:cbce1fd3b1b7 | Chris | # Redmine - project management software |
| 9202 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 9203 | 0:513646585e45 | Chris | # Copyright (C) 2007 Patrick Aljord patcito@ŋmail.com |
| 9204 | 441:cbce1fd3b1b7 | Chris | # |
| 9205 | 0:513646585e45 | Chris | # This program is free software; you can redistribute it and/or |
| 9206 | # modify it under the terms of the GNU General Public License |
||
| 9207 | # as published by the Free Software Foundation; either version 2 |
||
| 9208 | # of the License, or (at your option) any later version. |
||
| 9209 | 441:cbce1fd3b1b7 | Chris | # |
| 9210 | 0:513646585e45 | Chris | # This program is distributed in the hope that it will be useful, |
| 9211 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 9212 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 9213 | # GNU General Public License for more details. |
||
| 9214 | 441:cbce1fd3b1b7 | Chris | # |
| 9215 | 0:513646585e45 | Chris | # You should have received a copy of the GNU General Public License |
| 9216 | # along with this program; if not, write to the Free Software |
||
| 9217 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 9218 | |||
| 9219 | 1136:51d7f3e06556 | chris | require_dependency 'redmine/scm/adapters/git_adapter' |
| 9220 | 0:513646585e45 | Chris | |
| 9221 | class Repository::Git < Repository |
||
| 9222 | attr_protected :root_url |
||
| 9223 | validates_presence_of :url |
||
| 9224 | |||
| 9225 | 1115:433d4f72a19b | Chris | def self.human_attribute_name(attribute_key_name, *args) |
| 9226 | attr_name = attribute_key_name.to_s |
||
| 9227 | 441:cbce1fd3b1b7 | Chris | if attr_name == "url" |
| 9228 | attr_name = "path_to_repository" |
||
| 9229 | end |
||
| 9230 | 1115:433d4f72a19b | Chris | super(attr_name, *args) |
| 9231 | 245:051f544170fe | Chris | end |
| 9232 | |||
| 9233 | def self.scm_adapter_class |
||
| 9234 | 0:513646585e45 | Chris | Redmine::Scm::Adapters::GitAdapter |
| 9235 | end |
||
| 9236 | 245:051f544170fe | Chris | |
| 9237 | 0:513646585e45 | Chris | def self.scm_name |
| 9238 | 'Git' |
||
| 9239 | end |
||
| 9240 | |||
| 9241 | 441:cbce1fd3b1b7 | Chris | def report_last_commit |
| 9242 | extra_report_last_commit |
||
| 9243 | end |
||
| 9244 | |||
| 9245 | def extra_report_last_commit |
||
| 9246 | return false if extra_info.nil? |
||
| 9247 | v = extra_info["extra_report_last_commit"] |
||
| 9248 | return false if v.nil? |
||
| 9249 | v.to_s != '0' |
||
| 9250 | end |
||
| 9251 | |||
| 9252 | def supports_directory_revisions? |
||
| 9253 | true |
||
| 9254 | end |
||
| 9255 | |||
| 9256 | 909:cbb26bc654de | Chris | def supports_revision_graph? |
| 9257 | true |
||
| 9258 | end |
||
| 9259 | |||
| 9260 | 245:051f544170fe | Chris | def repo_log_encoding |
| 9261 | 'UTF-8' |
||
| 9262 | end |
||
| 9263 | |||
| 9264 | 117:af80e5618e9b | Chris | # Returns the identifier for the given git changeset |
| 9265 | def self.changeset_identifier(changeset) |
||
| 9266 | changeset.scmid |
||
| 9267 | end |
||
| 9268 | |||
| 9269 | # Returns the readable identifier for the given git changeset |
||
| 9270 | def self.format_changeset_identifier(changeset) |
||
| 9271 | changeset.revision[0, 8] |
||
| 9272 | end |
||
| 9273 | |||
| 9274 | 0:513646585e45 | Chris | def branches |
| 9275 | scm.branches |
||
| 9276 | end |
||
| 9277 | |||
| 9278 | def tags |
||
| 9279 | scm.tags |
||
| 9280 | end |
||
| 9281 | |||
| 9282 | 507:0c939c159af4 | Chris | def default_branch |
| 9283 | scm.default_branch |
||
| 9284 | 909:cbb26bc654de | Chris | rescue Exception => e |
| 9285 | logger.error "git: error during get default branch: #{e.message}"
|
||
| 9286 | nil |
||
| 9287 | 507:0c939c159af4 | Chris | end |
| 9288 | |||
| 9289 | 245:051f544170fe | Chris | def find_changeset_by_name(name) |
| 9290 | 1115:433d4f72a19b | Chris | if name.present? |
| 9291 | changesets.where(:revision => name.to_s).first || |
||
| 9292 | changesets.where('scmid LIKE ?', "#{name}%").first
|
||
| 9293 | end |
||
| 9294 | 245:051f544170fe | Chris | end |
| 9295 | |||
| 9296 | 1517:dffacf8a6908 | Chris | def scm_entries(path=nil, identifier=nil) |
| 9297 | scm.entries(path, identifier, :report_last_commit => extra_report_last_commit) |
||
| 9298 | 441:cbce1fd3b1b7 | Chris | end |
| 9299 | 1517:dffacf8a6908 | Chris | protected :scm_entries |
| 9300 | 441:cbce1fd3b1b7 | Chris | |
| 9301 | 909:cbb26bc654de | Chris | # With SCMs that have a sequential commit numbering, |
| 9302 | # such as Subversion and Mercurial, |
||
| 9303 | # Redmine is able to be clever and only fetch changesets |
||
| 9304 | # going forward from the most recent one it knows about. |
||
| 9305 | 1115:433d4f72a19b | Chris | # |
| 9306 | 909:cbb26bc654de | Chris | # However, Git does not have a sequential commit numbering. |
| 9307 | # |
||
| 9308 | # In order to fetch only new adding revisions, |
||
| 9309 | 1115:433d4f72a19b | Chris | # Redmine needs to save "heads". |
| 9310 | 909:cbb26bc654de | Chris | # |
| 9311 | 441:cbce1fd3b1b7 | Chris | # In Git and Mercurial, revisions are not in date order. |
| 9312 | # Redmine Mercurial fixed issues. |
||
| 9313 | # * Redmine Takes Too Long On Large Mercurial Repository |
||
| 9314 | # http://www.redmine.org/issues/3449 |
||
| 9315 | # * Sorting for changesets might go wrong on Mercurial repos |
||
| 9316 | # http://www.redmine.org/issues/3567 |
||
| 9317 | # |
||
| 9318 | # Database revision column is text, so Redmine can not sort by revision. |
||
| 9319 | # Mercurial has revision number, and revision number guarantees revision order. |
||
| 9320 | # Redmine Mercurial model stored revisions ordered by database id to database. |
||
| 9321 | # So, Redmine Mercurial model can use correct ordering revisions. |
||
| 9322 | # |
||
| 9323 | # Redmine Mercurial adapter uses "hg log -r 0:tip --limit 10" |
||
| 9324 | # to get limited revisions from old to new. |
||
| 9325 | # But, Git 1.7.3.4 does not support --reverse with -n or --skip. |
||
| 9326 | # |
||
| 9327 | 0:513646585e45 | Chris | # The repository can still be fully reloaded by calling #clear_changesets |
| 9328 | # before fetching changesets (eg. for offline resync) |
||
| 9329 | def fetch_changesets |
||
| 9330 | 441:cbce1fd3b1b7 | Chris | scm_brs = branches |
| 9331 | return if scm_brs.nil? || scm_brs.empty? |
||
| 9332 | 1115:433d4f72a19b | Chris | |
| 9333 | 441:cbce1fd3b1b7 | Chris | h1 = extra_info || {}
|
| 9334 | h = h1.dup |
||
| 9335 | 1115:433d4f72a19b | Chris | repo_heads = scm_brs.map{ |br| br.scmid }
|
| 9336 | h["heads"] ||= [] |
||
| 9337 | prev_db_heads = h["heads"].dup |
||
| 9338 | if prev_db_heads.empty? |
||
| 9339 | prev_db_heads += heads_from_branches_hash |
||
| 9340 | end |
||
| 9341 | return if prev_db_heads.sort == repo_heads.sort |
||
| 9342 | |||
| 9343 | 441:cbce1fd3b1b7 | Chris | h["db_consistent"] ||= {}
|
| 9344 | if changesets.count == 0 |
||
| 9345 | h["db_consistent"]["ordering"] = 1 |
||
| 9346 | merge_extra_info(h) |
||
| 9347 | self.save |
||
| 9348 | elsif ! h["db_consistent"].has_key?("ordering")
|
||
| 9349 | h["db_consistent"]["ordering"] = 0 |
||
| 9350 | merge_extra_info(h) |
||
| 9351 | self.save |
||
| 9352 | end |
||
| 9353 | 1115:433d4f72a19b | Chris | save_revisions(prev_db_heads, repo_heads) |
| 9354 | end |
||
| 9355 | |||
| 9356 | def save_revisions(prev_db_heads, repo_heads) |
||
| 9357 | h = {}
|
||
| 9358 | opts = {}
|
||
| 9359 | opts[:reverse] = true |
||
| 9360 | opts[:excludes] = prev_db_heads |
||
| 9361 | opts[:includes] = repo_heads |
||
| 9362 | |||
| 9363 | revisions = scm.revisions('', nil, nil, opts)
|
||
| 9364 | return if revisions.blank? |
||
| 9365 | |||
| 9366 | # Make the search for existing revisions in the database in a more sufficient manner |
||
| 9367 | # |
||
| 9368 | # Git branch is the reference to the specific revision. |
||
| 9369 | # Git can *delete* remote branch and *re-push* branch. |
||
| 9370 | # |
||
| 9371 | # $ git push remote :branch |
||
| 9372 | # $ git push remote branch |
||
| 9373 | # |
||
| 9374 | # After deleting branch, revisions remain in repository until "git gc". |
||
| 9375 | # On git 1.7.2.3, default pruning date is 2 weeks. |
||
| 9376 | # So, "git log --not deleted_branch_head_revision" return code is 0. |
||
| 9377 | # |
||
| 9378 | # After re-pushing branch, "git log" returns revisions which are saved in database. |
||
| 9379 | # So, Redmine needs to scan revisions and database every time. |
||
| 9380 | # |
||
| 9381 | # This is replacing the one-after-one queries. |
||
| 9382 | 1517:dffacf8a6908 | Chris | # Find all revisions, that are in the database, and then remove them |
| 9383 | # from the revision array. |
||
| 9384 | 1115:433d4f72a19b | Chris | # Then later we won't need any conditions for db existence. |
| 9385 | 1517:dffacf8a6908 | Chris | # Query for several revisions at once, and remove them |
| 9386 | # from the revisions array, if they are there. |
||
| 9387 | # Do this in chunks, to avoid eventual memory problems |
||
| 9388 | # (in case of tens of thousands of commits). |
||
| 9389 | 1115:433d4f72a19b | Chris | # If there are no revisions (because the original code's algorithm filtered them), |
| 9390 | # then this part will be stepped over. |
||
| 9391 | # We make queries, just if there is any revision. |
||
| 9392 | limit = 100 |
||
| 9393 | offset = 0 |
||
| 9394 | revisions_copy = revisions.clone # revisions will change |
||
| 9395 | while offset < revisions_copy.size |
||
| 9396 | 1464:261b3d9a4903 | Chris | scmids = revisions_copy.slice(offset, limit).map{|x| x.scmid}
|
| 9397 | 1517:dffacf8a6908 | Chris | recent_changesets_slice = changesets.where(:scmid => scmids) |
| 9398 | 1115:433d4f72a19b | Chris | # Subtract revisions that redmine already knows about |
| 9399 | recent_revisions = recent_changesets_slice.map{|c| c.scmid}
|
||
| 9400 | revisions.reject!{|r| recent_revisions.include?(r.scmid)}
|
||
| 9401 | offset += limit |
||
| 9402 | end |
||
| 9403 | revisions.each do |rev| |
||
| 9404 | transaction do |
||
| 9405 | # There is no search in the db for this revision, because above we ensured, |
||
| 9406 | # that it's not in the db. |
||
| 9407 | save_revision(rev) |
||
| 9408 | 245:051f544170fe | Chris | end |
| 9409 | end |
||
| 9410 | 1115:433d4f72a19b | Chris | h["heads"] = repo_heads.dup |
| 9411 | merge_extra_info(h) |
||
| 9412 | self.save |
||
| 9413 | 0:513646585e45 | Chris | end |
| 9414 | 1115:433d4f72a19b | Chris | private :save_revisions |
| 9415 | 0:513646585e45 | Chris | |
| 9416 | 441:cbce1fd3b1b7 | Chris | def save_revision(rev) |
| 9417 | 1115:433d4f72a19b | Chris | parents = (rev.parents || []).collect{|rp| find_changeset_by_name(rp)}.compact
|
| 9418 | changeset = Changeset.create( |
||
| 9419 | 441:cbce1fd3b1b7 | Chris | :repository => self, |
| 9420 | :revision => rev.identifier, |
||
| 9421 | :scmid => rev.scmid, |
||
| 9422 | :committer => rev.author, |
||
| 9423 | :committed_on => rev.time, |
||
| 9424 | 1115:433d4f72a19b | Chris | :comments => rev.message, |
| 9425 | :parents => parents |
||
| 9426 | 441:cbce1fd3b1b7 | Chris | ) |
| 9427 | 1115:433d4f72a19b | Chris | unless changeset.new_record? |
| 9428 | rev.paths.each { |change| changeset.create_change(change) }
|
||
| 9429 | 441:cbce1fd3b1b7 | Chris | end |
| 9430 | 909:cbb26bc654de | Chris | changeset |
| 9431 | 441:cbce1fd3b1b7 | Chris | end |
| 9432 | private :save_revision |
||
| 9433 | |||
| 9434 | 1115:433d4f72a19b | Chris | def heads_from_branches_hash |
| 9435 | h1 = extra_info || {}
|
||
| 9436 | h = h1.dup |
||
| 9437 | h["branches"] ||= {}
|
||
| 9438 | h['branches'].map{|br, hs| hs['last_scmid']}
|
||
| 9439 | end |
||
| 9440 | |||
| 9441 | 0:513646585e45 | Chris | def latest_changesets(path,rev,limit=10) |
| 9442 | revisions = scm.revisions(path, nil, rev, :limit => limit, :all => false) |
||
| 9443 | return [] if revisions.nil? || revisions.empty? |
||
| 9444 | 1464:261b3d9a4903 | Chris | changesets.where(:scmid => revisions.map {|c| c.scmid}).all
|
| 9445 | 0:513646585e45 | Chris | end |
| 9446 | 1115:433d4f72a19b | Chris | |
| 9447 | def clear_extra_info_of_changesets |
||
| 9448 | return if extra_info.nil? |
||
| 9449 | v = extra_info["extra_report_last_commit"] |
||
| 9450 | write_attribute(:extra_info, nil) |
||
| 9451 | h = {}
|
||
| 9452 | h["extra_report_last_commit"] = v |
||
| 9453 | merge_extra_info(h) |
||
| 9454 | self.save |
||
| 9455 | end |
||
| 9456 | private :clear_extra_info_of_changesets |
||
| 9457 | 0:513646585e45 | Chris | end |
| 9458 | 441:cbce1fd3b1b7 | Chris | # Redmine - project management software |
| 9459 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 9460 | 0:513646585e45 | Chris | # |
| 9461 | # This program is free software; you can redistribute it and/or |
||
| 9462 | # modify it under the terms of the GNU General Public License |
||
| 9463 | # as published by the Free Software Foundation; either version 2 |
||
| 9464 | # of the License, or (at your option) any later version. |
||
| 9465 | 441:cbce1fd3b1b7 | Chris | # |
| 9466 | 0:513646585e45 | Chris | # This program is distributed in the hope that it will be useful, |
| 9467 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 9468 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 9469 | # GNU General Public License for more details. |
||
| 9470 | 441:cbce1fd3b1b7 | Chris | # |
| 9471 | 0:513646585e45 | Chris | # You should have received a copy of the GNU General Public License |
| 9472 | # along with this program; if not, write to the Free Software |
||
| 9473 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 9474 | |||
| 9475 | 1136:51d7f3e06556 | chris | require_dependency 'redmine/scm/adapters/mercurial_adapter' |
| 9476 | 0:513646585e45 | Chris | |
| 9477 | class Repository::Mercurial < Repository |
||
| 9478 | 119:8661b858af72 | Chris | # sort changesets by revision number |
| 9479 | 909:cbb26bc654de | Chris | has_many :changesets, |
| 9480 | :order => "#{Changeset.table_name}.id DESC",
|
||
| 9481 | :foreign_key => 'repository_id' |
||
| 9482 | 119:8661b858af72 | Chris | |
| 9483 | 909:cbb26bc654de | Chris | attr_protected :root_url |
| 9484 | 225:6056b3c5f8f2 | luis | # validates_presence_of :url |
| 9485 | 0:513646585e45 | Chris | |
| 9486 | 909:cbb26bc654de | Chris | # number of changesets to fetch at once |
| 9487 | FETCH_AT_ONCE = 100 |
||
| 9488 | 245:051f544170fe | Chris | |
| 9489 | 1115:433d4f72a19b | Chris | def self.human_attribute_name(attribute_key_name, *args) |
| 9490 | attr_name = attribute_key_name.to_s |
||
| 9491 | 441:cbce1fd3b1b7 | Chris | if attr_name == "url" |
| 9492 | attr_name = "path_to_repository" |
||
| 9493 | end |
||
| 9494 | 1115:433d4f72a19b | Chris | super(attr_name, *args) |
| 9495 | 245:051f544170fe | Chris | end |
| 9496 | |||
| 9497 | def self.scm_adapter_class |
||
| 9498 | 0:513646585e45 | Chris | Redmine::Scm::Adapters::MercurialAdapter |
| 9499 | end |
||
| 9500 | 119:8661b858af72 | Chris | |
| 9501 | 0:513646585e45 | Chris | def self.scm_name |
| 9502 | 'Mercurial' |
||
| 9503 | end |
||
| 9504 | 119:8661b858af72 | Chris | |
| 9505 | 441:cbce1fd3b1b7 | Chris | def supports_directory_revisions? |
| 9506 | true |
||
| 9507 | end |
||
| 9508 | |||
| 9509 | 909:cbb26bc654de | Chris | def supports_revision_graph? |
| 9510 | true |
||
| 9511 | end |
||
| 9512 | |||
| 9513 | 245:051f544170fe | Chris | def repo_log_encoding |
| 9514 | 'UTF-8' |
||
| 9515 | end |
||
| 9516 | |||
| 9517 | 119:8661b858af72 | Chris | # Returns the readable identifier for the given mercurial changeset |
| 9518 | def self.format_changeset_identifier(changeset) |
||
| 9519 | 1517:dffacf8a6908 | Chris | "#{changeset.revision}:#{changeset.scmid[0, 12]}"
|
| 9520 | 119:8661b858af72 | Chris | end |
| 9521 | |||
| 9522 | # Returns the identifier for the given Mercurial changeset |
||
| 9523 | def self.changeset_identifier(changeset) |
||
| 9524 | changeset.scmid |
||
| 9525 | end |
||
| 9526 | |||
| 9527 | def diff_format_revisions(cs, cs_to, sep=':') |
||
| 9528 | super(cs, cs_to, ' ') |
||
| 9529 | end |
||
| 9530 | |||
| 9531 | 1517:dffacf8a6908 | Chris | def modify_entry_lastrev_identifier(entry) |
| 9532 | if entry.lastrev && entry.lastrev.identifier |
||
| 9533 | entry.lastrev.identifier = scmid_for_inserting_db(entry.lastrev.identifier) |
||
| 9534 | end |
||
| 9535 | end |
||
| 9536 | private :modify_entry_lastrev_identifier |
||
| 9537 | |||
| 9538 | def entry(path=nil, identifier=nil) |
||
| 9539 | entry = scm.entry(path, identifier) |
||
| 9540 | return nil if entry.nil? |
||
| 9541 | modify_entry_lastrev_identifier(entry) |
||
| 9542 | entry |
||
| 9543 | end |
||
| 9544 | |||
| 9545 | def scm_entries(path=nil, identifier=nil) |
||
| 9546 | entries = scm.entries(path, identifier) |
||
| 9547 | return nil if entries.nil? |
||
| 9548 | entries.each {|entry| modify_entry_lastrev_identifier(entry)}
|
||
| 9549 | entries |
||
| 9550 | end |
||
| 9551 | protected :scm_entries |
||
| 9552 | |||
| 9553 | 119:8661b858af72 | Chris | # Finds and returns a revision with a number or the beginning of a hash |
| 9554 | def find_changeset_by_name(name) |
||
| 9555 | 1115:433d4f72a19b | Chris | return nil if name.blank? |
| 9556 | s = name.to_s |
||
| 9557 | if /[^\d]/ =~ s or s.size > 8 |
||
| 9558 | cs = changesets.where(:scmid => s).first |
||
| 9559 | 119:8661b858af72 | Chris | else |
| 9560 | 1115:433d4f72a19b | Chris | cs = changesets.where(:revision => s).first |
| 9561 | 119:8661b858af72 | Chris | end |
| 9562 | 1115:433d4f72a19b | Chris | return cs if cs |
| 9563 | changesets.where('scmid LIKE ?', "#{s}%").first
|
||
| 9564 | 119:8661b858af72 | Chris | end |
| 9565 | |||
| 9566 | # Returns the latest changesets for +path+; sorted by revision number |
||
| 9567 | 441:cbce1fd3b1b7 | Chris | # |
| 9568 | # Because :order => 'id DESC' is defined at 'has_many', |
||
| 9569 | # there is no need to set 'order'. |
||
| 9570 | # But, MySQL test fails. |
||
| 9571 | # Sqlite3 and PostgreSQL pass. |
||
| 9572 | # Is this MySQL bug? |
||
| 9573 | 119:8661b858af72 | Chris | def latest_changesets(path, rev, limit=10) |
| 9574 | 1464:261b3d9a4903 | Chris | changesets. |
| 9575 | includes(:user). |
||
| 9576 | where(latest_changesets_cond(path, rev, limit)). |
||
| 9577 | limit(limit). |
||
| 9578 | order("#{Changeset.table_name}.id DESC").
|
||
| 9579 | all |
||
| 9580 | 441:cbce1fd3b1b7 | Chris | end |
| 9581 | |||
| 9582 | 1517:dffacf8a6908 | Chris | def is_short_id_in_db? |
| 9583 | return @is_short_id_in_db unless @is_short_id_in_db.nil? |
||
| 9584 | cs = changesets.first |
||
| 9585 | @is_short_id_in_db = (!cs.nil? && cs.scmid.length != 40) |
||
| 9586 | end |
||
| 9587 | private :is_short_id_in_db? |
||
| 9588 | |||
| 9589 | def scmid_for_inserting_db(scmid) |
||
| 9590 | is_short_id_in_db? ? scmid[0, 12] : scmid |
||
| 9591 | end |
||
| 9592 | |||
| 9593 | def nodes_in_branch(rev, branch_limit) |
||
| 9594 | scm.nodes_in_branch(rev, :limit => branch_limit).collect do |b| |
||
| 9595 | scmid_for_inserting_db(b) |
||
| 9596 | end |
||
| 9597 | end |
||
| 9598 | |||
| 9599 | def tag_scmid(rev) |
||
| 9600 | scmid = scm.tagmap[rev] |
||
| 9601 | scmid.nil? ? nil : scmid_for_inserting_db(scmid) |
||
| 9602 | end |
||
| 9603 | |||
| 9604 | 441:cbce1fd3b1b7 | Chris | def latest_changesets_cond(path, rev, limit) |
| 9605 | cond, args = [], [] |
||
| 9606 | if scm.branchmap.member? rev |
||
| 9607 | # Mercurial named branch is *stable* in each revision. |
||
| 9608 | # So, named branch can be stored in database. |
||
| 9609 | # Mercurial provides *bookmark* which is equivalent with git branch. |
||
| 9610 | # But, bookmark is not implemented. |
||
| 9611 | cond << "#{Changeset.table_name}.scmid IN (?)"
|
||
| 9612 | # Revisions in root directory and sub directory are not equal. |
||
| 9613 | # So, in order to get correct limit, we need to get all revisions. |
||
| 9614 | # But, it is very heavy. |
||
| 9615 | # Mercurial does not treat direcotry. |
||
| 9616 | # So, "hg log DIR" is very heavy. |
||
| 9617 | branch_limit = path.blank? ? limit : ( limit * 5 ) |
||
| 9618 | 1517:dffacf8a6908 | Chris | args << nodes_in_branch(rev, branch_limit) |
| 9619 | elsif last = rev ? find_changeset_by_name(tag_scmid(rev) || rev) : nil |
||
| 9620 | 441:cbce1fd3b1b7 | Chris | cond << "#{Changeset.table_name}.id <= ?"
|
| 9621 | args << last.id |
||
| 9622 | 119:8661b858af72 | Chris | end |
| 9623 | 441:cbce1fd3b1b7 | Chris | unless path.blank? |
| 9624 | cond << "EXISTS (SELECT * FROM #{Change.table_name}
|
||
| 9625 | WHERE #{Change.table_name}.changeset_id = #{Changeset.table_name}.id
|
||
| 9626 | AND (#{Change.table_name}.path = ?
|
||
| 9627 | OR #{Change.table_name}.path LIKE ? ESCAPE ?))"
|
||
| 9628 | args << path.with_leading_slash |
||
| 9629 | 909:cbb26bc654de | Chris | args << "#{path.with_leading_slash.gsub(%r{[%_\\]}) { |s| "\\#{s}" }}/%" << '\\'
|
| 9630 | 441:cbce1fd3b1b7 | Chris | end |
| 9631 | [cond.join(' AND '), *args] unless cond.empty?
|
||
| 9632 | 119:8661b858af72 | Chris | end |
| 9633 | 441:cbce1fd3b1b7 | Chris | private :latest_changesets_cond |
| 9634 | 119:8661b858af72 | Chris | |
| 9635 | 0:513646585e45 | Chris | def fetch_changesets |
| 9636 | 507:0c939c159af4 | Chris | return if scm.info.nil? |
| 9637 | 245:051f544170fe | Chris | scm_rev = scm.info.lastrev.revision.to_i |
| 9638 | 909:cbb26bc654de | Chris | db_rev = latest_changeset ? latest_changeset.revision.to_i : -1 |
| 9639 | 245:051f544170fe | Chris | return unless db_rev < scm_rev # already up-to-date |
| 9640 | |||
| 9641 | logger.debug "Fetching changesets for repository #{url}" if logger
|
||
| 9642 | (db_rev + 1).step(scm_rev, FETCH_AT_ONCE) do |i| |
||
| 9643 | 1115:433d4f72a19b | Chris | scm.each_revision('', i, [i + FETCH_AT_ONCE - 1, scm_rev].min) do |re|
|
| 9644 | transaction do |
||
| 9645 | 1517:dffacf8a6908 | Chris | parents = (re.parents || []).collect do |rp| |
| 9646 | find_changeset_by_name(scmid_for_inserting_db(rp)) |
||
| 9647 | end.compact |
||
| 9648 | 909:cbb26bc654de | Chris | cs = Changeset.create(:repository => self, |
| 9649 | :revision => re.revision, |
||
| 9650 | 1517:dffacf8a6908 | Chris | :scmid => scmid_for_inserting_db(re.scmid), |
| 9651 | 909:cbb26bc654de | Chris | :committer => re.author, |
| 9652 | 245:051f544170fe | Chris | :committed_on => re.time, |
| 9653 | 1115:433d4f72a19b | Chris | :comments => re.message, |
| 9654 | :parents => parents) |
||
| 9655 | unless cs.new_record? |
||
| 9656 | 1517:dffacf8a6908 | Chris | re.paths.each do |e| |
| 9657 | if from_revision = e[:from_revision] |
||
| 9658 | e[:from_revision] = scmid_for_inserting_db(from_revision) |
||
| 9659 | end |
||
| 9660 | cs.create_change(e) |
||
| 9661 | end |
||
| 9662 | 909:cbb26bc654de | Chris | end |
| 9663 | 0:513646585e45 | Chris | end |
| 9664 | end |
||
| 9665 | end |
||
| 9666 | end |
||
| 9667 | end |
||
| 9668 | 441:cbce1fd3b1b7 | Chris | # Redmine - project management software |
| 9669 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 9670 | 0:513646585e45 | Chris | # |
| 9671 | # This program is free software; you can redistribute it and/or |
||
| 9672 | # modify it under the terms of the GNU General Public License |
||
| 9673 | # as published by the Free Software Foundation; either version 2 |
||
| 9674 | # of the License, or (at your option) any later version. |
||
| 9675 | 441:cbce1fd3b1b7 | Chris | # |
| 9676 | 0:513646585e45 | Chris | # This program is distributed in the hope that it will be useful, |
| 9677 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 9678 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 9679 | # GNU General Public License for more details. |
||
| 9680 | 441:cbce1fd3b1b7 | Chris | # |
| 9681 | 0:513646585e45 | Chris | # You should have received a copy of the GNU General Public License |
| 9682 | # along with this program; if not, write to the Free Software |
||
| 9683 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 9684 | |||
| 9685 | 1136:51d7f3e06556 | chris | require_dependency 'redmine/scm/adapters/subversion_adapter' |
| 9686 | 0:513646585e45 | Chris | |
| 9687 | class Repository::Subversion < Repository |
||
| 9688 | attr_protected :root_url |
||
| 9689 | validates_presence_of :url |
||
| 9690 | 1464:261b3d9a4903 | Chris | validates_format_of :url, :with => %r{\A(http|https|svn(\+[^\s:\/\\]+)?|file):\/\/.+}i
|
| 9691 | 0:513646585e45 | Chris | |
| 9692 | 245:051f544170fe | Chris | def self.scm_adapter_class |
| 9693 | 0:513646585e45 | Chris | Redmine::Scm::Adapters::SubversionAdapter |
| 9694 | end |
||
| 9695 | 245:051f544170fe | Chris | |
| 9696 | 0:513646585e45 | Chris | def self.scm_name |
| 9697 | 'Subversion' |
||
| 9698 | end |
||
| 9699 | |||
| 9700 | 441:cbce1fd3b1b7 | Chris | def supports_directory_revisions? |
| 9701 | true |
||
| 9702 | end |
||
| 9703 | |||
| 9704 | 245:051f544170fe | Chris | def repo_log_encoding |
| 9705 | 'UTF-8' |
||
| 9706 | end |
||
| 9707 | |||
| 9708 | 0:513646585e45 | Chris | def latest_changesets(path, rev, limit=10) |
| 9709 | revisions = scm.revisions(path, rev, nil, :limit => limit) |
||
| 9710 | 1115:433d4f72a19b | Chris | if revisions |
| 9711 | identifiers = revisions.collect(&:identifier).compact |
||
| 9712 | changesets.where(:revision => identifiers).reorder("committed_on DESC").includes(:repository, :user).all
|
||
| 9713 | else |
||
| 9714 | [] |
||
| 9715 | end |
||
| 9716 | 0:513646585e45 | Chris | end |
| 9717 | 441:cbce1fd3b1b7 | Chris | |
| 9718 | 0:513646585e45 | Chris | # Returns a path relative to the url of the repository |
| 9719 | def relative_path(path) |
||
| 9720 | path.gsub(Regexp.new("^\/?#{Regexp.escape(relative_url)}"), '')
|
||
| 9721 | end |
||
| 9722 | 441:cbce1fd3b1b7 | Chris | |
| 9723 | 0:513646585e45 | Chris | def fetch_changesets |
| 9724 | scm_info = scm.info |
||
| 9725 | if scm_info |
||
| 9726 | # latest revision found in database |
||
| 9727 | db_revision = latest_changeset ? latest_changeset.revision.to_i : 0 |
||
| 9728 | # latest revision in the repository |
||
| 9729 | scm_revision = scm_info.lastrev.identifier.to_i |
||
| 9730 | if db_revision < scm_revision |
||
| 9731 | logger.debug "Fetching changesets for repository #{url}" if logger && logger.debug?
|
||
| 9732 | identifier_from = db_revision + 1 |
||
| 9733 | while (identifier_from <= scm_revision) |
||
| 9734 | # loads changesets by batches of 200 |
||
| 9735 | identifier_to = [identifier_from + 199, scm_revision].min |
||
| 9736 | revisions = scm.revisions('', identifier_to, identifier_from, :with_paths => true)
|
||
| 9737 | revisions.reverse_each do |revision| |
||
| 9738 | transaction do |
||
| 9739 | 441:cbce1fd3b1b7 | Chris | changeset = Changeset.create(:repository => self, |
| 9740 | :revision => revision.identifier, |
||
| 9741 | :committer => revision.author, |
||
| 9742 | 0:513646585e45 | Chris | :committed_on => revision.time, |
| 9743 | 441:cbce1fd3b1b7 | Chris | :comments => revision.message) |
| 9744 | |||
| 9745 | 0:513646585e45 | Chris | revision.paths.each do |change| |
| 9746 | changeset.create_change(change) |
||
| 9747 | end unless changeset.new_record? |
||
| 9748 | end |
||
| 9749 | end unless revisions.nil? |
||
| 9750 | identifier_from = identifier_to + 1 |
||
| 9751 | end |
||
| 9752 | end |
||
| 9753 | end |
||
| 9754 | end |
||
| 9755 | 441:cbce1fd3b1b7 | Chris | |
| 9756 | 1115:433d4f72a19b | Chris | protected |
| 9757 | |||
| 9758 | def load_entries_changesets(entries) |
||
| 9759 | return unless entries |
||
| 9760 | 1517:dffacf8a6908 | Chris | entries_with_identifier = |
| 9761 | entries.select {|entry| entry.lastrev && entry.lastrev.identifier.present?}
|
||
| 9762 | 1115:433d4f72a19b | Chris | identifiers = entries_with_identifier.map {|entry| entry.lastrev.identifier}.compact.uniq
|
| 9763 | if identifiers.any? |
||
| 9764 | 1517:dffacf8a6908 | Chris | changesets_by_identifier = |
| 9765 | changesets.where(:revision => identifiers). |
||
| 9766 | includes(:user, :repository).group_by(&:revision) |
||
| 9767 | 1115:433d4f72a19b | Chris | entries_with_identifier.each do |entry| |
| 9768 | if m = changesets_by_identifier[entry.lastrev.identifier] |
||
| 9769 | entry.changeset = m.first |
||
| 9770 | end |
||
| 9771 | end |
||
| 9772 | end |
||
| 9773 | end |
||
| 9774 | |||
| 9775 | 0:513646585e45 | Chris | private |
| 9776 | 441:cbce1fd3b1b7 | Chris | |
| 9777 | 0:513646585e45 | Chris | # Returns the relative url of the repository |
| 9778 | # Eg: root_url = file:///var/svn/foo |
||
| 9779 | # url = file:///var/svn/foo/bar |
||
| 9780 | # => returns /bar |
||
| 9781 | def relative_url |
||
| 9782 | @relative_url ||= url.gsub(Regexp.new("^#{Regexp.escape(root_url || scm.root_url)}", Regexp::IGNORECASE), '')
|
||
| 9783 | end |
||
| 9784 | end |
||
| 9785 | 441:cbce1fd3b1b7 | Chris | # Redmine - project management software |
| 9786 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 9787 | 0:513646585e45 | Chris | # |
| 9788 | # This program is free software; you can redistribute it and/or |
||
| 9789 | # modify it under the terms of the GNU General Public License |
||
| 9790 | # as published by the Free Software Foundation; either version 2 |
||
| 9791 | # of the License, or (at your option) any later version. |
||
| 9792 | 909:cbb26bc654de | Chris | # |
| 9793 | 0:513646585e45 | Chris | # This program is distributed in the hope that it will be useful, |
| 9794 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 9795 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 9796 | # GNU General Public License for more details. |
||
| 9797 | 909:cbb26bc654de | Chris | # |
| 9798 | 0:513646585e45 | Chris | # You should have received a copy of the GNU General Public License |
| 9799 | # along with this program; if not, write to the Free Software |
||
| 9800 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 9801 | |||
| 9802 | class Role < ActiveRecord::Base |
||
| 9803 | 1115:433d4f72a19b | Chris | # Custom coder for the permissions attribute that should be an |
| 9804 | # array of symbols. Rails 3 uses Psych which can be *unbelievably* |
||
| 9805 | # slow on some platforms (eg. mingw32). |
||
| 9806 | class PermissionsAttributeCoder |
||
| 9807 | def self.load(str) |
||
| 9808 | str.to_s.scan(/:([a-z0-9_]+)/).flatten.map(&:to_sym) |
||
| 9809 | end |
||
| 9810 | |||
| 9811 | def self.dump(value) |
||
| 9812 | YAML.dump(value) |
||
| 9813 | end |
||
| 9814 | end |
||
| 9815 | |||
| 9816 | 0:513646585e45 | Chris | # Built-in roles |
| 9817 | BUILTIN_NON_MEMBER = 1 |
||
| 9818 | BUILTIN_ANONYMOUS = 2 |
||
| 9819 | 909:cbb26bc654de | Chris | |
| 9820 | 441:cbce1fd3b1b7 | Chris | ISSUES_VISIBILITY_OPTIONS = [ |
| 9821 | ['all', :label_issues_visibility_all], |
||
| 9822 | ['default', :label_issues_visibility_public], |
||
| 9823 | ['own', :label_issues_visibility_own] |
||
| 9824 | ] |
||
| 9825 | 0:513646585e45 | Chris | |
| 9826 | 1464:261b3d9a4903 | Chris | scope :sorted, lambda { order("#{table_name}.builtin ASC, #{table_name}.position ASC") }
|
| 9827 | scope :givable, lambda { order("#{table_name}.position ASC").where(:builtin => 0) }
|
||
| 9828 | 1115:433d4f72a19b | Chris | scope :builtin, lambda { |*args|
|
| 9829 | compare = (args.first == true ? 'not' : '') |
||
| 9830 | where("#{compare} builtin = 0")
|
||
| 9831 | 0:513646585e45 | Chris | } |
| 9832 | 909:cbb26bc654de | Chris | |
| 9833 | 0:513646585e45 | Chris | before_destroy :check_deletable |
| 9834 | 1115:433d4f72a19b | Chris | has_many :workflow_rules, :dependent => :delete_all do |
| 9835 | 0:513646585e45 | Chris | def copy(source_role) |
| 9836 | 1115:433d4f72a19b | Chris | WorkflowRule.copy(nil, source_role, nil, proxy_association.owner) |
| 9837 | 0:513646585e45 | Chris | end |
| 9838 | end |
||
| 9839 | 1464:261b3d9a4903 | Chris | has_and_belongs_to_many :custom_fields, :join_table => "#{table_name_prefix}custom_fields_roles#{table_name_suffix}", :foreign_key => "role_id"
|
| 9840 | 909:cbb26bc654de | Chris | |
| 9841 | 0:513646585e45 | Chris | has_many :member_roles, :dependent => :destroy |
| 9842 | has_many :members, :through => :member_roles |
||
| 9843 | acts_as_list |
||
| 9844 | 909:cbb26bc654de | Chris | |
| 9845 | 1115:433d4f72a19b | Chris | serialize :permissions, ::Role::PermissionsAttributeCoder |
| 9846 | 0:513646585e45 | Chris | attr_protected :builtin |
| 9847 | |||
| 9848 | validates_presence_of :name |
||
| 9849 | validates_uniqueness_of :name |
||
| 9850 | validates_length_of :name, :maximum => 30 |
||
| 9851 | 441:cbce1fd3b1b7 | Chris | validates_inclusion_of :issues_visibility, |
| 9852 | :in => ISSUES_VISIBILITY_OPTIONS.collect(&:first), |
||
| 9853 | :if => lambda {|role| role.respond_to?(:issues_visibility)}
|
||
| 9854 | 909:cbb26bc654de | Chris | |
| 9855 | 1115:433d4f72a19b | Chris | # Copies attributes from another role, arg can be an id or a Role |
| 9856 | def copy_from(arg, options={})
|
||
| 9857 | return unless arg.present? |
||
| 9858 | role = arg.is_a?(Role) ? arg : Role.find_by_id(arg.to_s) |
||
| 9859 | self.attributes = role.attributes.dup.except("id", "name", "position", "builtin", "permissions")
|
||
| 9860 | self.permissions = role.permissions.dup |
||
| 9861 | self |
||
| 9862 | 0:513646585e45 | Chris | end |
| 9863 | 909:cbb26bc654de | Chris | |
| 9864 | 0:513646585e45 | Chris | def permissions=(perms) |
| 9865 | perms = perms.collect {|p| p.to_sym unless p.blank? }.compact.uniq if perms
|
||
| 9866 | write_attribute(:permissions, perms) |
||
| 9867 | end |
||
| 9868 | |||
| 9869 | def add_permission!(*perms) |
||
| 9870 | self.permissions = [] unless permissions.is_a?(Array) |
||
| 9871 | |||
| 9872 | permissions_will_change! |
||
| 9873 | perms.each do |p| |
||
| 9874 | p = p.to_sym |
||
| 9875 | permissions << p unless permissions.include?(p) |
||
| 9876 | end |
||
| 9877 | save! |
||
| 9878 | end |
||
| 9879 | |||
| 9880 | def remove_permission!(*perms) |
||
| 9881 | return unless permissions.is_a?(Array) |
||
| 9882 | permissions_will_change! |
||
| 9883 | perms.each { |p| permissions.delete(p.to_sym) }
|
||
| 9884 | save! |
||
| 9885 | end |
||
| 9886 | 909:cbb26bc654de | Chris | |
| 9887 | 0:513646585e45 | Chris | # Returns true if the role has the given permission |
| 9888 | def has_permission?(perm) |
||
| 9889 | !permissions.nil? && permissions.include?(perm.to_sym) |
||
| 9890 | end |
||
| 9891 | 909:cbb26bc654de | Chris | |
| 9892 | 0:513646585e45 | Chris | def <=>(role) |
| 9893 | 1115:433d4f72a19b | Chris | if role |
| 9894 | if builtin == role.builtin |
||
| 9895 | position <=> role.position |
||
| 9896 | else |
||
| 9897 | builtin <=> role.builtin |
||
| 9898 | end |
||
| 9899 | else |
||
| 9900 | -1 |
||
| 9901 | end |
||
| 9902 | 0:513646585e45 | Chris | end |
| 9903 | 909:cbb26bc654de | Chris | |
| 9904 | 0:513646585e45 | Chris | def to_s |
| 9905 | name |
||
| 9906 | end |
||
| 9907 | 909:cbb26bc654de | Chris | |
| 9908 | 441:cbce1fd3b1b7 | Chris | def name |
| 9909 | case builtin |
||
| 9910 | when 1; l(:label_role_non_member, :default => read_attribute(:name)) |
||
| 9911 | when 2; l(:label_role_anonymous, :default => read_attribute(:name)) |
||
| 9912 | else; read_attribute(:name) |
||
| 9913 | end |
||
| 9914 | end |
||
| 9915 | 909:cbb26bc654de | Chris | |
| 9916 | 0:513646585e45 | Chris | # Return true if the role is a builtin role |
| 9917 | def builtin? |
||
| 9918 | self.builtin != 0 |
||
| 9919 | end |
||
| 9920 | 909:cbb26bc654de | Chris | |
| 9921 | 1115:433d4f72a19b | Chris | # Return true if the role is the anonymous role |
| 9922 | def anonymous? |
||
| 9923 | builtin == 2 |
||
| 9924 | end |
||
| 9925 | 1464:261b3d9a4903 | Chris | |
| 9926 | 0:513646585e45 | Chris | # Return true if the role is a project member role |
| 9927 | def member? |
||
| 9928 | !self.builtin? |
||
| 9929 | end |
||
| 9930 | 909:cbb26bc654de | Chris | |
| 9931 | 0:513646585e45 | Chris | # Return true if role is allowed to do the specified action |
| 9932 | # action can be: |
||
| 9933 | # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit') |
||
| 9934 | # * a permission Symbol (eg. :edit_project) |
||
| 9935 | def allowed_to?(action) |
||
| 9936 | if action.is_a? Hash |
||
| 9937 | allowed_actions.include? "#{action[:controller]}/#{action[:action]}"
|
||
| 9938 | else |
||
| 9939 | allowed_permissions.include? action |
||
| 9940 | end |
||
| 9941 | end |
||
| 9942 | 909:cbb26bc654de | Chris | |
| 9943 | 0:513646585e45 | Chris | # Return all the permissions that can be given to the role |
| 9944 | def setable_permissions |
||
| 9945 | setable_permissions = Redmine::AccessControl.permissions - Redmine::AccessControl.public_permissions |
||
| 9946 | setable_permissions -= Redmine::AccessControl.members_only_permissions if self.builtin == BUILTIN_NON_MEMBER |
||
| 9947 | setable_permissions -= Redmine::AccessControl.loggedin_only_permissions if self.builtin == BUILTIN_ANONYMOUS |
||
| 9948 | setable_permissions |
||
| 9949 | end |
||
| 9950 | |||
| 9951 | # Find all the roles that can be given to a project member |
||
| 9952 | def self.find_all_givable |
||
| 9953 | 1115:433d4f72a19b | Chris | Role.givable.all |
| 9954 | 0:513646585e45 | Chris | end |
| 9955 | |||
| 9956 | # Return the builtin 'non member' role. If the role doesn't exist, |
||
| 9957 | # it will be created on the fly. |
||
| 9958 | def self.non_member |
||
| 9959 | 909:cbb26bc654de | Chris | find_or_create_system_role(BUILTIN_NON_MEMBER, 'Non member') |
| 9960 | 0:513646585e45 | Chris | end |
| 9961 | |||
| 9962 | # Return the builtin 'anonymous' role. If the role doesn't exist, |
||
| 9963 | # it will be created on the fly. |
||
| 9964 | def self.anonymous |
||
| 9965 | 909:cbb26bc654de | Chris | find_or_create_system_role(BUILTIN_ANONYMOUS, 'Anonymous') |
| 9966 | 0:513646585e45 | Chris | end |
| 9967 | |||
| 9968 | private |
||
| 9969 | 909:cbb26bc654de | Chris | |
| 9970 | 0:513646585e45 | Chris | def allowed_permissions |
| 9971 | @allowed_permissions ||= permissions + Redmine::AccessControl.public_permissions.collect {|p| p.name}
|
||
| 9972 | end |
||
| 9973 | |||
| 9974 | def allowed_actions |
||
| 9975 | @actions_allowed ||= allowed_permissions.inject([]) { |actions, permission| actions += Redmine::AccessControl.allowed_actions(permission) }.flatten
|
||
| 9976 | end |
||
| 9977 | 909:cbb26bc654de | Chris | |
| 9978 | 0:513646585e45 | Chris | def check_deletable |
| 9979 | raise "Can't delete role" if members.any? |
||
| 9980 | raise "Can't delete builtin role" if builtin? |
||
| 9981 | end |
||
| 9982 | 909:cbb26bc654de | Chris | |
| 9983 | def self.find_or_create_system_role(builtin, name) |
||
| 9984 | 1115:433d4f72a19b | Chris | role = where(:builtin => builtin).first |
| 9985 | 909:cbb26bc654de | Chris | if role.nil? |
| 9986 | role = create(:name => name, :position => 0) do |r| |
||
| 9987 | r.builtin = builtin |
||
| 9988 | end |
||
| 9989 | raise "Unable to create the #{name} role." if role.new_record?
|
||
| 9990 | end |
||
| 9991 | role |
||
| 9992 | end |
||
| 9993 | 0:513646585e45 | Chris | end |
| 9994 | 441:cbce1fd3b1b7 | Chris | # Redmine - project management software |
| 9995 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 9996 | 0:513646585e45 | Chris | # |
| 9997 | # This program is free software; you can redistribute it and/or |
||
| 9998 | # modify it under the terms of the GNU General Public License |
||
| 9999 | # as published by the Free Software Foundation; either version 2 |
||
| 10000 | # of the License, or (at your option) any later version. |
||
| 10001 | 441:cbce1fd3b1b7 | Chris | # |
| 10002 | 0:513646585e45 | Chris | # This program is distributed in the hope that it will be useful, |
| 10003 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 10004 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 10005 | # GNU General Public License for more details. |
||
| 10006 | 441:cbce1fd3b1b7 | Chris | # |
| 10007 | 0:513646585e45 | Chris | # You should have received a copy of the GNU General Public License |
| 10008 | # along with this program; if not, write to the Free Software |
||
| 10009 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 10010 | |||
| 10011 | class Setting < ActiveRecord::Base |
||
| 10012 | |||
| 10013 | DATE_FORMATS = [ |
||
| 10014 | 1464:261b3d9a4903 | Chris | '%Y-%m-%d', |
| 10015 | '%d/%m/%Y', |
||
| 10016 | '%d.%m.%Y', |
||
| 10017 | '%d-%m-%Y', |
||
| 10018 | '%m/%d/%Y', |
||
| 10019 | '%d %b %Y', |
||
| 10020 | '%d %B %Y', |
||
| 10021 | '%b %d, %Y', |
||
| 10022 | '%B %d, %Y' |
||
| 10023 | 0:513646585e45 | Chris | ] |
| 10024 | 441:cbce1fd3b1b7 | Chris | |
| 10025 | 0:513646585e45 | Chris | TIME_FORMATS = [ |
| 10026 | '%H:%M', |
||
| 10027 | '%I:%M %p' |
||
| 10028 | ] |
||
| 10029 | 441:cbce1fd3b1b7 | Chris | |
| 10030 | 0:513646585e45 | Chris | ENCODINGS = %w(US-ASCII |
| 10031 | windows-1250 |
||
| 10032 | windows-1251 |
||
| 10033 | windows-1252 |
||
| 10034 | windows-1253 |
||
| 10035 | windows-1254 |
||
| 10036 | windows-1255 |
||
| 10037 | windows-1256 |
||
| 10038 | windows-1257 |
||
| 10039 | windows-1258 |
||
| 10040 | windows-31j |
||
| 10041 | ISO-2022-JP |
||
| 10042 | ISO-2022-KR |
||
| 10043 | ISO-8859-1 |
||
| 10044 | ISO-8859-2 |
||
| 10045 | ISO-8859-3 |
||
| 10046 | ISO-8859-4 |
||
| 10047 | ISO-8859-5 |
||
| 10048 | ISO-8859-6 |
||
| 10049 | ISO-8859-7 |
||
| 10050 | ISO-8859-8 |
||
| 10051 | ISO-8859-9 |
||
| 10052 | ISO-8859-13 |
||
| 10053 | ISO-8859-15 |
||
| 10054 | KOI8-R |
||
| 10055 | UTF-8 |
||
| 10056 | UTF-16 |
||
| 10057 | UTF-16BE |
||
| 10058 | UTF-16LE |
||
| 10059 | EUC-JP |
||
| 10060 | Shift_JIS |
||
| 10061 | 245:051f544170fe | Chris | CP932 |
| 10062 | 0:513646585e45 | Chris | GB18030 |
| 10063 | GBK |
||
| 10064 | ISCII91 |
||
| 10065 | EUC-KR |
||
| 10066 | Big5 |
||
| 10067 | Big5-HKSCS |
||
| 10068 | TIS-620) |
||
| 10069 | 441:cbce1fd3b1b7 | Chris | |
| 10070 | 0:513646585e45 | Chris | cattr_accessor :available_settings |
| 10071 | 909:cbb26bc654de | Chris | @@available_settings = YAML::load(File.open("#{Rails.root}/config/settings.yml"))
|
| 10072 | 0:513646585e45 | Chris | Redmine::Plugin.all.each do |plugin| |
| 10073 | next unless plugin.settings |
||
| 10074 | 441:cbce1fd3b1b7 | Chris | @@available_settings["plugin_#{plugin.id}"] = {'default' => plugin.settings[:default], 'serialized' => true}
|
| 10075 | 0:513646585e45 | Chris | end |
| 10076 | 441:cbce1fd3b1b7 | Chris | |
| 10077 | 0:513646585e45 | Chris | validates_uniqueness_of :name |
| 10078 | validates_inclusion_of :name, :in => @@available_settings.keys |
||
| 10079 | 1517:dffacf8a6908 | Chris | validates_numericality_of :value, :only_integer => true, :if => Proc.new { |setting|
|
| 10080 | (s = @@available_settings[setting.name]) && s['format'] == 'int' |
||
| 10081 | } |
||
| 10082 | 0:513646585e45 | Chris | |
| 10083 | # Hash used to cache setting values |
||
| 10084 | @cached_settings = {}
|
||
| 10085 | @cached_cleared_on = Time.now |
||
| 10086 | 441:cbce1fd3b1b7 | Chris | |
| 10087 | 0:513646585e45 | Chris | def value |
| 10088 | v = read_attribute(:value) |
||
| 10089 | # Unserialize serialized settings |
||
| 10090 | v = YAML::load(v) if @@available_settings[name]['serialized'] && v.is_a?(String) |
||
| 10091 | v = v.to_sym if @@available_settings[name]['format'] == 'symbol' && !v.blank? |
||
| 10092 | v |
||
| 10093 | end |
||
| 10094 | 441:cbce1fd3b1b7 | Chris | |
| 10095 | 0:513646585e45 | Chris | def value=(v) |
| 10096 | v = v.to_yaml if v && @@available_settings[name] && @@available_settings[name]['serialized'] |
||
| 10097 | write_attribute(:value, v.to_s) |
||
| 10098 | end |
||
| 10099 | 441:cbce1fd3b1b7 | Chris | |
| 10100 | 0:513646585e45 | Chris | # Returns the value of the setting named name |
| 10101 | def self.[](name) |
||
| 10102 | v = @cached_settings[name] |
||
| 10103 | v ? v : (@cached_settings[name] = find_or_default(name).value) |
||
| 10104 | end |
||
| 10105 | 441:cbce1fd3b1b7 | Chris | |
| 10106 | 0:513646585e45 | Chris | def self.[]=(name, v) |
| 10107 | setting = find_or_default(name) |
||
| 10108 | setting.value = (v ? v : "") |
||
| 10109 | @cached_settings[name] = nil |
||
| 10110 | setting.save |
||
| 10111 | setting.value |
||
| 10112 | end |
||
| 10113 | 441:cbce1fd3b1b7 | Chris | |
| 10114 | 0:513646585e45 | Chris | # Defines getter and setter for each setting |
| 10115 | # Then setting values can be read using: Setting.some_setting_name |
||
| 10116 | # or set using Setting.some_setting_name = "some value" |
||
| 10117 | @@available_settings.each do |name, params| |
||
| 10118 | src = <<-END_SRC |
||
| 10119 | def self.#{name}
|
||
| 10120 | self[:#{name}]
|
||
| 10121 | end |
||
| 10122 | |||
| 10123 | def self.#{name}?
|
||
| 10124 | self[:#{name}].to_i > 0
|
||
| 10125 | end |
||
| 10126 | |||
| 10127 | def self.#{name}=(value)
|
||
| 10128 | self[:#{name}] = value
|
||
| 10129 | end |
||
| 10130 | 1464:261b3d9a4903 | Chris | END_SRC |
| 10131 | 0:513646585e45 | Chris | class_eval src, __FILE__, __LINE__ |
| 10132 | end |
||
| 10133 | 441:cbce1fd3b1b7 | Chris | |
| 10134 | 1464:261b3d9a4903 | Chris | # Sets a setting value from params |
| 10135 | def self.set_from_params(name, params) |
||
| 10136 | params = params.dup |
||
| 10137 | params.delete_if {|v| v.blank? } if params.is_a?(Array)
|
||
| 10138 | |||
| 10139 | m = "#{name}_from_params"
|
||
| 10140 | if respond_to? m |
||
| 10141 | self[name.to_sym] = send m, params |
||
| 10142 | else |
||
| 10143 | self[name.to_sym] = params |
||
| 10144 | end |
||
| 10145 | end |
||
| 10146 | |||
| 10147 | # Returns a hash suitable for commit_update_keywords setting |
||
| 10148 | # |
||
| 10149 | # Example: |
||
| 10150 | # params = {:keywords => ['fixes', 'closes'], :status_id => ["3", "5"], :done_ratio => ["", "100"]}
|
||
| 10151 | # Setting.commit_update_keywords_from_params(params) |
||
| 10152 | # # => [{'keywords => 'fixes', 'status_id' => "3"}, {'keywords => 'closes', 'status_id' => "5", 'done_ratio' => "100"}]
|
||
| 10153 | def self.commit_update_keywords_from_params(params) |
||
| 10154 | s = [] |
||
| 10155 | if params.is_a?(Hash) && params.key?(:keywords) && params.values.all? {|v| v.is_a? Array}
|
||
| 10156 | attributes = params.except(:keywords).keys |
||
| 10157 | params[:keywords].each_with_index do |keywords, i| |
||
| 10158 | next if keywords.blank? |
||
| 10159 | s << attributes.inject({}) {|h, a|
|
||
| 10160 | value = params[a][i].to_s |
||
| 10161 | h[a.to_s] = value if value.present? |
||
| 10162 | h |
||
| 10163 | }.merge('keywords' => keywords)
|
||
| 10164 | end |
||
| 10165 | end |
||
| 10166 | s |
||
| 10167 | end |
||
| 10168 | |||
| 10169 | 0:513646585e45 | Chris | # Helper that returns an array based on per_page_options setting |
| 10170 | def self.per_page_options_array |
||
| 10171 | per_page_options.split(%r{[\s,]}).collect(&:to_i).select {|n| n > 0}.sort
|
||
| 10172 | end |
||
| 10173 | 441:cbce1fd3b1b7 | Chris | |
| 10174 | 1464:261b3d9a4903 | Chris | # Helper that returns a Hash with single update keywords as keys |
| 10175 | def self.commit_update_keywords_array |
||
| 10176 | a = [] |
||
| 10177 | if commit_update_keywords.is_a?(Array) |
||
| 10178 | commit_update_keywords.each do |rule| |
||
| 10179 | next unless rule.is_a?(Hash) |
||
| 10180 | rule = rule.dup |
||
| 10181 | rule.delete_if {|k, v| v.blank?}
|
||
| 10182 | keywords = rule['keywords'].to_s.downcase.split(",").map(&:strip).reject(&:blank?)
|
||
| 10183 | next if keywords.empty? |
||
| 10184 | a << rule.merge('keywords' => keywords)
|
||
| 10185 | end |
||
| 10186 | end |
||
| 10187 | a |
||
| 10188 | end |
||
| 10189 | |||
| 10190 | def self.commit_fix_keywords |
||
| 10191 | ActiveSupport::Deprecation.warn "Setting.commit_fix_keywords is deprecated and will be removed in Redmine 3" |
||
| 10192 | if commit_update_keywords.is_a?(Array) |
||
| 10193 | commit_update_keywords.first && commit_update_keywords.first['keywords'] |
||
| 10194 | end |
||
| 10195 | end |
||
| 10196 | |||
| 10197 | def self.commit_fix_status_id |
||
| 10198 | ActiveSupport::Deprecation.warn "Setting.commit_fix_status_id is deprecated and will be removed in Redmine 3" |
||
| 10199 | if commit_update_keywords.is_a?(Array) |
||
| 10200 | commit_update_keywords.first && commit_update_keywords.first['status_id'] |
||
| 10201 | end |
||
| 10202 | end |
||
| 10203 | |||
| 10204 | def self.commit_fix_done_ratio |
||
| 10205 | ActiveSupport::Deprecation.warn "Setting.commit_fix_done_ratio is deprecated and will be removed in Redmine 3" |
||
| 10206 | if commit_update_keywords.is_a?(Array) |
||
| 10207 | commit_update_keywords.first && commit_update_keywords.first['done_ratio'] |
||
| 10208 | end |
||
| 10209 | end |
||
| 10210 | |||
| 10211 | 0:513646585e45 | Chris | def self.openid? |
| 10212 | Object.const_defined?(:OpenID) && self[:openid].to_i > 0 |
||
| 10213 | end |
||
| 10214 | 441:cbce1fd3b1b7 | Chris | |
| 10215 | 0:513646585e45 | Chris | # Checks if settings have changed since the values were read |
| 10216 | # and clears the cache hash if it's the case |
||
| 10217 | # Called once per request |
||
| 10218 | def self.check_cache |
||
| 10219 | settings_updated_on = Setting.maximum(:updated_on) |
||
| 10220 | if settings_updated_on && @cached_cleared_on <= settings_updated_on |
||
| 10221 | 909:cbb26bc654de | Chris | clear_cache |
| 10222 | 0:513646585e45 | Chris | end |
| 10223 | end |
||
| 10224 | 1464:261b3d9a4903 | Chris | |
| 10225 | 909:cbb26bc654de | Chris | # Clears the settings cache |
| 10226 | def self.clear_cache |
||
| 10227 | @cached_settings.clear |
||
| 10228 | @cached_cleared_on = Time.now |
||
| 10229 | logger.info "Settings cache cleared." if logger |
||
| 10230 | end |
||
| 10231 | 441:cbce1fd3b1b7 | Chris | |
| 10232 | 0:513646585e45 | Chris | private |
| 10233 | # Returns the Setting instance for the setting named name |
||
| 10234 | # (record found in database or new record with default value) |
||
| 10235 | def self.find_or_default(name) |
||
| 10236 | name = name.to_s |
||
| 10237 | 441:cbce1fd3b1b7 | Chris | raise "There's no setting named #{name}" unless @@available_settings.has_key?(name)
|
| 10238 | 1517:dffacf8a6908 | Chris | setting = where(:name => name).first |
| 10239 | 909:cbb26bc654de | Chris | unless setting |
| 10240 | 1517:dffacf8a6908 | Chris | setting = new |
| 10241 | setting.name = name |
||
| 10242 | 909:cbb26bc654de | Chris | setting.value = @@available_settings[name]['default'] |
| 10243 | end |
||
| 10244 | setting |
||
| 10245 | 0:513646585e45 | Chris | end |
| 10246 | end |
||
| 10247 | 56:1d072f771b4d | luisf | class SsamrUserDetail < ActiveRecord::Base |
| 10248 | 60:cf39b52d24b4 | luisf | belongs_to :user |
| 10249 | 64:9d42bcda8cea | luisf | |
| 10250 | validates_presence_of :description |
||
| 10251 | 163:9a5a265e77f0 | luisf | |
| 10252 | validate :check_institution |
||
| 10253 | |||
| 10254 | def check_institution() |
||
| 10255 | errors.add(:institution_id, "Please insert an institution") if |
||
| 10256 | institution_id.blank? and other_institution.blank? |
||
| 10257 | end |
||
| 10258 | |||
| 10259 | 525:8a26a0e291cf | luis | def institution_name() |
| 10260 | if not self.institution_type.nil? |
||
| 10261 | if self.institution_type |
||
| 10262 | Institution.find(self.institution_id).name |
||
| 10263 | else |
||
| 10264 | self.other_institution |
||
| 10265 | end |
||
| 10266 | else |
||
| 10267 | "" |
||
| 10268 | end |
||
| 10269 | end |
||
| 10270 | 56:1d072f771b4d | luisf | end |
| 10271 | 441:cbce1fd3b1b7 | Chris | # Redmine - project management software |
| 10272 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 10273 | 0:513646585e45 | Chris | # |
| 10274 | # This program is free software; you can redistribute it and/or |
||
| 10275 | # modify it under the terms of the GNU General Public License |
||
| 10276 | # as published by the Free Software Foundation; either version 2 |
||
| 10277 | # of the License, or (at your option) any later version. |
||
| 10278 | 441:cbce1fd3b1b7 | Chris | # |
| 10279 | 0:513646585e45 | Chris | # This program is distributed in the hope that it will be useful, |
| 10280 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 10281 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 10282 | # GNU General Public License for more details. |
||
| 10283 | 441:cbce1fd3b1b7 | Chris | # |
| 10284 | 0:513646585e45 | Chris | # You should have received a copy of the GNU General Public License |
| 10285 | # along with this program; if not, write to the Free Software |
||
| 10286 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 10287 | |||
| 10288 | class TimeEntry < ActiveRecord::Base |
||
| 10289 | 929:5f33065ddc4b | Chris | include Redmine::SafeAttributes |
| 10290 | 0:513646585e45 | Chris | # could have used polymorphic association |
| 10291 | # project association here allows easy loading of time entries at project level with one database trip |
||
| 10292 | belongs_to :project |
||
| 10293 | belongs_to :issue |
||
| 10294 | belongs_to :user |
||
| 10295 | belongs_to :activity, :class_name => 'TimeEntryActivity', :foreign_key => 'activity_id' |
||
| 10296 | 441:cbce1fd3b1b7 | Chris | |
| 10297 | 0:513646585e45 | Chris | attr_protected :project_id, :user_id, :tyear, :tmonth, :tweek |
| 10298 | |||
| 10299 | acts_as_customizable |
||
| 10300 | acts_as_event :title => Proc.new {|o| "#{l_hours(o.hours)} (#{(o.issue || o.project).event_title})"},
|
||
| 10301 | 37:94944d00e43c | chris | :url => Proc.new {|o| {:controller => 'timelog', :action => 'index', :project_id => o.project, :issue_id => o.issue}},
|
| 10302 | 0:513646585e45 | Chris | :author => :user, |
| 10303 | 1464:261b3d9a4903 | Chris | :group => :issue, |
| 10304 | 0:513646585e45 | Chris | :description => :comments |
| 10305 | |||
| 10306 | acts_as_activity_provider :timestamp => "#{table_name}.created_on",
|
||
| 10307 | :author_key => :user_id, |
||
| 10308 | 441:cbce1fd3b1b7 | Chris | :find_options => {:include => :project}
|
| 10309 | 0:513646585e45 | Chris | |
| 10310 | validates_presence_of :user_id, :activity_id, :project_id, :hours, :spent_on |
||
| 10311 | validates_numericality_of :hours, :allow_nil => true, :message => :invalid |
||
| 10312 | validates_length_of :comments, :maximum => 255, :allow_nil => true |
||
| 10313 | 1464:261b3d9a4903 | Chris | validates :spent_on, :date => true |
| 10314 | 909:cbb26bc654de | Chris | before_validation :set_project_if_nil |
| 10315 | validate :validate_time_entry |
||
| 10316 | 0:513646585e45 | Chris | |
| 10317 | 1464:261b3d9a4903 | Chris | scope :visible, lambda {|*args|
|
| 10318 | includes(:project).where(Project.allowed_to_condition(args.shift || User.current, :view_time_entries, *args)) |
||
| 10319 | } |
||
| 10320 | scope :on_issue, lambda {|issue|
|
||
| 10321 | includes(:issue).where("#{Issue.table_name}.root_id = #{issue.root_id} AND #{Issue.table_name}.lft >= #{issue.lft} AND #{Issue.table_name}.rgt <= #{issue.rgt}")
|
||
| 10322 | } |
||
| 10323 | scope :on_project, lambda {|project, include_subprojects|
|
||
| 10324 | includes(:project).where(project.project_condition(include_subprojects)) |
||
| 10325 | } |
||
| 10326 | 1115:433d4f72a19b | Chris | scope :spent_between, lambda {|from, to|
|
| 10327 | if from && to |
||
| 10328 | 1464:261b3d9a4903 | Chris | where("#{TimeEntry.table_name}.spent_on BETWEEN ? AND ?", from, to)
|
| 10329 | 1115:433d4f72a19b | Chris | elsif from |
| 10330 | 1464:261b3d9a4903 | Chris | where("#{TimeEntry.table_name}.spent_on >= ?", from)
|
| 10331 | 1115:433d4f72a19b | Chris | elsif to |
| 10332 | 1464:261b3d9a4903 | Chris | where("#{TimeEntry.table_name}.spent_on <= ?", to)
|
| 10333 | 1115:433d4f72a19b | Chris | else |
| 10334 | 1464:261b3d9a4903 | Chris | where(nil) |
| 10335 | 1115:433d4f72a19b | Chris | end |
| 10336 | } |
||
| 10337 | 441:cbce1fd3b1b7 | Chris | |
| 10338 | 1115:433d4f72a19b | Chris | safe_attributes 'hours', 'comments', 'issue_id', 'activity_id', 'spent_on', 'custom_field_values', 'custom_fields' |
| 10339 | 929:5f33065ddc4b | Chris | |
| 10340 | 1115:433d4f72a19b | Chris | def initialize(attributes=nil, *args) |
| 10341 | super |
||
| 10342 | 0:513646585e45 | Chris | if new_record? && self.activity.nil? |
| 10343 | if default_activity = TimeEntryActivity.default |
||
| 10344 | self.activity_id = default_activity.id |
||
| 10345 | end |
||
| 10346 | self.hours = nil if hours == 0 |
||
| 10347 | end |
||
| 10348 | end |
||
| 10349 | 441:cbce1fd3b1b7 | Chris | |
| 10350 | 1517:dffacf8a6908 | Chris | def safe_attributes=(attrs, user=User.current) |
| 10351 | attrs = super |
||
| 10352 | if !new_record? && issue && issue.project_id != project_id |
||
| 10353 | if user.allowed_to?(:log_time, issue.project) |
||
| 10354 | self.project_id = issue.project_id |
||
| 10355 | end |
||
| 10356 | end |
||
| 10357 | attrs |
||
| 10358 | end |
||
| 10359 | |||
| 10360 | 909:cbb26bc654de | Chris | def set_project_if_nil |
| 10361 | 0:513646585e45 | Chris | self.project = issue.project if issue && project.nil? |
| 10362 | end |
||
| 10363 | 441:cbce1fd3b1b7 | Chris | |
| 10364 | 909:cbb26bc654de | Chris | def validate_time_entry |
| 10365 | 0:513646585e45 | Chris | errors.add :hours, :invalid if hours && (hours < 0 || hours >= 1000) |
| 10366 | errors.add :project_id, :invalid if project.nil? |
||
| 10367 | errors.add :issue_id, :invalid if (issue_id && !issue) || (issue && project!=issue.project) |
||
| 10368 | end |
||
| 10369 | 441:cbce1fd3b1b7 | Chris | |
| 10370 | 0:513646585e45 | Chris | def hours=(h) |
| 10371 | write_attribute :hours, (h.is_a?(String) ? (h.to_hours || h) : h) |
||
| 10372 | end |
||
| 10373 | 441:cbce1fd3b1b7 | Chris | |
| 10374 | 1115:433d4f72a19b | Chris | def hours |
| 10375 | h = read_attribute(:hours) |
||
| 10376 | if h.is_a?(Float) |
||
| 10377 | h.round(2) |
||
| 10378 | else |
||
| 10379 | h |
||
| 10380 | end |
||
| 10381 | end |
||
| 10382 | |||
| 10383 | 0:513646585e45 | Chris | # tyear, tmonth, tweek assigned where setting spent_on attributes |
| 10384 | # these attributes make time aggregations easier |
||
| 10385 | def spent_on=(date) |
||
| 10386 | super |
||
| 10387 | 128:07fa8a8b56a8 | Chris | if spent_on.is_a?(Time) |
| 10388 | self.spent_on = spent_on.to_date |
||
| 10389 | end |
||
| 10390 | 0:513646585e45 | Chris | self.tyear = spent_on ? spent_on.year : nil |
| 10391 | self.tmonth = spent_on ? spent_on.month : nil |
||
| 10392 | self.tweek = spent_on ? Date.civil(spent_on.year, spent_on.month, spent_on.day).cweek : nil |
||
| 10393 | end |
||
| 10394 | 441:cbce1fd3b1b7 | Chris | |
| 10395 | 0:513646585e45 | Chris | # Returns true if the time entry can be edited by usr, otherwise false |
| 10396 | def editable_by?(usr) |
||
| 10397 | (usr == user && usr.allowed_to?(:edit_own_time_entries, project)) || usr.allowed_to?(:edit_time_entries, project) |
||
| 10398 | end |
||
| 10399 | end |
||
| 10400 | 909:cbb26bc654de | Chris | # Redmine - project management software |
| 10401 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 10402 | 0:513646585e45 | Chris | # |
| 10403 | # This program is free software; you can redistribute it and/or |
||
| 10404 | # modify it under the terms of the GNU General Public License |
||
| 10405 | # as published by the Free Software Foundation; either version 2 |
||
| 10406 | # of the License, or (at your option) any later version. |
||
| 10407 | 909:cbb26bc654de | Chris | # |
| 10408 | 0:513646585e45 | Chris | # This program is distributed in the hope that it will be useful, |
| 10409 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 10410 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 10411 | # GNU General Public License for more details. |
||
| 10412 | 909:cbb26bc654de | Chris | # |
| 10413 | 0:513646585e45 | Chris | # You should have received a copy of the GNU General Public License |
| 10414 | # along with this program; if not, write to the Free Software |
||
| 10415 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 10416 | |||
| 10417 | class TimeEntryActivity < Enumeration |
||
| 10418 | has_many :time_entries, :foreign_key => 'activity_id' |
||
| 10419 | |||
| 10420 | OptionName = :enumeration_activities |
||
| 10421 | 909:cbb26bc654de | Chris | |
| 10422 | 0:513646585e45 | Chris | def option_name |
| 10423 | OptionName |
||
| 10424 | end |
||
| 10425 | |||
| 10426 | 1464:261b3d9a4903 | Chris | def objects |
| 10427 | TimeEntry.where(:activity_id => self_and_descendants(1).map(&:id)) |
||
| 10428 | end |
||
| 10429 | |||
| 10430 | 0:513646585e45 | Chris | def objects_count |
| 10431 | 1464:261b3d9a4903 | Chris | objects.count |
| 10432 | 0:513646585e45 | Chris | end |
| 10433 | |||
| 10434 | def transfer_relations(to) |
||
| 10435 | 1464:261b3d9a4903 | Chris | objects.update_all(:activity_id => to.id) |
| 10436 | 0:513646585e45 | Chris | end |
| 10437 | end |
||
| 10438 | 909:cbb26bc654de | Chris | # Redmine - project management software |
| 10439 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 10440 | 0:513646585e45 | Chris | # |
| 10441 | # This program is free software; you can redistribute it and/or |
||
| 10442 | # modify it under the terms of the GNU General Public License |
||
| 10443 | # as published by the Free Software Foundation; either version 2 |
||
| 10444 | # of the License, or (at your option) any later version. |
||
| 10445 | 909:cbb26bc654de | Chris | # |
| 10446 | 0:513646585e45 | Chris | # This program is distributed in the hope that it will be useful, |
| 10447 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 10448 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 10449 | # GNU General Public License for more details. |
||
| 10450 | 909:cbb26bc654de | Chris | # |
| 10451 | 0:513646585e45 | Chris | # You should have received a copy of the GNU General Public License |
| 10452 | # along with this program; if not, write to the Free Software |
||
| 10453 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 10454 | |||
| 10455 | class TimeEntryActivityCustomField < CustomField |
||
| 10456 | def type_name |
||
| 10457 | :enumeration_activities |
||
| 10458 | end |
||
| 10459 | end |
||
| 10460 | 909:cbb26bc654de | Chris | # Redmine - project management software |
| 10461 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 10462 | 0:513646585e45 | Chris | # |
| 10463 | # This program is free software; you can redistribute it and/or |
||
| 10464 | # modify it under the terms of the GNU General Public License |
||
| 10465 | # as published by the Free Software Foundation; either version 2 |
||
| 10466 | # of the License, or (at your option) any later version. |
||
| 10467 | 909:cbb26bc654de | Chris | # |
| 10468 | 0:513646585e45 | Chris | # This program is distributed in the hope that it will be useful, |
| 10469 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 10470 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 10471 | # GNU General Public License for more details. |
||
| 10472 | 909:cbb26bc654de | Chris | # |
| 10473 | 0:513646585e45 | Chris | # You should have received a copy of the GNU General Public License |
| 10474 | # along with this program; if not, write to the Free Software |
||
| 10475 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 10476 | |||
| 10477 | class TimeEntryCustomField < CustomField |
||
| 10478 | def type_name |
||
| 10479 | :label_spent_time |
||
| 10480 | end |
||
| 10481 | end |
||
| 10482 | |||
| 10483 | 1464:261b3d9a4903 | Chris | # Redmine - project management software |
| 10484 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 10485 | 1464:261b3d9a4903 | Chris | # |
| 10486 | # This program is free software; you can redistribute it and/or |
||
| 10487 | # modify it under the terms of the GNU General Public License |
||
| 10488 | # as published by the Free Software Foundation; either version 2 |
||
| 10489 | # of the License, or (at your option) any later version. |
||
| 10490 | # |
||
| 10491 | # This program is distributed in the hope that it will be useful, |
||
| 10492 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 10493 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 10494 | # GNU General Public License for more details. |
||
| 10495 | # |
||
| 10496 | # You should have received a copy of the GNU General Public License |
||
| 10497 | # along with this program; if not, write to the Free Software |
||
| 10498 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 10499 | |||
| 10500 | class TimeEntryQuery < Query |
||
| 10501 | |||
| 10502 | self.queried_class = TimeEntry |
||
| 10503 | |||
| 10504 | self.available_columns = [ |
||
| 10505 | QueryColumn.new(:project, :sortable => "#{Project.table_name}.name", :groupable => true),
|
||
| 10506 | QueryColumn.new(:spent_on, :sortable => ["#{TimeEntry.table_name}.spent_on", "#{TimeEntry.table_name}.created_on"], :default_order => 'desc', :groupable => true),
|
||
| 10507 | QueryColumn.new(:user, :sortable => lambda {User.fields_for_order_statement}, :groupable => true),
|
||
| 10508 | QueryColumn.new(:activity, :sortable => "#{TimeEntryActivity.table_name}.position", :groupable => true),
|
||
| 10509 | QueryColumn.new(:issue, :sortable => "#{Issue.table_name}.id"),
|
||
| 10510 | QueryColumn.new(:comments), |
||
| 10511 | QueryColumn.new(:hours, :sortable => "#{TimeEntry.table_name}.hours"),
|
||
| 10512 | ] |
||
| 10513 | |||
| 10514 | def initialize(attributes=nil, *args) |
||
| 10515 | super attributes |
||
| 10516 | self.filters ||= {}
|
||
| 10517 | add_filter('spent_on', '*') unless filters.present?
|
||
| 10518 | end |
||
| 10519 | |||
| 10520 | def initialize_available_filters |
||
| 10521 | add_available_filter "spent_on", :type => :date_past |
||
| 10522 | |||
| 10523 | principals = [] |
||
| 10524 | if project |
||
| 10525 | principals += project.principals.sort |
||
| 10526 | unless project.leaf? |
||
| 10527 | subprojects = project.descendants.visible.all |
||
| 10528 | if subprojects.any? |
||
| 10529 | add_available_filter "subproject_id", |
||
| 10530 | :type => :list_subprojects, |
||
| 10531 | :values => subprojects.collect{|s| [s.name, s.id.to_s] }
|
||
| 10532 | principals += Principal.member_of(subprojects) |
||
| 10533 | end |
||
| 10534 | end |
||
| 10535 | else |
||
| 10536 | if all_projects.any? |
||
| 10537 | # members of visible projects |
||
| 10538 | principals += Principal.member_of(all_projects) |
||
| 10539 | # project filter |
||
| 10540 | project_values = [] |
||
| 10541 | if User.current.logged? && User.current.memberships.any? |
||
| 10542 | project_values << ["<< #{l(:label_my_projects).downcase} >>", "mine"]
|
||
| 10543 | end |
||
| 10544 | project_values += all_projects_values |
||
| 10545 | add_available_filter("project_id",
|
||
| 10546 | :type => :list, :values => project_values |
||
| 10547 | ) unless project_values.empty? |
||
| 10548 | end |
||
| 10549 | end |
||
| 10550 | principals.uniq! |
||
| 10551 | principals.sort! |
||
| 10552 | users = principals.select {|p| p.is_a?(User)}
|
||
| 10553 | |||
| 10554 | users_values = [] |
||
| 10555 | users_values << ["<< #{l(:label_me)} >>", "me"] if User.current.logged?
|
||
| 10556 | users_values += users.collect{|s| [s.name, s.id.to_s] }
|
||
| 10557 | add_available_filter("user_id",
|
||
| 10558 | :type => :list_optional, :values => users_values |
||
| 10559 | ) unless users_values.empty? |
||
| 10560 | |||
| 10561 | activities = (project ? project.activities : TimeEntryActivity.shared.active) |
||
| 10562 | add_available_filter("activity_id",
|
||
| 10563 | :type => :list, :values => activities.map {|a| [a.name, a.id.to_s]}
|
||
| 10564 | ) unless activities.empty? |
||
| 10565 | |||
| 10566 | add_available_filter "comments", :type => :text |
||
| 10567 | add_available_filter "hours", :type => :float |
||
| 10568 | |||
| 10569 | add_custom_fields_filters(TimeEntryCustomField) |
||
| 10570 | add_associations_custom_fields_filters :project, :issue, :user |
||
| 10571 | end |
||
| 10572 | |||
| 10573 | def available_columns |
||
| 10574 | return @available_columns if @available_columns |
||
| 10575 | @available_columns = self.class.available_columns.dup |
||
| 10576 | 1517:dffacf8a6908 | Chris | @available_columns += TimeEntryCustomField.visible. |
| 10577 | map {|cf| QueryCustomFieldColumn.new(cf) }
|
||
| 10578 | @available_columns += IssueCustomField.visible. |
||
| 10579 | map {|cf| QueryAssociationCustomFieldColumn.new(:issue, cf) }
|
||
| 10580 | 1464:261b3d9a4903 | Chris | @available_columns |
| 10581 | end |
||
| 10582 | |||
| 10583 | def default_columns_names |
||
| 10584 | @default_columns_names ||= [:project, :spent_on, :user, :activity, :issue, :comments, :hours] |
||
| 10585 | end |
||
| 10586 | |||
| 10587 | def results_scope(options={})
|
||
| 10588 | order_option = [group_by_sort_order, options[:order]].flatten.reject(&:blank?) |
||
| 10589 | |||
| 10590 | TimeEntry.visible. |
||
| 10591 | where(statement). |
||
| 10592 | order(order_option). |
||
| 10593 | joins(joins_for_order_statement(order_option.join(','))).
|
||
| 10594 | includes(:activity) |
||
| 10595 | end |
||
| 10596 | |||
| 10597 | def sql_for_activity_id_field(field, operator, value) |
||
| 10598 | condition_on_id = sql_for_field(field, operator, value, Enumeration.table_name, 'id') |
||
| 10599 | condition_on_parent_id = sql_for_field(field, operator, value, Enumeration.table_name, 'parent_id') |
||
| 10600 | ids = value.map(&:to_i).join(',')
|
||
| 10601 | table_name = Enumeration.table_name |
||
| 10602 | if operator == '=' |
||
| 10603 | "(#{table_name}.id IN (#{ids}) OR #{table_name}.parent_id IN (#{ids}))"
|
||
| 10604 | else |
||
| 10605 | "(#{table_name}.id NOT IN (#{ids}) AND (#{table_name}.parent_id IS NULL OR #{table_name}.parent_id NOT IN (#{ids})))"
|
||
| 10606 | end |
||
| 10607 | end |
||
| 10608 | |||
| 10609 | # Accepts :from/:to params as shortcut filters |
||
| 10610 | def build_from_params(params) |
||
| 10611 | super |
||
| 10612 | if params[:from].present? && params[:to].present? |
||
| 10613 | add_filter('spent_on', '><', [params[:from], params[:to]])
|
||
| 10614 | elsif params[:from].present? |
||
| 10615 | add_filter('spent_on', '>=', [params[:from]])
|
||
| 10616 | elsif params[:to].present? |
||
| 10617 | add_filter('spent_on', '<=', [params[:to]])
|
||
| 10618 | end |
||
| 10619 | self |
||
| 10620 | end |
||
| 10621 | end |
||
| 10622 | 0:513646585e45 | Chris | # Redmine - project management software |
| 10623 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 10624 | 0:513646585e45 | Chris | # |
| 10625 | # This program is free software; you can redistribute it and/or |
||
| 10626 | # modify it under the terms of the GNU General Public License |
||
| 10627 | # as published by the Free Software Foundation; either version 2 |
||
| 10628 | # of the License, or (at your option) any later version. |
||
| 10629 | 909:cbb26bc654de | Chris | # |
| 10630 | 0:513646585e45 | Chris | # This program is distributed in the hope that it will be useful, |
| 10631 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 10632 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 10633 | # GNU General Public License for more details. |
||
| 10634 | 909:cbb26bc654de | Chris | # |
| 10635 | 0:513646585e45 | Chris | # You should have received a copy of the GNU General Public License |
| 10636 | # along with this program; if not, write to the Free Software |
||
| 10637 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 10638 | |||
| 10639 | class Token < ActiveRecord::Base |
||
| 10640 | belongs_to :user |
||
| 10641 | validates_uniqueness_of :value |
||
| 10642 | 909:cbb26bc654de | Chris | |
| 10643 | before_create :delete_previous_tokens, :generate_new_token |
||
| 10644 | |||
| 10645 | 0:513646585e45 | Chris | @@validity_time = 1.day |
| 10646 | 909:cbb26bc654de | Chris | |
| 10647 | def generate_new_token |
||
| 10648 | 0:513646585e45 | Chris | self.value = Token.generate_token_value |
| 10649 | end |
||
| 10650 | |||
| 10651 | 909:cbb26bc654de | Chris | # Return true if token has expired |
| 10652 | 0:513646585e45 | Chris | def expired? |
| 10653 | return Time.now > self.created_on + @@validity_time |
||
| 10654 | end |
||
| 10655 | 909:cbb26bc654de | Chris | |
| 10656 | 0:513646585e45 | Chris | # Delete all expired tokens |
| 10657 | def self.destroy_expired |
||
| 10658 | 1115:433d4f72a19b | Chris | Token.delete_all ["action NOT IN (?) AND created_on < ?", ['feeds', 'api'], Time.now - @@validity_time] |
| 10659 | 0:513646585e45 | Chris | end |
| 10660 | 909:cbb26bc654de | Chris | |
| 10661 | 1464:261b3d9a4903 | Chris | # Returns the active user who owns the key for the given action |
| 10662 | def self.find_active_user(action, key, validity_days=nil) |
||
| 10663 | user = find_user(action, key, validity_days) |
||
| 10664 | if user && user.active? |
||
| 10665 | user |
||
| 10666 | end |
||
| 10667 | end |
||
| 10668 | |||
| 10669 | # Returns the user who owns the key for the given action |
||
| 10670 | def self.find_user(action, key, validity_days=nil) |
||
| 10671 | token = find_token(action, key, validity_days) |
||
| 10672 | if token |
||
| 10673 | token.user |
||
| 10674 | end |
||
| 10675 | end |
||
| 10676 | |||
| 10677 | # Returns the token for action and key with an optional |
||
| 10678 | # validity duration (in number of days) |
||
| 10679 | def self.find_token(action, key, validity_days=nil) |
||
| 10680 | action = action.to_s |
||
| 10681 | key = key.to_s |
||
| 10682 | return nil unless action.present? && key =~ /\A[a-z0-9]+\z/i |
||
| 10683 | |||
| 10684 | token = Token.where(:action => action, :value => key).first |
||
| 10685 | if token && (token.action == action) && (token.value == key) && token.user |
||
| 10686 | if validity_days.nil? || (token.created_on > validity_days.days.ago) |
||
| 10687 | token |
||
| 10688 | end |
||
| 10689 | end |
||
| 10690 | end |
||
| 10691 | |||
| 10692 | 0:513646585e45 | Chris | def self.generate_token_value |
| 10693 | 1115:433d4f72a19b | Chris | Redmine::Utils.random_hex(20) |
| 10694 | 0:513646585e45 | Chris | end |
| 10695 | 909:cbb26bc654de | Chris | |
| 10696 | 1464:261b3d9a4903 | Chris | private |
| 10697 | |||
| 10698 | 0:513646585e45 | Chris | # Removes obsolete tokens (same user and action) |
| 10699 | def delete_previous_tokens |
||
| 10700 | if user |
||
| 10701 | Token.delete_all(['user_id = ? AND action = ?', user.id, action]) |
||
| 10702 | end |
||
| 10703 | end |
||
| 10704 | end |
||
| 10705 | 909:cbb26bc654de | Chris | # Redmine - project management software |
| 10706 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 10707 | 0:513646585e45 | Chris | # |
| 10708 | # This program is free software; you can redistribute it and/or |
||
| 10709 | # modify it under the terms of the GNU General Public License |
||
| 10710 | # as published by the Free Software Foundation; either version 2 |
||
| 10711 | # of the License, or (at your option) any later version. |
||
| 10712 | 909:cbb26bc654de | Chris | # |
| 10713 | 0:513646585e45 | Chris | # This program is distributed in the hope that it will be useful, |
| 10714 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 10715 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 10716 | # GNU General Public License for more details. |
||
| 10717 | 909:cbb26bc654de | Chris | # |
| 10718 | 0:513646585e45 | Chris | # You should have received a copy of the GNU General Public License |
| 10719 | # along with this program; if not, write to the Free Software |
||
| 10720 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 10721 | |||
| 10722 | class Tracker < ActiveRecord::Base |
||
| 10723 | 1115:433d4f72a19b | Chris | |
| 10724 | CORE_FIELDS_UNDISABLABLE = %w(project_id tracker_id subject description priority_id is_private).freeze |
||
| 10725 | # Fields that can be disabled |
||
| 10726 | # Other (future) fields should be appended, not inserted! |
||
| 10727 | CORE_FIELDS = %w(assigned_to_id category_id fixed_version_id parent_issue_id start_date due_date estimated_hours done_ratio).freeze |
||
| 10728 | CORE_FIELDS_ALL = (CORE_FIELDS_UNDISABLABLE + CORE_FIELDS).freeze |
||
| 10729 | |||
| 10730 | 909:cbb26bc654de | Chris | before_destroy :check_integrity |
| 10731 | 0:513646585e45 | Chris | has_many :issues |
| 10732 | 1115:433d4f72a19b | Chris | has_many :workflow_rules, :dependent => :delete_all do |
| 10733 | 0:513646585e45 | Chris | def copy(source_tracker) |
| 10734 | 1115:433d4f72a19b | Chris | WorkflowRule.copy(source_tracker, nil, proxy_association.owner, nil) |
| 10735 | 0:513646585e45 | Chris | end |
| 10736 | end |
||
| 10737 | 909:cbb26bc654de | Chris | |
| 10738 | 0:513646585e45 | Chris | has_and_belongs_to_many :projects |
| 10739 | has_and_belongs_to_many :custom_fields, :class_name => 'IssueCustomField', :join_table => "#{table_name_prefix}custom_fields_trackers#{table_name_suffix}", :association_foreign_key => 'custom_field_id'
|
||
| 10740 | acts_as_list |
||
| 10741 | |||
| 10742 | 1464:261b3d9a4903 | Chris | attr_protected :fields_bits |
| 10743 | 1115:433d4f72a19b | Chris | |
| 10744 | 0:513646585e45 | Chris | validates_presence_of :name |
| 10745 | validates_uniqueness_of :name |
||
| 10746 | validates_length_of :name, :maximum => 30 |
||
| 10747 | |||
| 10748 | 1464:261b3d9a4903 | Chris | scope :sorted, lambda { order("#{table_name}.position ASC") }
|
| 10749 | 1115:433d4f72a19b | Chris | scope :named, lambda {|arg| where("LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip)}
|
| 10750 | 909:cbb26bc654de | Chris | |
| 10751 | 0:513646585e45 | Chris | def to_s; name end |
| 10752 | 909:cbb26bc654de | Chris | |
| 10753 | 0:513646585e45 | Chris | def <=>(tracker) |
| 10754 | 1115:433d4f72a19b | Chris | position <=> tracker.position |
| 10755 | 0:513646585e45 | Chris | end |
| 10756 | 909:cbb26bc654de | Chris | |
| 10757 | 0:513646585e45 | Chris | # Returns an array of IssueStatus that are used |
| 10758 | # in the tracker's workflows |
||
| 10759 | def issue_statuses |
||
| 10760 | if @issue_statuses |
||
| 10761 | 909:cbb26bc654de | Chris | return @issue_statuses |
| 10762 | 0:513646585e45 | Chris | elsif new_record? |
| 10763 | return [] |
||
| 10764 | end |
||
| 10765 | 909:cbb26bc654de | Chris | |
| 10766 | 1115:433d4f72a19b | Chris | ids = WorkflowTransition. |
| 10767 | connection.select_rows("SELECT DISTINCT old_status_id, new_status_id FROM #{WorkflowTransition.table_name} WHERE tracker_id = #{id} AND type = 'WorkflowTransition'").
|
||
| 10768 | 0:513646585e45 | Chris | flatten. |
| 10769 | uniq |
||
| 10770 | 1517:dffacf8a6908 | Chris | @issue_statuses = IssueStatus.where(:id => ids).all.sort |
| 10771 | 0:513646585e45 | Chris | end |
| 10772 | 909:cbb26bc654de | Chris | |
| 10773 | 1115:433d4f72a19b | Chris | def disabled_core_fields |
| 10774 | i = -1 |
||
| 10775 | @disabled_core_fields ||= CORE_FIELDS.select { i += 1; (fields_bits || 0) & (2 ** i) != 0}
|
||
| 10776 | end |
||
| 10777 | |||
| 10778 | def core_fields |
||
| 10779 | CORE_FIELDS - disabled_core_fields |
||
| 10780 | end |
||
| 10781 | |||
| 10782 | def core_fields=(fields) |
||
| 10783 | raise ArgumentError.new("Tracker.core_fields takes an array") unless fields.is_a?(Array)
|
||
| 10784 | |||
| 10785 | bits = 0 |
||
| 10786 | CORE_FIELDS.each_with_index do |field, i| |
||
| 10787 | unless fields.include?(field) |
||
| 10788 | bits |= 2 ** i |
||
| 10789 | end |
||
| 10790 | end |
||
| 10791 | self.fields_bits = bits |
||
| 10792 | @disabled_core_fields = nil |
||
| 10793 | core_fields |
||
| 10794 | end |
||
| 10795 | |||
| 10796 | # Returns the fields that are disabled for all the given trackers |
||
| 10797 | def self.disabled_core_fields(trackers) |
||
| 10798 | if trackers.present? |
||
| 10799 | trackers.uniq.map(&:disabled_core_fields).reduce(:&) |
||
| 10800 | else |
||
| 10801 | [] |
||
| 10802 | end |
||
| 10803 | end |
||
| 10804 | |||
| 10805 | # Returns the fields that are enabled for one tracker at least |
||
| 10806 | def self.core_fields(trackers) |
||
| 10807 | if trackers.present? |
||
| 10808 | trackers.uniq.map(&:core_fields).reduce(:|) |
||
| 10809 | else |
||
| 10810 | CORE_FIELDS.dup |
||
| 10811 | end |
||
| 10812 | end |
||
| 10813 | |||
| 10814 | 0:513646585e45 | Chris | private |
| 10815 | def check_integrity |
||
| 10816 | 1115:433d4f72a19b | Chris | raise Exception.new("Can't delete tracker") if Issue.where(:tracker_id => self.id).any?
|
| 10817 | 0:513646585e45 | Chris | end |
| 10818 | end |
||
| 10819 | # Redmine - project management software |
||
| 10820 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 10821 | 0:513646585e45 | Chris | # |
| 10822 | # This program is free software; you can redistribute it and/or |
||
| 10823 | # modify it under the terms of the GNU General Public License |
||
| 10824 | # as published by the Free Software Foundation; either version 2 |
||
| 10825 | # of the License, or (at your option) any later version. |
||
| 10826 | 909:cbb26bc654de | Chris | # |
| 10827 | 0:513646585e45 | Chris | # This program is distributed in the hope that it will be useful, |
| 10828 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 10829 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 10830 | # GNU General Public License for more details. |
||
| 10831 | 909:cbb26bc654de | Chris | # |
| 10832 | 0:513646585e45 | Chris | # You should have received a copy of the GNU General Public License |
| 10833 | # along with this program; if not, write to the Free Software |
||
| 10834 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 10835 | |||
| 10836 | require "digest/sha1" |
||
| 10837 | |||
| 10838 | class User < Principal |
||
| 10839 | 119:8661b858af72 | Chris | include Redmine::SafeAttributes |
| 10840 | 909:cbb26bc654de | Chris | |
| 10841 | # Different ways of displaying/sorting users |
||
| 10842 | 0:513646585e45 | Chris | USER_FORMATS = {
|
| 10843 | 1115:433d4f72a19b | Chris | :firstname_lastname => {
|
| 10844 | :string => '#{firstname} #{lastname}',
|
||
| 10845 | :order => %w(firstname lastname id), |
||
| 10846 | :setting_order => 1 |
||
| 10847 | }, |
||
| 10848 | :firstname_lastinitial => {
|
||
| 10849 | :string => '#{firstname} #{lastname.to_s.chars.first}.',
|
||
| 10850 | :order => %w(firstname lastname id), |
||
| 10851 | :setting_order => 2 |
||
| 10852 | }, |
||
| 10853 | 1517:dffacf8a6908 | Chris | :firstinitial_lastname => {
|
| 10854 | :string => '#{firstname.to_s.gsub(/(([[:alpha:]])[[:alpha:]]*\.?)/, \'\2.\')} #{lastname}',
|
||
| 10855 | :order => %w(firstname lastname id), |
||
| 10856 | :setting_order => 2 |
||
| 10857 | }, |
||
| 10858 | 1115:433d4f72a19b | Chris | :firstname => {
|
| 10859 | :string => '#{firstname}',
|
||
| 10860 | :order => %w(firstname id), |
||
| 10861 | :setting_order => 3 |
||
| 10862 | }, |
||
| 10863 | :lastname_firstname => {
|
||
| 10864 | :string => '#{lastname} #{firstname}',
|
||
| 10865 | :order => %w(lastname firstname id), |
||
| 10866 | :setting_order => 4 |
||
| 10867 | }, |
||
| 10868 | :lastname_coma_firstname => {
|
||
| 10869 | :string => '#{lastname}, #{firstname}',
|
||
| 10870 | :order => %w(lastname firstname id), |
||
| 10871 | :setting_order => 5 |
||
| 10872 | }, |
||
| 10873 | :lastname => {
|
||
| 10874 | :string => '#{lastname}',
|
||
| 10875 | :order => %w(lastname id), |
||
| 10876 | :setting_order => 6 |
||
| 10877 | }, |
||
| 10878 | :username => {
|
||
| 10879 | :string => '#{login}',
|
||
| 10880 | :order => %w(login id), |
||
| 10881 | :setting_order => 7 |
||
| 10882 | }, |
||
| 10883 | 0:513646585e45 | Chris | } |
| 10884 | |||
| 10885 | 37:94944d00e43c | chris | MAIL_NOTIFICATION_OPTIONS = [ |
| 10886 | 119:8661b858af72 | Chris | ['all', :label_user_mail_option_all], |
| 10887 | ['selected', :label_user_mail_option_selected], |
||
| 10888 | ['only_my_events', :label_user_mail_option_only_my_events], |
||
| 10889 | ['only_assigned', :label_user_mail_option_only_assigned], |
||
| 10890 | ['only_owner', :label_user_mail_option_only_owner], |
||
| 10891 | ['none', :label_user_mail_option_none] |
||
| 10892 | ] |
||
| 10893 | 37:94944d00e43c | chris | |
| 10894 | 1517:dffacf8a6908 | Chris | has_and_belongs_to_many :groups, |
| 10895 | :join_table => "#{table_name_prefix}groups_users#{table_name_suffix}",
|
||
| 10896 | :after_add => Proc.new {|user, group| group.user_added(user)},
|
||
| 10897 | :after_remove => Proc.new {|user, group| group.user_removed(user)}
|
||
| 10898 | 0:513646585e45 | Chris | has_many :changesets, :dependent => :nullify |
| 10899 | has_one :preference, :dependent => :destroy, :class_name => 'UserPreference' |
||
| 10900 | 128:07fa8a8b56a8 | Chris | has_one :rss_token, :class_name => 'Token', :conditions => "action='feeds'" |
| 10901 | has_one :api_token, :class_name => 'Token', :conditions => "action='api'" |
||
| 10902 | 0:513646585e45 | Chris | belongs_to :auth_source |
| 10903 | 909:cbb26bc654de | Chris | |
| 10904 | 1464:261b3d9a4903 | Chris | scope :logged, lambda { where("#{User.table_name}.status <> #{STATUS_ANONYMOUS}") }
|
| 10905 | scope :status, lambda {|arg| where(arg.blank? ? nil : {:status => arg.to_i}) }
|
||
| 10906 | 909:cbb26bc654de | Chris | |
| 10907 | 55:bbb139d5ca95 | luisf | has_one :ssamr_user_detail, :dependent => :destroy, :class_name => 'SsamrUserDetail' |
| 10908 | 65:69ee2e406f71 | luisf | accepts_nested_attributes_for :ssamr_user_detail |
| 10909 | 403:b15397a5341c | luis | |
| 10910 | has_one :author |
||
| 10911 | 64:9d42bcda8cea | luisf | |
| 10912 | 0:513646585e45 | Chris | acts_as_customizable |
| 10913 | 909:cbb26bc654de | Chris | |
| 10914 | 1464:261b3d9a4903 | Chris | attr_accessor :password, :password_confirmation, :generate_password |
| 10915 | 0:513646585e45 | Chris | attr_accessor :last_before_login_on |
| 10916 | # Prevents unauthorized assignments |
||
| 10917 | 119:8661b858af72 | Chris | attr_protected :login, :admin, :password, :password_confirmation, :hashed_password |
| 10918 | 1115:433d4f72a19b | Chris | |
| 10919 | LOGIN_LENGTH_LIMIT = 60 |
||
| 10920 | MAIL_LENGTH_LIMIT = 60 |
||
| 10921 | |||
| 10922 | 0:513646585e45 | Chris | validates_presence_of :login, :firstname, :lastname, :mail, :if => Proc.new { |user| !user.is_a?(AnonymousUser) }
|
| 10923 | 1115:433d4f72a19b | Chris | validates_uniqueness_of :login, :if => Proc.new { |user| user.login_changed? && user.login.present? }, :case_sensitive => false
|
| 10924 | validates_uniqueness_of :mail, :if => Proc.new { |user| user.mail_changed? && user.mail.present? }, :case_sensitive => false
|
||
| 10925 | 1484:51364c0cd58f | Chris | |
| 10926 | 1464:261b3d9a4903 | Chris | # Login must contain letters, numbers, underscores only |
| 10927 | validates_format_of :login, :with => /\A[a-z0-9_\-@\.]*\z/i |
||
| 10928 | 1115:433d4f72a19b | Chris | validates_length_of :login, :maximum => LOGIN_LENGTH_LIMIT |
| 10929 | 0:513646585e45 | Chris | validates_length_of :firstname, :lastname, :maximum => 30 |
| 10930 | 1464:261b3d9a4903 | Chris | validates_format_of :mail, :with => /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\z/i, :allow_blank => true
|
| 10931 | 1115:433d4f72a19b | Chris | validates_length_of :mail, :maximum => MAIL_LENGTH_LIMIT, :allow_nil => true |
| 10932 | 0:513646585e45 | Chris | validates_confirmation_of :password, :allow_nil => true |
| 10933 | 119:8661b858af72 | Chris | validates_inclusion_of :mail_notification, :in => MAIL_NOTIFICATION_OPTIONS.collect(&:first), :allow_blank => true |
| 10934 | 909:cbb26bc654de | Chris | validate :validate_password_length |
| 10935 | 0:513646585e45 | Chris | |
| 10936 | 909:cbb26bc654de | Chris | before_create :set_mail_notification |
| 10937 | 1464:261b3d9a4903 | Chris | before_save :generate_password_if_needed, :update_hashed_password |
| 10938 | 128:07fa8a8b56a8 | Chris | before_destroy :remove_references_before_destroy |
| 10939 | 1464:261b3d9a4903 | Chris | after_save :update_notified_project_ids |
| 10940 | 909:cbb26bc654de | Chris | |
| 10941 | 190:440c4f4bf2d6 | luisf | validates_acceptance_of :terms_and_conditions, :on => :create, :message => :must_accept_terms_and_conditions |
| 10942 | |||
| 10943 | 1115:433d4f72a19b | Chris | scope :in_group, lambda {|group|
|
| 10944 | 441:cbce1fd3b1b7 | Chris | group_id = group.is_a?(Group) ? group.id : group.to_i |
| 10945 | 1115:433d4f72a19b | Chris | where("#{User.table_name}.id IN (SELECT gu.user_id FROM #{table_name_prefix}groups_users#{table_name_suffix} gu WHERE gu.group_id = ?)", group_id)
|
| 10946 | 441:cbce1fd3b1b7 | Chris | } |
| 10947 | 1115:433d4f72a19b | Chris | scope :not_in_group, lambda {|group|
|
| 10948 | 441:cbce1fd3b1b7 | Chris | group_id = group.is_a?(Group) ? group.id : group.to_i |
| 10949 | 1115:433d4f72a19b | Chris | where("#{User.table_name}.id NOT IN (SELECT gu.user_id FROM #{table_name_prefix}groups_users#{table_name_suffix} gu WHERE gu.group_id = ?)", group_id)
|
| 10950 | 441:cbce1fd3b1b7 | Chris | } |
| 10951 | 1464:261b3d9a4903 | Chris | scope :sorted, lambda { order(*User.fields_for_order_statement)}
|
| 10952 | 909:cbb26bc654de | Chris | |
| 10953 | def set_mail_notification |
||
| 10954 | 37:94944d00e43c | chris | self.mail_notification = Setting.default_notification_option if self.mail_notification.blank? |
| 10955 | 0:513646585e45 | Chris | true |
| 10956 | end |
||
| 10957 | 909:cbb26bc654de | Chris | |
| 10958 | def update_hashed_password |
||
| 10959 | 0:513646585e45 | Chris | # update hashed_password if password was set |
| 10960 | 245:051f544170fe | Chris | if self.password && self.auth_source_id.blank? |
| 10961 | salt_password(password) |
||
| 10962 | end |
||
| 10963 | 0:513646585e45 | Chris | end |
| 10964 | 909:cbb26bc654de | Chris | |
| 10965 | 1464:261b3d9a4903 | Chris | alias :base_reload :reload |
| 10966 | 0:513646585e45 | Chris | def reload(*args) |
| 10967 | @name = nil |
||
| 10968 | 441:cbce1fd3b1b7 | Chris | @projects_by_role = nil |
| 10969 | 1464:261b3d9a4903 | Chris | @membership_by_project_id = nil |
| 10970 | @notified_projects_ids = nil |
||
| 10971 | @notified_projects_ids_changed = false |
||
| 10972 | @builtin_role = nil |
||
| 10973 | base_reload(*args) |
||
| 10974 | 0:513646585e45 | Chris | end |
| 10975 | 909:cbb26bc654de | Chris | |
| 10976 | 1:cca12e1c1fd4 | Chris | def mail=(arg) |
| 10977 | write_attribute(:mail, arg.to_s.strip) |
||
| 10978 | end |
||
| 10979 | 909:cbb26bc654de | Chris | |
| 10980 | 59:7ff14a13f48a | luisf | def description=(arg) |
| 10981 | write_attribute(:description, arg.to_s.strip) |
||
| 10982 | end |
||
| 10983 | |||
| 10984 | 0:513646585e45 | Chris | def identity_url=(url) |
| 10985 | if url.blank? |
||
| 10986 | write_attribute(:identity_url, '') |
||
| 10987 | else |
||
| 10988 | begin |
||
| 10989 | write_attribute(:identity_url, OpenIdAuthentication.normalize_identifier(url)) |
||
| 10990 | rescue OpenIdAuthentication::InvalidOpenId |
||
| 10991 | 1464:261b3d9a4903 | Chris | # Invalid url, don't save |
| 10992 | 0:513646585e45 | Chris | end |
| 10993 | end |
||
| 10994 | self.read_attribute(:identity_url) |
||
| 10995 | end |
||
| 10996 | 909:cbb26bc654de | Chris | |
| 10997 | 0:513646585e45 | Chris | # Returns the user that matches provided login and password, or nil |
| 10998 | 1464:261b3d9a4903 | Chris | def self.try_to_login(login, password, active_only=true) |
| 10999 | 1115:433d4f72a19b | Chris | login = login.to_s |
| 11000 | password = password.to_s |
||
| 11001 | |||
| 11002 | 1464:261b3d9a4903 | Chris | # Make sure no one can sign in with an empty login or password |
| 11003 | return nil if login.empty? || password.empty? |
||
| 11004 | 0:513646585e45 | Chris | user = find_by_login(login) |
| 11005 | if user |
||
| 11006 | # user is already in local database |
||
| 11007 | 1464:261b3d9a4903 | Chris | return nil unless user.check_password?(password) |
| 11008 | return nil if !user.active? && active_only |
||
| 11009 | 0:513646585e45 | Chris | else |
| 11010 | # user is not yet registered, try to authenticate with available sources |
||
| 11011 | attrs = AuthSource.authenticate(login, password) |
||
| 11012 | if attrs |
||
| 11013 | user = new(attrs) |
||
| 11014 | user.login = login |
||
| 11015 | user.language = Setting.default_language |
||
| 11016 | if user.save |
||
| 11017 | user.reload |
||
| 11018 | logger.info("User '#{user.login}' created from external auth source: #{user.auth_source.type} - #{user.auth_source.name}") if logger && user.auth_source
|
||
| 11019 | end |
||
| 11020 | end |
||
| 11021 | 909:cbb26bc654de | Chris | end |
| 11022 | 1464:261b3d9a4903 | Chris | user.update_column(:last_login_on, Time.now) if user && !user.new_record? && user.active? |
| 11023 | 0:513646585e45 | Chris | user |
| 11024 | rescue => text |
||
| 11025 | raise text |
||
| 11026 | end |
||
| 11027 | 909:cbb26bc654de | Chris | |
| 11028 | 0:513646585e45 | Chris | # Returns the user who matches the given autologin +key+ or nil |
| 11029 | def self.try_to_autologin(key) |
||
| 11030 | 1464:261b3d9a4903 | Chris | user = Token.find_active_user('autologin', key, Setting.autologin.to_i)
|
| 11031 | if user |
||
| 11032 | user.update_column(:last_login_on, Time.now) |
||
| 11033 | user |
||
| 11034 | 0:513646585e45 | Chris | end |
| 11035 | end |
||
| 11036 | 909:cbb26bc654de | Chris | |
| 11037 | def self.name_formatter(formatter = nil) |
||
| 11038 | USER_FORMATS[formatter || Setting.user_format] || USER_FORMATS[:firstname_lastname] |
||
| 11039 | end |
||
| 11040 | |||
| 11041 | # Returns an array of fields names than can be used to make an order statement for users |
||
| 11042 | # according to how user names are displayed |
||
| 11043 | # Examples: |
||
| 11044 | # |
||
| 11045 | # User.fields_for_order_statement => ['users.login', 'users.id'] |
||
| 11046 | # User.fields_for_order_statement('authors') => ['authors.login', 'authors.id']
|
||
| 11047 | def self.fields_for_order_statement(table=nil) |
||
| 11048 | table ||= table_name |
||
| 11049 | name_formatter[:order].map {|field| "#{table}.#{field}"}
|
||
| 11050 | end |
||
| 11051 | |||
| 11052 | 0:513646585e45 | Chris | # Return user's full name for display |
| 11053 | def name(formatter = nil) |
||
| 11054 | 909:cbb26bc654de | Chris | f = self.class.name_formatter(formatter) |
| 11055 | 0:513646585e45 | Chris | if formatter |
| 11056 | 909:cbb26bc654de | Chris | eval('"' + f[:string] + '"')
|
| 11057 | 0:513646585e45 | Chris | else |
| 11058 | 909:cbb26bc654de | Chris | @name ||= eval('"' + f[:string] + '"')
|
| 11059 | 0:513646585e45 | Chris | end |
| 11060 | end |
||
| 11061 | 909:cbb26bc654de | Chris | |
| 11062 | 0:513646585e45 | Chris | def active? |
| 11063 | self.status == STATUS_ACTIVE |
||
| 11064 | end |
||
| 11065 | |||
| 11066 | def registered? |
||
| 11067 | self.status == STATUS_REGISTERED |
||
| 11068 | end |
||
| 11069 | 909:cbb26bc654de | Chris | |
| 11070 | 0:513646585e45 | Chris | def locked? |
| 11071 | self.status == STATUS_LOCKED |
||
| 11072 | end |
||
| 11073 | |||
| 11074 | 14:1d32c0a0efbf | Chris | def activate |
| 11075 | self.status = STATUS_ACTIVE |
||
| 11076 | end |
||
| 11077 | |||
| 11078 | def register |
||
| 11079 | self.status = STATUS_REGISTERED |
||
| 11080 | end |
||
| 11081 | |||
| 11082 | def lock |
||
| 11083 | self.status = STATUS_LOCKED |
||
| 11084 | end |
||
| 11085 | |||
| 11086 | def activate! |
||
| 11087 | update_attribute(:status, STATUS_ACTIVE) |
||
| 11088 | end |
||
| 11089 | |||
| 11090 | def register! |
||
| 11091 | update_attribute(:status, STATUS_REGISTERED) |
||
| 11092 | end |
||
| 11093 | |||
| 11094 | def lock! |
||
| 11095 | update_attribute(:status, STATUS_LOCKED) |
||
| 11096 | end |
||
| 11097 | |||
| 11098 | 245:051f544170fe | Chris | # Returns true if +clear_password+ is the correct user's password, otherwise false |
| 11099 | 0:513646585e45 | Chris | def check_password?(clear_password) |
| 11100 | if auth_source_id.present? |
||
| 11101 | auth_source.authenticate(self.login, clear_password) |
||
| 11102 | else |
||
| 11103 | 245:051f544170fe | Chris | User.hash_password("#{salt}#{User.hash_password clear_password}") == hashed_password
|
| 11104 | 0:513646585e45 | Chris | end |
| 11105 | end |
||
| 11106 | 909:cbb26bc654de | Chris | |
| 11107 | 245:051f544170fe | Chris | # Generates a random salt and computes hashed_password for +clear_password+ |
| 11108 | # The hashed password is stored in the following form: SHA1(salt + SHA1(password)) |
||
| 11109 | def salt_password(clear_password) |
||
| 11110 | self.salt = User.generate_salt |
||
| 11111 | self.hashed_password = User.hash_password("#{salt}#{User.hash_password clear_password}")
|
||
| 11112 | end |
||
| 11113 | 0:513646585e45 | Chris | |
| 11114 | # Does the backend storage allow this user to change their password? |
||
| 11115 | def change_password_allowed? |
||
| 11116 | 1115:433d4f72a19b | Chris | return true if auth_source.nil? |
| 11117 | 0:513646585e45 | Chris | return auth_source.allow_password_changes? |
| 11118 | end |
||
| 11119 | |||
| 11120 | 1464:261b3d9a4903 | Chris | def must_change_password? |
| 11121 | must_change_passwd? && change_password_allowed? |
||
| 11122 | end |
||
| 11123 | |||
| 11124 | def generate_password? |
||
| 11125 | generate_password == '1' || generate_password == true |
||
| 11126 | end |
||
| 11127 | |||
| 11128 | # Generate and set a random password on given length |
||
| 11129 | def random_password(length=40) |
||
| 11130 | 0:513646585e45 | Chris | chars = ("a".."z").to_a + ("A".."Z").to_a + ("0".."9").to_a
|
| 11131 | 1464:261b3d9a4903 | Chris | chars -= %w(0 O 1 l) |
| 11132 | 0:513646585e45 | Chris | password = '' |
| 11133 | 1464:261b3d9a4903 | Chris | length.times {|i| password << chars[SecureRandom.random_number(chars.size)] }
|
| 11134 | 0:513646585e45 | Chris | self.password = password |
| 11135 | self.password_confirmation = password |
||
| 11136 | self |
||
| 11137 | end |
||
| 11138 | 909:cbb26bc654de | Chris | |
| 11139 | 0:513646585e45 | Chris | def pref |
| 11140 | self.preference ||= UserPreference.new(:user => self) |
||
| 11141 | end |
||
| 11142 | 909:cbb26bc654de | Chris | |
| 11143 | 0:513646585e45 | Chris | def time_zone |
| 11144 | @time_zone ||= (self.pref.time_zone.blank? ? nil : ActiveSupport::TimeZone[self.pref.time_zone]) |
||
| 11145 | end |
||
| 11146 | 909:cbb26bc654de | Chris | |
| 11147 | 1517:dffacf8a6908 | Chris | def force_default_language? |
| 11148 | Setting.force_default_language_for_loggedin? |
||
| 11149 | end |
||
| 11150 | |||
| 11151 | def language |
||
| 11152 | if force_default_language? |
||
| 11153 | Setting.default_language |
||
| 11154 | else |
||
| 11155 | super |
||
| 11156 | end |
||
| 11157 | end |
||
| 11158 | |||
| 11159 | 0:513646585e45 | Chris | def wants_comments_in_reverse_order? |
| 11160 | self.pref[:comments_sorting] == 'desc' |
||
| 11161 | end |
||
| 11162 | 909:cbb26bc654de | Chris | |
| 11163 | 0:513646585e45 | Chris | # Return user's RSS key (a 40 chars long string), used to access feeds |
| 11164 | def rss_key |
||
| 11165 | 1115:433d4f72a19b | Chris | if rss_token.nil? |
| 11166 | create_rss_token(:action => 'feeds') |
||
| 11167 | end |
||
| 11168 | rss_token.value |
||
| 11169 | 0:513646585e45 | Chris | end |
| 11170 | |||
| 11171 | # Return user's API key (a 40 chars long string), used to access the API |
||
| 11172 | def api_key |
||
| 11173 | 1115:433d4f72a19b | Chris | if api_token.nil? |
| 11174 | create_api_token(:action => 'api') |
||
| 11175 | end |
||
| 11176 | api_token.value |
||
| 11177 | 0:513646585e45 | Chris | end |
| 11178 | 909:cbb26bc654de | Chris | |
| 11179 | 0:513646585e45 | Chris | # Return an array of project ids for which the user has explicitly turned mail notifications on |
| 11180 | def notified_projects_ids |
||
| 11181 | @notified_projects_ids ||= memberships.select {|m| m.mail_notification?}.collect(&:project_id)
|
||
| 11182 | end |
||
| 11183 | 909:cbb26bc654de | Chris | |
| 11184 | 0:513646585e45 | Chris | def notified_project_ids=(ids) |
| 11185 | 1464:261b3d9a4903 | Chris | @notified_projects_ids_changed = true |
| 11186 | @notified_projects_ids = ids |
||
| 11187 | 0:513646585e45 | Chris | end |
| 11188 | |||
| 11189 | 1464:261b3d9a4903 | Chris | # Updates per project notifications (after_save callback) |
| 11190 | def update_notified_project_ids |
||
| 11191 | if @notified_projects_ids_changed |
||
| 11192 | ids = (mail_notification == 'selected' ? Array.wrap(notified_projects_ids).reject(&:blank?) : []) |
||
| 11193 | members.update_all(:mail_notification => false) |
||
| 11194 | members.where(:project_id => ids).update_all(:mail_notification => true) if ids.any? |
||
| 11195 | end |
||
| 11196 | end |
||
| 11197 | private :update_notified_project_ids |
||
| 11198 | |||
| 11199 | 128:07fa8a8b56a8 | Chris | def valid_notification_options |
| 11200 | self.class.valid_notification_options(self) |
||
| 11201 | end |
||
| 11202 | |||
| 11203 | 37:94944d00e43c | chris | # Only users that belong to more than 1 project can select projects for which they are notified |
| 11204 | 128:07fa8a8b56a8 | Chris | def self.valid_notification_options(user=nil) |
| 11205 | 37:94944d00e43c | chris | # Note that @user.membership.size would fail since AR ignores |
| 11206 | # :include association option when doing a count |
||
| 11207 | 128:07fa8a8b56a8 | Chris | if user.nil? || user.memberships.length < 1 |
| 11208 | MAIL_NOTIFICATION_OPTIONS.reject {|option| option.first == 'selected'}
|
||
| 11209 | 37:94944d00e43c | chris | else |
| 11210 | MAIL_NOTIFICATION_OPTIONS |
||
| 11211 | end |
||
| 11212 | end |
||
| 11213 | |||
| 11214 | 0:513646585e45 | Chris | # Find a user account by matching the exact login and then a case-insensitive |
| 11215 | # version. Exact matches will be given priority. |
||
| 11216 | def self.find_by_login(login) |
||
| 11217 | 1517:dffacf8a6908 | Chris | login = Redmine::CodesetUtil.replace_invalid_utf8(login.to_s) |
| 11218 | 1464:261b3d9a4903 | Chris | if login.present? |
| 11219 | # First look for an exact match |
||
| 11220 | 1517:dffacf8a6908 | Chris | user = where(:login => login).detect {|u| u.login == login}
|
| 11221 | 1464:261b3d9a4903 | Chris | unless user |
| 11222 | # Fail over to case-insensitive if none was found |
||
| 11223 | user = where("LOWER(login) = ?", login.downcase).first
|
||
| 11224 | end |
||
| 11225 | user |
||
| 11226 | 1115:433d4f72a19b | Chris | end |
| 11227 | 0:513646585e45 | Chris | end |
| 11228 | |||
| 11229 | def self.find_by_rss_key(key) |
||
| 11230 | 1464:261b3d9a4903 | Chris | Token.find_active_user('feeds', key)
|
| 11231 | 0:513646585e45 | Chris | end |
| 11232 | 909:cbb26bc654de | Chris | |
| 11233 | 0:513646585e45 | Chris | def self.find_by_api_key(key) |
| 11234 | 1464:261b3d9a4903 | Chris | Token.find_active_user('api', key)
|
| 11235 | 0:513646585e45 | Chris | end |
| 11236 | 909:cbb26bc654de | Chris | |
| 11237 | 0:513646585e45 | Chris | # Makes find_by_mail case-insensitive |
| 11238 | def self.find_by_mail(mail) |
||
| 11239 | 1115:433d4f72a19b | Chris | where("LOWER(mail) = ?", mail.to_s.downcase).first
|
| 11240 | 0:513646585e45 | Chris | end |
| 11241 | 909:cbb26bc654de | Chris | |
| 11242 | 929:5f33065ddc4b | Chris | # Returns true if the default admin account can no longer be used |
| 11243 | def self.default_admin_account_changed? |
||
| 11244 | !User.active.find_by_login("admin").try(:check_password?, "admin")
|
||
| 11245 | end |
||
| 11246 | |||
| 11247 | 0:513646585e45 | Chris | def to_s |
| 11248 | name |
||
| 11249 | end |
||
| 11250 | 909:cbb26bc654de | Chris | |
| 11251 | 1115:433d4f72a19b | Chris | CSS_CLASS_BY_STATUS = {
|
| 11252 | STATUS_ANONYMOUS => 'anon', |
||
| 11253 | STATUS_ACTIVE => 'active', |
||
| 11254 | STATUS_REGISTERED => 'registered', |
||
| 11255 | STATUS_LOCKED => 'locked' |
||
| 11256 | } |
||
| 11257 | |||
| 11258 | def css_classes |
||
| 11259 | "user #{CSS_CLASS_BY_STATUS[status]}"
|
||
| 11260 | end |
||
| 11261 | |||
| 11262 | 0:513646585e45 | Chris | # Returns the current day according to user's time zone |
| 11263 | def today |
||
| 11264 | if time_zone.nil? |
||
| 11265 | Date.today |
||
| 11266 | else |
||
| 11267 | Time.now.in_time_zone(time_zone).to_date |
||
| 11268 | end |
||
| 11269 | end |
||
| 11270 | 909:cbb26bc654de | Chris | |
| 11271 | 1115:433d4f72a19b | Chris | # Returns the day of +time+ according to user's time zone |
| 11272 | def time_to_date(time) |
||
| 11273 | if time_zone.nil? |
||
| 11274 | time.to_date |
||
| 11275 | else |
||
| 11276 | time.in_time_zone(time_zone).to_date |
||
| 11277 | end |
||
| 11278 | end |
||
| 11279 | |||
| 11280 | 0:513646585e45 | Chris | def logged? |
| 11281 | true |
||
| 11282 | end |
||
| 11283 | 909:cbb26bc654de | Chris | |
| 11284 | 0:513646585e45 | Chris | def anonymous? |
| 11285 | !logged? |
||
| 11286 | end |
||
| 11287 | 909:cbb26bc654de | Chris | |
| 11288 | 1464:261b3d9a4903 | Chris | # Returns user's membership for the given project |
| 11289 | # or nil if the user is not a member of project |
||
| 11290 | def membership(project) |
||
| 11291 | project_id = project.is_a?(Project) ? project.id : project |
||
| 11292 | |||
| 11293 | @membership_by_project_id ||= Hash.new {|h, project_id|
|
||
| 11294 | h[project_id] = memberships.where(:project_id => project_id).first |
||
| 11295 | } |
||
| 11296 | @membership_by_project_id[project_id] |
||
| 11297 | end |
||
| 11298 | |||
| 11299 | # Returns the user's bult-in role |
||
| 11300 | def builtin_role |
||
| 11301 | @builtin_role ||= Role.non_member |
||
| 11302 | end |
||
| 11303 | |||
| 11304 | 0:513646585e45 | Chris | # Return user's roles for project |
| 11305 | def roles_for_project(project) |
||
| 11306 | roles = [] |
||
| 11307 | # No role on archived projects |
||
| 11308 | 1115:433d4f72a19b | Chris | return roles if project.nil? || project.archived? |
| 11309 | 1464:261b3d9a4903 | Chris | if membership = membership(project) |
| 11310 | roles = membership.roles |
||
| 11311 | 0:513646585e45 | Chris | else |
| 11312 | 1464:261b3d9a4903 | Chris | roles << builtin_role |
| 11313 | 0:513646585e45 | Chris | end |
| 11314 | roles |
||
| 11315 | end |
||
| 11316 | 909:cbb26bc654de | Chris | |
| 11317 | 0:513646585e45 | Chris | # Return true if the user is a member of project |
| 11318 | def member_of?(project) |
||
| 11319 | 1464:261b3d9a4903 | Chris | projects.to_a.include?(project) |
| 11320 | 0:513646585e45 | Chris | end |
| 11321 | 909:cbb26bc654de | Chris | |
| 11322 | 441:cbce1fd3b1b7 | Chris | # Returns a hash of user's projects grouped by roles |
| 11323 | def projects_by_role |
||
| 11324 | return @projects_by_role if @projects_by_role |
||
| 11325 | 909:cbb26bc654de | Chris | |
| 11326 | 1115:433d4f72a19b | Chris | @projects_by_role = Hash.new([]) |
| 11327 | 441:cbce1fd3b1b7 | Chris | memberships.each do |membership| |
| 11328 | 1115:433d4f72a19b | Chris | if membership.project |
| 11329 | membership.roles.each do |role| |
||
| 11330 | @projects_by_role[role] = [] unless @projects_by_role.key?(role) |
||
| 11331 | @projects_by_role[role] << membership.project |
||
| 11332 | end |
||
| 11333 | 441:cbce1fd3b1b7 | Chris | end |
| 11334 | end |
||
| 11335 | @projects_by_role.each do |role, projects| |
||
| 11336 | projects.uniq! |
||
| 11337 | end |
||
| 11338 | 909:cbb26bc654de | Chris | |
| 11339 | 441:cbce1fd3b1b7 | Chris | @projects_by_role |
| 11340 | end |
||
| 11341 | 909:cbb26bc654de | Chris | |
| 11342 | # Returns true if user is arg or belongs to arg |
||
| 11343 | def is_or_belongs_to?(arg) |
||
| 11344 | if arg.is_a?(User) |
||
| 11345 | self == arg |
||
| 11346 | elsif arg.is_a?(Group) |
||
| 11347 | arg.users.include?(self) |
||
| 11348 | else |
||
| 11349 | false |
||
| 11350 | end |
||
| 11351 | end |
||
| 11352 | |||
| 11353 | 37:94944d00e43c | chris | # Return true if the user is allowed to do the specified action on a specific context |
| 11354 | # Action can be: |
||
| 11355 | 0:513646585e45 | Chris | # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit') |
| 11356 | # * a permission Symbol (eg. :edit_project) |
||
| 11357 | 37:94944d00e43c | chris | # Context can be: |
| 11358 | # * a project : returns true if user is allowed to do the specified action on this project |
||
| 11359 | 441:cbce1fd3b1b7 | Chris | # * an array of projects : returns true if user is allowed on every project |
| 11360 | 909:cbb26bc654de | Chris | # * nil with options[:global] set : check if user has at least one role allowed for this action, |
| 11361 | 37:94944d00e43c | chris | # or falls back to Non Member / Anonymous permissions depending if the user is logged |
| 11362 | 441:cbce1fd3b1b7 | Chris | def allowed_to?(action, context, options={}, &block)
|
| 11363 | 37:94944d00e43c | chris | if context && context.is_a?(Project) |
| 11364 | return false unless context.allows_to?(action) |
||
| 11365 | 0:513646585e45 | Chris | # Admin users are authorized for anything else |
| 11366 | return true if admin? |
||
| 11367 | 909:cbb26bc654de | Chris | |
| 11368 | 37:94944d00e43c | chris | roles = roles_for_project(context) |
| 11369 | 0:513646585e45 | Chris | return false unless roles |
| 11370 | 1115:433d4f72a19b | Chris | roles.any? {|role|
|
| 11371 | 441:cbce1fd3b1b7 | Chris | (context.is_public? || role.member?) && |
| 11372 | role.allowed_to?(action) && |
||
| 11373 | (block_given? ? yield(role, self) : true) |
||
| 11374 | } |
||
| 11375 | 37:94944d00e43c | chris | elsif context && context.is_a?(Array) |
| 11376 | 1115:433d4f72a19b | Chris | if context.empty? |
| 11377 | false |
||
| 11378 | else |
||
| 11379 | # Authorize if user is authorized on every element of the array |
||
| 11380 | context.map {|project| allowed_to?(action, project, options, &block)}.reduce(:&)
|
||
| 11381 | 37:94944d00e43c | chris | end |
| 11382 | 0:513646585e45 | Chris | elsif options[:global] |
| 11383 | # Admin users are always authorized |
||
| 11384 | return true if admin? |
||
| 11385 | 909:cbb26bc654de | Chris | |
| 11386 | 0:513646585e45 | Chris | # authorize if user has at least one role that has this permission |
| 11387 | roles = memberships.collect {|m| m.roles}.flatten.uniq
|
||
| 11388 | 441:cbce1fd3b1b7 | Chris | roles << (self.logged? ? Role.non_member : Role.anonymous) |
| 11389 | 1115:433d4f72a19b | Chris | roles.any? {|role|
|
| 11390 | 441:cbce1fd3b1b7 | Chris | role.allowed_to?(action) && |
| 11391 | (block_given? ? yield(role, self) : true) |
||
| 11392 | } |
||
| 11393 | 0:513646585e45 | Chris | else |
| 11394 | false |
||
| 11395 | end |
||
| 11396 | end |
||
| 11397 | 22:40f7cfd4df19 | chris | |
| 11398 | # Is the user allowed to do the specified action on any project? |
||
| 11399 | # See allowed_to? for the actions and valid options. |
||
| 11400 | 441:cbce1fd3b1b7 | Chris | def allowed_to_globally?(action, options, &block) |
| 11401 | allowed_to?(action, nil, options.reverse_merge(:global => true), &block) |
||
| 11402 | 22:40f7cfd4df19 | chris | end |
| 11403 | 119:8661b858af72 | Chris | |
| 11404 | 1464:261b3d9a4903 | Chris | # Returns true if the user is allowed to delete the user's own account |
| 11405 | 1115:433d4f72a19b | Chris | def own_account_deletable? |
| 11406 | Setting.unsubscribe? && |
||
| 11407 | (!admin? || User.active.where("admin = ? AND id <> ?", true, id).exists?)
|
||
| 11408 | end |
||
| 11409 | |||
| 11410 | 119:8661b858af72 | Chris | safe_attributes 'login', |
| 11411 | 'firstname', |
||
| 11412 | 'lastname', |
||
| 11413 | 'mail', |
||
| 11414 | 'mail_notification', |
||
| 11415 | 1464:261b3d9a4903 | Chris | 'notified_project_ids', |
| 11416 | 119:8661b858af72 | Chris | 'language', |
| 11417 | 'custom_field_values', |
||
| 11418 | 'custom_fields', |
||
| 11419 | 'identity_url' |
||
| 11420 | 909:cbb26bc654de | Chris | |
| 11421 | 119:8661b858af72 | Chris | safe_attributes 'status', |
| 11422 | 'auth_source_id', |
||
| 11423 | 1464:261b3d9a4903 | Chris | 'generate_password', |
| 11424 | 'must_change_passwd', |
||
| 11425 | 119:8661b858af72 | Chris | :if => lambda {|user, current_user| current_user.admin?}
|
| 11426 | 909:cbb26bc654de | Chris | |
| 11427 | 119:8661b858af72 | Chris | safe_attributes 'group_ids', |
| 11428 | :if => lambda {|user, current_user| current_user.admin? && !user.new_record?}
|
||
| 11429 | 909:cbb26bc654de | Chris | |
| 11430 | 37:94944d00e43c | chris | # Utility method to help check if a user should be notified about an |
| 11431 | # event. |
||
| 11432 | # |
||
| 11433 | # TODO: only supports Issue events currently |
||
| 11434 | def notify_about?(object) |
||
| 11435 | 1464:261b3d9a4903 | Chris | if mail_notification == 'all' |
| 11436 | 37:94944d00e43c | chris | true |
| 11437 | 1464:261b3d9a4903 | Chris | elsif mail_notification.blank? || mail_notification == 'none' |
| 11438 | false |
||
| 11439 | else |
||
| 11440 | case object |
||
| 11441 | when Issue |
||
| 11442 | case mail_notification |
||
| 11443 | when 'selected', 'only_my_events' |
||
| 11444 | # user receives notifications for created/assigned issues on unselected projects |
||
| 11445 | object.author == self || is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.assigned_to_was) |
||
| 11446 | when 'only_assigned' |
||
| 11447 | is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.assigned_to_was) |
||
| 11448 | when 'only_owner' |
||
| 11449 | object.author == self |
||
| 11450 | end |
||
| 11451 | when News |
||
| 11452 | # always send to project members except when mail_notification is set to 'none' |
||
| 11453 | 210:0579821a129a | Chris | true |
| 11454 | end |
||
| 11455 | 37:94944d00e43c | chris | end |
| 11456 | end |
||
| 11457 | 909:cbb26bc654de | Chris | |
| 11458 | 0:513646585e45 | Chris | def self.current=(user) |
| 11459 | 1464:261b3d9a4903 | Chris | Thread.current[:current_user] = user |
| 11460 | 0:513646585e45 | Chris | end |
| 11461 | 909:cbb26bc654de | Chris | |
| 11462 | 0:513646585e45 | Chris | def self.current |
| 11463 | 1464:261b3d9a4903 | Chris | Thread.current[:current_user] ||= User.anonymous |
| 11464 | 0:513646585e45 | Chris | end |
| 11465 | 909:cbb26bc654de | Chris | |
| 11466 | 0:513646585e45 | Chris | # Returns the anonymous user. If the anonymous user does not exist, it is created. There can be only |
| 11467 | # one anonymous user per database. |
||
| 11468 | def self.anonymous |
||
| 11469 | 1115:433d4f72a19b | Chris | anonymous_user = AnonymousUser.first |
| 11470 | 0:513646585e45 | Chris | if anonymous_user.nil? |
| 11471 | anonymous_user = AnonymousUser.create(:lastname => 'Anonymous', :firstname => '', :mail => '', :login => '', :status => 0) |
||
| 11472 | raise 'Unable to create the anonymous user.' if anonymous_user.new_record? |
||
| 11473 | end |
||
| 11474 | anonymous_user |
||
| 11475 | end |
||
| 11476 | 245:051f544170fe | Chris | |
| 11477 | # Salts all existing unsalted passwords |
||
| 11478 | # It changes password storage scheme from SHA1(password) to SHA1(salt + SHA1(password)) |
||
| 11479 | # This method is used in the SaltPasswords migration and is to be kept as is |
||
| 11480 | def self.salt_unsalted_passwords! |
||
| 11481 | transaction do |
||
| 11482 | 1115:433d4f72a19b | Chris | User.where("salt IS NULL OR salt = ''").find_each do |user|
|
| 11483 | 245:051f544170fe | Chris | next if user.hashed_password.blank? |
| 11484 | salt = User.generate_salt |
||
| 11485 | hashed_password = User.hash_password("#{salt}#{user.hashed_password}")
|
||
| 11486 | 1115:433d4f72a19b | Chris | User.where(:id => user.id).update_all(:salt => salt, :hashed_password => hashed_password) |
| 11487 | 245:051f544170fe | Chris | end |
| 11488 | end |
||
| 11489 | end |
||
| 11490 | 909:cbb26bc654de | Chris | |
| 11491 | 0:513646585e45 | Chris | protected |
| 11492 | 909:cbb26bc654de | Chris | |
| 11493 | def validate_password_length |
||
| 11494 | 1464:261b3d9a4903 | Chris | return if password.blank? && generate_password? |
| 11495 | 0:513646585e45 | Chris | # Password length validation based on setting |
| 11496 | if !password.nil? && password.size < Setting.password_min_length.to_i |
||
| 11497 | errors.add(:password, :too_short, :count => Setting.password_min_length.to_i) |
||
| 11498 | end |
||
| 11499 | end |
||
| 11500 | 909:cbb26bc654de | Chris | |
| 11501 | 0:513646585e45 | Chris | private |
| 11502 | 909:cbb26bc654de | Chris | |
| 11503 | 1464:261b3d9a4903 | Chris | def generate_password_if_needed |
| 11504 | if generate_password? && auth_source.nil? |
||
| 11505 | length = [Setting.password_min_length.to_i + 2, 10].max |
||
| 11506 | random_password(length) |
||
| 11507 | end |
||
| 11508 | end |
||
| 11509 | |||
| 11510 | 128:07fa8a8b56a8 | Chris | # Removes references that are not handled by associations |
| 11511 | # Things that are not deleted are reassociated with the anonymous user |
||
| 11512 | def remove_references_before_destroy |
||
| 11513 | return if self.id.nil? |
||
| 11514 | 909:cbb26bc654de | Chris | |
| 11515 | 128:07fa8a8b56a8 | Chris | substitute = User.anonymous |
| 11516 | 1517:dffacf8a6908 | Chris | Attachment.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id]) |
| 11517 | Comment.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id]) |
||
| 11518 | Issue.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id]) |
||
| 11519 | Issue.where(['assigned_to_id = ?', id]).update_all('assigned_to_id = NULL')
|
||
| 11520 | Journal.where(['user_id = ?', id]).update_all(['user_id = ?', substitute.id]) |
||
| 11521 | JournalDetail. |
||
| 11522 | where(["property = 'attr' AND prop_key = 'assigned_to_id' AND old_value = ?", id.to_s]). |
||
| 11523 | update_all(['old_value = ?', substitute.id.to_s]) |
||
| 11524 | JournalDetail. |
||
| 11525 | where(["property = 'attr' AND prop_key = 'assigned_to_id' AND value = ?", id.to_s]). |
||
| 11526 | update_all(['value = ?', substitute.id.to_s]) |
||
| 11527 | Message.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id]) |
||
| 11528 | News.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id]) |
||
| 11529 | 128:07fa8a8b56a8 | Chris | # Remove private queries and keep public ones |
| 11530 | 1464:261b3d9a4903 | Chris | ::Query.delete_all ['user_id = ? AND visibility = ?', id, ::Query::VISIBILITY_PRIVATE] |
| 11531 | 1517:dffacf8a6908 | Chris | ::Query.where(['user_id = ?', id]).update_all(['user_id = ?', substitute.id]) |
| 11532 | TimeEntry.where(['user_id = ?', id]).update_all(['user_id = ?', substitute.id]) |
||
| 11533 | 128:07fa8a8b56a8 | Chris | Token.delete_all ['user_id = ?', id] |
| 11534 | Watcher.delete_all ['user_id = ?', id] |
||
| 11535 | 1517:dffacf8a6908 | Chris | WikiContent.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id]) |
| 11536 | WikiContent::Version.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id]) |
||
| 11537 | 128:07fa8a8b56a8 | Chris | end |
| 11538 | 909:cbb26bc654de | Chris | |
| 11539 | 0:513646585e45 | Chris | # Return password digest |
| 11540 | def self.hash_password(clear_password) |
||
| 11541 | Digest::SHA1.hexdigest(clear_password || "") |
||
| 11542 | end |
||
| 11543 | 909:cbb26bc654de | Chris | |
| 11544 | 245:051f544170fe | Chris | # Returns a 128bits random salt as a hex string (32 chars long) |
| 11545 | def self.generate_salt |
||
| 11546 | 1115:433d4f72a19b | Chris | Redmine::Utils.random_hex(16) |
| 11547 | 245:051f544170fe | Chris | end |
| 11548 | 909:cbb26bc654de | Chris | |
| 11549 | 0:513646585e45 | Chris | end |
| 11550 | |||
| 11551 | class AnonymousUser < User |
||
| 11552 | 1115:433d4f72a19b | Chris | validate :validate_anonymous_uniqueness, :on => :create |
| 11553 | 909:cbb26bc654de | Chris | |
| 11554 | 1115:433d4f72a19b | Chris | def validate_anonymous_uniqueness |
| 11555 | 0:513646585e45 | Chris | # There should be only one AnonymousUser in the database |
| 11556 | 1464:261b3d9a4903 | Chris | errors.add :base, 'An anonymous user already exists.' if AnonymousUser.exists? |
| 11557 | 0:513646585e45 | Chris | end |
| 11558 | 909:cbb26bc654de | Chris | |
| 11559 | 0:513646585e45 | Chris | def available_custom_fields |
| 11560 | [] |
||
| 11561 | end |
||
| 11562 | 909:cbb26bc654de | Chris | |
| 11563 | 0:513646585e45 | Chris | # Overrides a few properties |
| 11564 | def logged?; false end |
||
| 11565 | def admin; false end |
||
| 11566 | def name(*args); I18n.t(:label_user_anonymous) end |
||
| 11567 | def mail; nil end |
||
| 11568 | def time_zone; nil end |
||
| 11569 | def rss_key; nil end |
||
| 11570 | 909:cbb26bc654de | Chris | |
| 11571 | 1115:433d4f72a19b | Chris | def pref |
| 11572 | UserPreference.new(:user => self) |
||
| 11573 | end |
||
| 11574 | |||
| 11575 | 1464:261b3d9a4903 | Chris | # Returns the user's bult-in role |
| 11576 | def builtin_role |
||
| 11577 | @builtin_role ||= Role.anonymous |
||
| 11578 | end |
||
| 11579 | |||
| 11580 | def membership(*args) |
||
| 11581 | nil |
||
| 11582 | end |
||
| 11583 | |||
| 11584 | def member_of?(*args) |
||
| 11585 | false |
||
| 11586 | end |
||
| 11587 | |||
| 11588 | 128:07fa8a8b56a8 | Chris | # Anonymous user can not be destroyed |
| 11589 | def destroy |
||
| 11590 | false |
||
| 11591 | end |
||
| 11592 | 0:513646585e45 | Chris | end |
| 11593 | 909:cbb26bc654de | Chris | # Redmine - project management software |
| 11594 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 11595 | 0:513646585e45 | Chris | # |
| 11596 | # This program is free software; you can redistribute it and/or |
||
| 11597 | # modify it under the terms of the GNU General Public License |
||
| 11598 | # as published by the Free Software Foundation; either version 2 |
||
| 11599 | # of the License, or (at your option) any later version. |
||
| 11600 | 909:cbb26bc654de | Chris | # |
| 11601 | 0:513646585e45 | Chris | # This program is distributed in the hope that it will be useful, |
| 11602 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 11603 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 11604 | # GNU General Public License for more details. |
||
| 11605 | 909:cbb26bc654de | Chris | # |
| 11606 | 0:513646585e45 | Chris | # You should have received a copy of the GNU General Public License |
| 11607 | # along with this program; if not, write to the Free Software |
||
| 11608 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 11609 | |||
| 11610 | class UserCustomField < CustomField |
||
| 11611 | def type_name |
||
| 11612 | :label_user_plural |
||
| 11613 | end |
||
| 11614 | end |
||
| 11615 | |||
| 11616 | 909:cbb26bc654de | Chris | # Redmine - project management software |
| 11617 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 11618 | 0:513646585e45 | Chris | # |
| 11619 | # This program is free software; you can redistribute it and/or |
||
| 11620 | # modify it under the terms of the GNU General Public License |
||
| 11621 | # as published by the Free Software Foundation; either version 2 |
||
| 11622 | # of the License, or (at your option) any later version. |
||
| 11623 | 909:cbb26bc654de | Chris | # |
| 11624 | 0:513646585e45 | Chris | # This program is distributed in the hope that it will be useful, |
| 11625 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 11626 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 11627 | # GNU General Public License for more details. |
||
| 11628 | 909:cbb26bc654de | Chris | # |
| 11629 | 0:513646585e45 | Chris | # You should have received a copy of the GNU General Public License |
| 11630 | # along with this program; if not, write to the Free Software |
||
| 11631 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 11632 | |||
| 11633 | class UserPreference < ActiveRecord::Base |
||
| 11634 | belongs_to :user |
||
| 11635 | serialize :others |
||
| 11636 | 909:cbb26bc654de | Chris | |
| 11637 | 929:5f33065ddc4b | Chris | attr_protected :others, :user_id |
| 11638 | 909:cbb26bc654de | Chris | |
| 11639 | 1115:433d4f72a19b | Chris | before_save :set_others_hash |
| 11640 | 1464:261b3d9a4903 | Chris | |
| 11641 | 1115:433d4f72a19b | Chris | def initialize(attributes=nil, *args) |
| 11642 | 0:513646585e45 | Chris | super |
| 11643 | self.others ||= {}
|
||
| 11644 | end |
||
| 11645 | 909:cbb26bc654de | Chris | |
| 11646 | 1115:433d4f72a19b | Chris | def set_others_hash |
| 11647 | 0:513646585e45 | Chris | self.others ||= {}
|
| 11648 | end |
||
| 11649 | 909:cbb26bc654de | Chris | |
| 11650 | 0:513646585e45 | Chris | def [](attr_name) |
| 11651 | 1464:261b3d9a4903 | Chris | if has_attribute? attr_name |
| 11652 | 0:513646585e45 | Chris | super |
| 11653 | else |
||
| 11654 | others ? others[attr_name] : nil |
||
| 11655 | end |
||
| 11656 | end |
||
| 11657 | 909:cbb26bc654de | Chris | |
| 11658 | 0:513646585e45 | Chris | def []=(attr_name, value) |
| 11659 | 1464:261b3d9a4903 | Chris | if has_attribute? attr_name |
| 11660 | 0:513646585e45 | Chris | super |
| 11661 | else |
||
| 11662 | 1115:433d4f72a19b | Chris | h = (read_attribute(:others) || {}).dup
|
| 11663 | 0:513646585e45 | Chris | h.update(attr_name => value) |
| 11664 | write_attribute(:others, h) |
||
| 11665 | value |
||
| 11666 | end |
||
| 11667 | end |
||
| 11668 | 909:cbb26bc654de | Chris | |
| 11669 | 0:513646585e45 | Chris | def comments_sorting; self[:comments_sorting] end |
| 11670 | def comments_sorting=(order); self[:comments_sorting]=order end |
||
| 11671 | 909:cbb26bc654de | Chris | |
| 11672 | 245:051f544170fe | Chris | def warn_on_leaving_unsaved; self[:warn_on_leaving_unsaved] || '1'; end |
| 11673 | def warn_on_leaving_unsaved=(value); self[:warn_on_leaving_unsaved]=value; end |
||
| 11674 | 1464:261b3d9a4903 | Chris | |
| 11675 | def no_self_notified; (self[:no_self_notified] == true || self[:no_self_notified] == '1'); end |
||
| 11676 | def no_self_notified=(value); self[:no_self_notified]=value; end |
||
| 11677 | 0:513646585e45 | Chris | end |
| 11678 | # Redmine - project management software |
||
| 11679 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 11680 | 0:513646585e45 | Chris | # |
| 11681 | # This program is free software; you can redistribute it and/or |
||
| 11682 | # modify it under the terms of the GNU General Public License |
||
| 11683 | # as published by the Free Software Foundation; either version 2 |
||
| 11684 | # of the License, or (at your option) any later version. |
||
| 11685 | 909:cbb26bc654de | Chris | # |
| 11686 | 0:513646585e45 | Chris | # This program is distributed in the hope that it will be useful, |
| 11687 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 11688 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 11689 | # GNU General Public License for more details. |
||
| 11690 | 909:cbb26bc654de | Chris | # |
| 11691 | 0:513646585e45 | Chris | # You should have received a copy of the GNU General Public License |
| 11692 | # along with this program; if not, write to the Free Software |
||
| 11693 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 11694 | |||
| 11695 | class Version < ActiveRecord::Base |
||
| 11696 | 929:5f33065ddc4b | Chris | include Redmine::SafeAttributes |
| 11697 | 0:513646585e45 | Chris | after_update :update_issues_from_sharing_change |
| 11698 | belongs_to :project |
||
| 11699 | has_many :fixed_issues, :class_name => 'Issue', :foreign_key => 'fixed_version_id', :dependent => :nullify |
||
| 11700 | acts_as_customizable |
||
| 11701 | acts_as_attachable :view_permission => :view_files, |
||
| 11702 | :delete_permission => :manage_files |
||
| 11703 | |||
| 11704 | VERSION_STATUSES = %w(open locked closed) |
||
| 11705 | VERSION_SHARINGS = %w(none descendants hierarchy tree system) |
||
| 11706 | 909:cbb26bc654de | Chris | |
| 11707 | 0:513646585e45 | Chris | validates_presence_of :name |
| 11708 | validates_uniqueness_of :name, :scope => [:project_id] |
||
| 11709 | validates_length_of :name, :maximum => 60 |
||
| 11710 | 1464:261b3d9a4903 | Chris | validates :effective_date, :date => true |
| 11711 | 0:513646585e45 | Chris | validates_inclusion_of :status, :in => VERSION_STATUSES |
| 11712 | validates_inclusion_of :sharing, :in => VERSION_SHARINGS |
||
| 11713 | |||
| 11714 | 1115:433d4f72a19b | Chris | scope :named, lambda {|arg| where("LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip)}
|
| 11715 | 1464:261b3d9a4903 | Chris | scope :open, lambda { where(:status => 'open') }
|
| 11716 | 1115:433d4f72a19b | Chris | scope :visible, lambda {|*args|
|
| 11717 | includes(:project).where(Project.allowed_to_condition(args.first || User.current, :view_issues)) |
||
| 11718 | } |
||
| 11719 | 0:513646585e45 | Chris | |
| 11720 | 1464:261b3d9a4903 | Chris | safe_attributes 'name', |
| 11721 | 929:5f33065ddc4b | Chris | 'description', |
| 11722 | 'effective_date', |
||
| 11723 | 'due_date', |
||
| 11724 | 'wiki_page_title', |
||
| 11725 | 'status', |
||
| 11726 | 'sharing', |
||
| 11727 | 1464:261b3d9a4903 | Chris | 'custom_field_values', |
| 11728 | 'custom_fields' |
||
| 11729 | 929:5f33065ddc4b | Chris | |
| 11730 | 0:513646585e45 | Chris | # Returns true if +user+ or current user is allowed to view the version |
| 11731 | def visible?(user=User.current) |
||
| 11732 | user.allowed_to?(:view_issues, self.project) |
||
| 11733 | end |
||
| 11734 | 909:cbb26bc654de | Chris | |
| 11735 | # Version files have same visibility as project files |
||
| 11736 | def attachments_visible?(*args) |
||
| 11737 | project.present? && project.attachments_visible?(*args) |
||
| 11738 | end |
||
| 11739 | |||
| 11740 | 0:513646585e45 | Chris | def start_date |
| 11741 | 119:8661b858af72 | Chris | @start_date ||= fixed_issues.minimum('start_date')
|
| 11742 | 0:513646585e45 | Chris | end |
| 11743 | 909:cbb26bc654de | Chris | |
| 11744 | 0:513646585e45 | Chris | def due_date |
| 11745 | effective_date |
||
| 11746 | end |
||
| 11747 | 909:cbb26bc654de | Chris | |
| 11748 | def due_date=(arg) |
||
| 11749 | self.effective_date=(arg) |
||
| 11750 | end |
||
| 11751 | |||
| 11752 | 0:513646585e45 | Chris | # Returns the total estimated time for this version |
| 11753 | # (sum of leaves estimated_hours) |
||
| 11754 | def estimated_hours |
||
| 11755 | @estimated_hours ||= fixed_issues.leaves.sum(:estimated_hours).to_f |
||
| 11756 | end |
||
| 11757 | 909:cbb26bc654de | Chris | |
| 11758 | 0:513646585e45 | Chris | # Returns the total reported time for this version |
| 11759 | def spent_hours |
||
| 11760 | 1115:433d4f72a19b | Chris | @spent_hours ||= TimeEntry.joins(:issue).where("#{Issue.table_name}.fixed_version_id = ?", id).sum(:hours).to_f
|
| 11761 | 0:513646585e45 | Chris | end |
| 11762 | 909:cbb26bc654de | Chris | |
| 11763 | 0:513646585e45 | Chris | def closed? |
| 11764 | status == 'closed' |
||
| 11765 | end |
||
| 11766 | |||
| 11767 | def open? |
||
| 11768 | status == 'open' |
||
| 11769 | end |
||
| 11770 | 909:cbb26bc654de | Chris | |
| 11771 | 0:513646585e45 | Chris | # Returns true if the version is completed: due date reached and no open issues |
| 11772 | def completed? |
||
| 11773 | 1115:433d4f72a19b | Chris | effective_date && (effective_date < Date.today) && (open_issues_count == 0) |
| 11774 | 0:513646585e45 | Chris | end |
| 11775 | 22:40f7cfd4df19 | chris | |
| 11776 | def behind_schedule? |
||
| 11777 | 1464:261b3d9a4903 | Chris | if completed_percent == 100 |
| 11778 | 22:40f7cfd4df19 | chris | return false |
| 11779 | 119:8661b858af72 | Chris | elsif due_date && start_date |
| 11780 | 1464:261b3d9a4903 | Chris | done_date = start_date + ((due_date - start_date+1)* completed_percent/100).floor |
| 11781 | 22:40f7cfd4df19 | chris | return done_date <= Date.today |
| 11782 | else |
||
| 11783 | false # No issues so it's not late |
||
| 11784 | end |
||
| 11785 | end |
||
| 11786 | 909:cbb26bc654de | Chris | |
| 11787 | 0:513646585e45 | Chris | # Returns the completion percentage of this version based on the amount of open/closed issues |
| 11788 | # and the time spent on the open issues. |
||
| 11789 | 1464:261b3d9a4903 | Chris | def completed_percent |
| 11790 | 0:513646585e45 | Chris | if issues_count == 0 |
| 11791 | 0 |
||
| 11792 | elsif open_issues_count == 0 |
||
| 11793 | 100 |
||
| 11794 | else |
||
| 11795 | issues_progress(false) + issues_progress(true) |
||
| 11796 | end |
||
| 11797 | end |
||
| 11798 | 909:cbb26bc654de | Chris | |
| 11799 | 1464:261b3d9a4903 | Chris | # TODO: remove in Redmine 3.0 |
| 11800 | def completed_pourcent |
||
| 11801 | ActiveSupport::Deprecation.warn "Version#completed_pourcent is deprecated and will be removed in Redmine 3.0. Please use #completed_percent instead." |
||
| 11802 | completed_percent |
||
| 11803 | end |
||
| 11804 | |||
| 11805 | 0:513646585e45 | Chris | # Returns the percentage of issues that have been marked as 'closed'. |
| 11806 | 1464:261b3d9a4903 | Chris | def closed_percent |
| 11807 | 0:513646585e45 | Chris | if issues_count == 0 |
| 11808 | 0 |
||
| 11809 | else |
||
| 11810 | issues_progress(false) |
||
| 11811 | end |
||
| 11812 | end |
||
| 11813 | 909:cbb26bc654de | Chris | |
| 11814 | 1464:261b3d9a4903 | Chris | # TODO: remove in Redmine 3.0 |
| 11815 | def closed_pourcent |
||
| 11816 | ActiveSupport::Deprecation.warn "Version#closed_pourcent is deprecated and will be removed in Redmine 3.0. Please use #closed_percent instead." |
||
| 11817 | closed_percent |
||
| 11818 | end |
||
| 11819 | |||
| 11820 | 0:513646585e45 | Chris | # Returns true if the version is overdue: due date reached and some open issues |
| 11821 | def overdue? |
||
| 11822 | effective_date && (effective_date < Date.today) && (open_issues_count > 0) |
||
| 11823 | end |
||
| 11824 | 909:cbb26bc654de | Chris | |
| 11825 | 0:513646585e45 | Chris | # Returns assigned issues count |
| 11826 | def issues_count |
||
| 11827 | 1115:433d4f72a19b | Chris | load_issue_counts |
| 11828 | @issue_count |
||
| 11829 | 0:513646585e45 | Chris | end |
| 11830 | 909:cbb26bc654de | Chris | |
| 11831 | 0:513646585e45 | Chris | # Returns the total amount of open issues for this version. |
| 11832 | def open_issues_count |
||
| 11833 | 1115:433d4f72a19b | Chris | load_issue_counts |
| 11834 | @open_issues_count |
||
| 11835 | 0:513646585e45 | Chris | end |
| 11836 | |||
| 11837 | # Returns the total amount of closed issues for this version. |
||
| 11838 | def closed_issues_count |
||
| 11839 | 1115:433d4f72a19b | Chris | load_issue_counts |
| 11840 | @closed_issues_count |
||
| 11841 | 0:513646585e45 | Chris | end |
| 11842 | 909:cbb26bc654de | Chris | |
| 11843 | 0:513646585e45 | Chris | def wiki_page |
| 11844 | if project.wiki && !wiki_page_title.blank? |
||
| 11845 | @wiki_page ||= project.wiki.find_page(wiki_page_title) |
||
| 11846 | end |
||
| 11847 | @wiki_page |
||
| 11848 | end |
||
| 11849 | 909:cbb26bc654de | Chris | |
| 11850 | 0:513646585e45 | Chris | def to_s; name end |
| 11851 | 22:40f7cfd4df19 | chris | |
| 11852 | def to_s_with_project |
||
| 11853 | "#{project} - #{name}"
|
||
| 11854 | end |
||
| 11855 | 909:cbb26bc654de | Chris | |
| 11856 | 1115:433d4f72a19b | Chris | # Versions are sorted by effective_date and name |
| 11857 | # Those with no effective_date are at the end, sorted by name |
||
| 11858 | 0:513646585e45 | Chris | def <=>(version) |
| 11859 | if self.effective_date |
||
| 11860 | if version.effective_date |
||
| 11861 | if self.effective_date == version.effective_date |
||
| 11862 | 1115:433d4f72a19b | Chris | name == version.name ? id <=> version.id : name <=> version.name |
| 11863 | 0:513646585e45 | Chris | else |
| 11864 | self.effective_date <=> version.effective_date |
||
| 11865 | end |
||
| 11866 | else |
||
| 11867 | -1 |
||
| 11868 | end |
||
| 11869 | else |
||
| 11870 | if version.effective_date |
||
| 11871 | 1 |
||
| 11872 | else |
||
| 11873 | 1115:433d4f72a19b | Chris | name == version.name ? id <=> version.id : name <=> version.name |
| 11874 | 0:513646585e45 | Chris | end |
| 11875 | end |
||
| 11876 | end |
||
| 11877 | 909:cbb26bc654de | Chris | |
| 11878 | 1115:433d4f72a19b | Chris | def self.fields_for_order_statement(table=nil) |
| 11879 | table ||= table_name |
||
| 11880 | ["(CASE WHEN #{table}.effective_date IS NULL THEN 1 ELSE 0 END)", "#{table}.effective_date", "#{table}.name", "#{table}.id"]
|
||
| 11881 | end |
||
| 11882 | |||
| 11883 | 1517:dffacf8a6908 | Chris | scope :sorted, lambda { order(fields_for_order_statement) }
|
| 11884 | 1115:433d4f72a19b | Chris | |
| 11885 | 0:513646585e45 | Chris | # Returns the sharings that +user+ can set the version to |
| 11886 | def allowed_sharings(user = User.current) |
||
| 11887 | VERSION_SHARINGS.select do |s| |
||
| 11888 | if sharing == s |
||
| 11889 | true |
||
| 11890 | else |
||
| 11891 | case s |
||
| 11892 | when 'system' |
||
| 11893 | # Only admin users can set a systemwide sharing |
||
| 11894 | user.admin? |
||
| 11895 | when 'hierarchy', 'tree' |
||
| 11896 | # Only users allowed to manage versions of the root project can |
||
| 11897 | # set sharing to hierarchy or tree |
||
| 11898 | project.nil? || user.allowed_to?(:manage_versions, project.root) |
||
| 11899 | else |
||
| 11900 | true |
||
| 11901 | end |
||
| 11902 | end |
||
| 11903 | end |
||
| 11904 | end |
||
| 11905 | 909:cbb26bc654de | Chris | |
| 11906 | 0:513646585e45 | Chris | private |
| 11907 | |||
| 11908 | 1115:433d4f72a19b | Chris | def load_issue_counts |
| 11909 | unless @issue_count |
||
| 11910 | @open_issues_count = 0 |
||
| 11911 | @closed_issues_count = 0 |
||
| 11912 | 1517:dffacf8a6908 | Chris | fixed_issues.group(:status).count.each do |status, count| |
| 11913 | 1115:433d4f72a19b | Chris | if status.is_closed? |
| 11914 | @closed_issues_count += count |
||
| 11915 | else |
||
| 11916 | @open_issues_count += count |
||
| 11917 | end |
||
| 11918 | end |
||
| 11919 | @issue_count = @open_issues_count + @closed_issues_count |
||
| 11920 | end |
||
| 11921 | end |
||
| 11922 | |||
| 11923 | 0:513646585e45 | Chris | # Update the issue's fixed versions. Used if a version's sharing changes. |
| 11924 | def update_issues_from_sharing_change |
||
| 11925 | if sharing_changed? |
||
| 11926 | if VERSION_SHARINGS.index(sharing_was).nil? || |
||
| 11927 | VERSION_SHARINGS.index(sharing).nil? || |
||
| 11928 | VERSION_SHARINGS.index(sharing_was) > VERSION_SHARINGS.index(sharing) |
||
| 11929 | Issue.update_versions_from_sharing_change self |
||
| 11930 | end |
||
| 11931 | end |
||
| 11932 | end |
||
| 11933 | 909:cbb26bc654de | Chris | |
| 11934 | 0:513646585e45 | Chris | # Returns the average estimated time of assigned issues |
| 11935 | # or 1 if no issue has an estimated time |
||
| 11936 | # Used to weigth unestimated issues in progress calculation |
||
| 11937 | def estimated_average |
||
| 11938 | if @estimated_average.nil? |
||
| 11939 | average = fixed_issues.average(:estimated_hours).to_f |
||
| 11940 | if average == 0 |
||
| 11941 | average = 1 |
||
| 11942 | end |
||
| 11943 | @estimated_average = average |
||
| 11944 | end |
||
| 11945 | @estimated_average |
||
| 11946 | end |
||
| 11947 | 909:cbb26bc654de | Chris | |
| 11948 | 0:513646585e45 | Chris | # Returns the total progress of open or closed issues. The returned percentage takes into account |
| 11949 | # the amount of estimated time set for this version. |
||
| 11950 | # |
||
| 11951 | # Examples: |
||
| 11952 | # issues_progress(true) => returns the progress percentage for open issues. |
||
| 11953 | # issues_progress(false) => returns the progress percentage for closed issues. |
||
| 11954 | def issues_progress(open) |
||
| 11955 | @issues_progress ||= {}
|
||
| 11956 | @issues_progress[open] ||= begin |
||
| 11957 | progress = 0 |
||
| 11958 | if issues_count > 0 |
||
| 11959 | ratio = open ? 'done_ratio' : 100 |
||
| 11960 | 909:cbb26bc654de | Chris | |
| 11961 | 1115:433d4f72a19b | Chris | done = fixed_issues.open(open).sum("COALESCE(estimated_hours, #{estimated_average}) * #{ratio}").to_f
|
| 11962 | 0:513646585e45 | Chris | progress = done / (estimated_average * issues_count) |
| 11963 | end |
||
| 11964 | progress |
||
| 11965 | end |
||
| 11966 | end |
||
| 11967 | end |
||
| 11968 | # Redmine - project management software |
||
| 11969 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 11970 | 0:513646585e45 | Chris | # |
| 11971 | # This program is free software; you can redistribute it and/or |
||
| 11972 | # modify it under the terms of the GNU General Public License |
||
| 11973 | # as published by the Free Software Foundation; either version 2 |
||
| 11974 | # of the License, or (at your option) any later version. |
||
| 11975 | 909:cbb26bc654de | Chris | # |
| 11976 | 0:513646585e45 | Chris | # This program is distributed in the hope that it will be useful, |
| 11977 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 11978 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 11979 | # GNU General Public License for more details. |
||
| 11980 | 909:cbb26bc654de | Chris | # |
| 11981 | 0:513646585e45 | Chris | # You should have received a copy of the GNU General Public License |
| 11982 | # along with this program; if not, write to the Free Software |
||
| 11983 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 11984 | |||
| 11985 | class VersionCustomField < CustomField |
||
| 11986 | def type_name |
||
| 11987 | :label_version_plural |
||
| 11988 | end |
||
| 11989 | end |
||
| 11990 | 909:cbb26bc654de | Chris | # Redmine - project management software |
| 11991 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 11992 | 0:513646585e45 | Chris | # |
| 11993 | # This program is free software; you can redistribute it and/or |
||
| 11994 | # modify it under the terms of the GNU General Public License |
||
| 11995 | # as published by the Free Software Foundation; either version 2 |
||
| 11996 | # of the License, or (at your option) any later version. |
||
| 11997 | 909:cbb26bc654de | Chris | # |
| 11998 | 0:513646585e45 | Chris | # This program is distributed in the hope that it will be useful, |
| 11999 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 12000 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 12001 | # GNU General Public License for more details. |
||
| 12002 | 909:cbb26bc654de | Chris | # |
| 12003 | 0:513646585e45 | Chris | # You should have received a copy of the GNU General Public License |
| 12004 | # along with this program; if not, write to the Free Software |
||
| 12005 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 12006 | |||
| 12007 | class Watcher < ActiveRecord::Base |
||
| 12008 | belongs_to :watchable, :polymorphic => true |
||
| 12009 | belongs_to :user |
||
| 12010 | 909:cbb26bc654de | Chris | |
| 12011 | 0:513646585e45 | Chris | validates_presence_of :user |
| 12012 | validates_uniqueness_of :user_id, :scope => [:watchable_type, :watchable_id] |
||
| 12013 | 1115:433d4f72a19b | Chris | validate :validate_user |
| 12014 | 0:513646585e45 | Chris | |
| 12015 | 1464:261b3d9a4903 | Chris | # Returns true if at least one object among objects is watched by user |
| 12016 | def self.any_watched?(objects, user) |
||
| 12017 | objects = objects.reject(&:new_record?) |
||
| 12018 | if objects.any? |
||
| 12019 | objects.group_by {|object| object.class.base_class}.each do |base_class, objects|
|
||
| 12020 | if Watcher.where(:watchable_type => base_class.name, :watchable_id => objects.map(&:id), :user_id => user.id).exists? |
||
| 12021 | return true |
||
| 12022 | end |
||
| 12023 | end |
||
| 12024 | end |
||
| 12025 | false |
||
| 12026 | end |
||
| 12027 | |||
| 12028 | 0:513646585e45 | Chris | # Unwatch things that users are no longer allowed to view |
| 12029 | def self.prune(options={})
|
||
| 12030 | if options.has_key?(:user) |
||
| 12031 | prune_single_user(options[:user], options) |
||
| 12032 | else |
||
| 12033 | pruned = 0 |
||
| 12034 | 1517:dffacf8a6908 | Chris | User.where("id IN (SELECT DISTINCT user_id FROM #{table_name})").each do |user|
|
| 12035 | 0:513646585e45 | Chris | pruned += prune_single_user(user, options) |
| 12036 | end |
||
| 12037 | pruned |
||
| 12038 | end |
||
| 12039 | end |
||
| 12040 | 909:cbb26bc654de | Chris | |
| 12041 | 0:513646585e45 | Chris | protected |
| 12042 | 909:cbb26bc654de | Chris | |
| 12043 | 1115:433d4f72a19b | Chris | def validate_user |
| 12044 | 0:513646585e45 | Chris | errors.add :user_id, :invalid unless user.nil? || user.active? |
| 12045 | end |
||
| 12046 | 909:cbb26bc654de | Chris | |
| 12047 | 0:513646585e45 | Chris | private |
| 12048 | 909:cbb26bc654de | Chris | |
| 12049 | 0:513646585e45 | Chris | def self.prune_single_user(user, options={})
|
| 12050 | return unless user.is_a?(User) |
||
| 12051 | pruned = 0 |
||
| 12052 | 1517:dffacf8a6908 | Chris | where(:user_id => user.id).each do |watcher| |
| 12053 | 0:513646585e45 | Chris | next if watcher.watchable.nil? |
| 12054 | if options.has_key?(:project) |
||
| 12055 | 1517:dffacf8a6908 | Chris | unless watcher.watchable.respond_to?(:project) && |
| 12056 | watcher.watchable.project == options[:project] |
||
| 12057 | next |
||
| 12058 | end |
||
| 12059 | 0:513646585e45 | Chris | end |
| 12060 | if watcher.watchable.respond_to?(:visible?) |
||
| 12061 | unless watcher.watchable.visible?(user) |
||
| 12062 | watcher.destroy |
||
| 12063 | pruned += 1 |
||
| 12064 | end |
||
| 12065 | end |
||
| 12066 | end |
||
| 12067 | pruned |
||
| 12068 | end |
||
| 12069 | end |
||
| 12070 | # Redmine - project management software |
||
| 12071 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 12072 | 0:513646585e45 | Chris | # |
| 12073 | # This program is free software; you can redistribute it and/or |
||
| 12074 | # modify it under the terms of the GNU General Public License |
||
| 12075 | # as published by the Free Software Foundation; either version 2 |
||
| 12076 | # of the License, or (at your option) any later version. |
||
| 12077 | 909:cbb26bc654de | Chris | # |
| 12078 | 0:513646585e45 | Chris | # This program is distributed in the hope that it will be useful, |
| 12079 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 12080 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 12081 | # GNU General Public License for more details. |
||
| 12082 | 909:cbb26bc654de | Chris | # |
| 12083 | 0:513646585e45 | Chris | # You should have received a copy of the GNU General Public License |
| 12084 | # along with this program; if not, write to the Free Software |
||
| 12085 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 12086 | |||
| 12087 | class Wiki < ActiveRecord::Base |
||
| 12088 | 929:5f33065ddc4b | Chris | include Redmine::SafeAttributes |
| 12089 | 0:513646585e45 | Chris | belongs_to :project |
| 12090 | has_many :pages, :class_name => 'WikiPage', :dependent => :destroy, :order => 'title' |
||
| 12091 | has_many :redirects, :class_name => 'WikiRedirect', :dependent => :delete_all |
||
| 12092 | 909:cbb26bc654de | Chris | |
| 12093 | 0:513646585e45 | Chris | acts_as_watchable |
| 12094 | 909:cbb26bc654de | Chris | |
| 12095 | 0:513646585e45 | Chris | validates_presence_of :start_page |
| 12096 | 1464:261b3d9a4903 | Chris | validates_format_of :start_page, :with => /\A[^,\.\/\?\;\|\:]*\z/ |
| 12097 | 909:cbb26bc654de | Chris | |
| 12098 | 929:5f33065ddc4b | Chris | safe_attributes 'start_page' |
| 12099 | |||
| 12100 | 0:513646585e45 | Chris | def visible?(user=User.current) |
| 12101 | !user.nil? && user.allowed_to?(:view_wiki_pages, project) |
||
| 12102 | end |
||
| 12103 | 909:cbb26bc654de | Chris | |
| 12104 | 0:513646585e45 | Chris | # Returns the wiki page that acts as the sidebar content |
| 12105 | # or nil if no such page exists |
||
| 12106 | def sidebar |
||
| 12107 | @sidebar ||= find_page('Sidebar', :with_redirect => false)
|
||
| 12108 | end |
||
| 12109 | 909:cbb26bc654de | Chris | |
| 12110 | 0:513646585e45 | Chris | # find the page with the given title |
| 12111 | # if page doesn't exist, return a new page |
||
| 12112 | def find_or_new_page(title) |
||
| 12113 | title = start_page if title.blank? |
||
| 12114 | find_page(title) || WikiPage.new(:wiki => self, :title => Wiki.titleize(title)) |
||
| 12115 | end |
||
| 12116 | 909:cbb26bc654de | Chris | |
| 12117 | 0:513646585e45 | Chris | # find the page with the given title |
| 12118 | def find_page(title, options = {})
|
||
| 12119 | 441:cbce1fd3b1b7 | Chris | @page_found_with_redirect = false |
| 12120 | 0:513646585e45 | Chris | title = start_page if title.blank? |
| 12121 | title = Wiki.titleize(title) |
||
| 12122 | 1464:261b3d9a4903 | Chris | page = pages.where("LOWER(title) = LOWER(?)", title).first
|
| 12123 | 0:513646585e45 | Chris | if !page && !(options[:with_redirect] == false) |
| 12124 | # search for a redirect |
||
| 12125 | 1464:261b3d9a4903 | Chris | redirect = redirects.where("LOWER(title) = LOWER(?)", title).first
|
| 12126 | 441:cbce1fd3b1b7 | Chris | if redirect |
| 12127 | page = find_page(redirect.redirects_to, :with_redirect => false) |
||
| 12128 | @page_found_with_redirect = true |
||
| 12129 | end |
||
| 12130 | 0:513646585e45 | Chris | end |
| 12131 | page |
||
| 12132 | end |
||
| 12133 | 909:cbb26bc654de | Chris | |
| 12134 | 441:cbce1fd3b1b7 | Chris | # Returns true if the last page was found with a redirect |
| 12135 | def page_found_with_redirect? |
||
| 12136 | @page_found_with_redirect |
||
| 12137 | end |
||
| 12138 | |||
| 12139 | 0:513646585e45 | Chris | # Finds a page by title |
| 12140 | # The given string can be of one of the forms: "title" or "project:title" |
||
| 12141 | # Examples: |
||
| 12142 | # Wiki.find_page("bar", project => foo)
|
||
| 12143 | # Wiki.find_page("foo:bar")
|
||
| 12144 | def self.find_page(title, options = {})
|
||
| 12145 | project = options[:project] |
||
| 12146 | if title.to_s =~ %r{^([^\:]+)\:(.*)$}
|
||
| 12147 | project_identifier, title = $1, $2 |
||
| 12148 | project = Project.find_by_identifier(project_identifier) || Project.find_by_name(project_identifier) |
||
| 12149 | end |
||
| 12150 | if project && project.wiki |
||
| 12151 | page = project.wiki.find_page(title) |
||
| 12152 | if page && page.content |
||
| 12153 | page |
||
| 12154 | end |
||
| 12155 | end |
||
| 12156 | end |
||
| 12157 | 909:cbb26bc654de | Chris | |
| 12158 | 0:513646585e45 | Chris | # turn a string into a valid page title |
| 12159 | def self.titleize(title) |
||
| 12160 | # replace spaces with _ and remove unwanted caracters |
||
| 12161 | title = title.gsub(/\s+/, '_').delete(',./?;|:') if title
|
||
| 12162 | # upcase the first letter |
||
| 12163 | title = (title.slice(0..0).upcase + (title.slice(1..-1) || '')) if title |
||
| 12164 | title |
||
| 12165 | 909:cbb26bc654de | Chris | end |
| 12166 | 0:513646585e45 | Chris | end |
| 12167 | 1115:433d4f72a19b | Chris | # Redmine - project management software |
| 12168 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 12169 | 0:513646585e45 | Chris | # |
| 12170 | # This program is free software; you can redistribute it and/or |
||
| 12171 | # modify it under the terms of the GNU General Public License |
||
| 12172 | # as published by the Free Software Foundation; either version 2 |
||
| 12173 | # of the License, or (at your option) any later version. |
||
| 12174 | 441:cbce1fd3b1b7 | Chris | # |
| 12175 | 0:513646585e45 | Chris | # This program is distributed in the hope that it will be useful, |
| 12176 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 12177 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 12178 | # GNU General Public License for more details. |
||
| 12179 | 441:cbce1fd3b1b7 | Chris | # |
| 12180 | 0:513646585e45 | Chris | # You should have received a copy of the GNU General Public License |
| 12181 | # along with this program; if not, write to the Free Software |
||
| 12182 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 12183 | |||
| 12184 | require 'zlib' |
||
| 12185 | |||
| 12186 | class WikiContent < ActiveRecord::Base |
||
| 12187 | 1115:433d4f72a19b | Chris | self.locking_column = 'version' |
| 12188 | 0:513646585e45 | Chris | belongs_to :page, :class_name => 'WikiPage', :foreign_key => 'page_id' |
| 12189 | belongs_to :author, :class_name => 'User', :foreign_key => 'author_id' |
||
| 12190 | validates_presence_of :text |
||
| 12191 | validates_length_of :comments, :maximum => 255, :allow_nil => true |
||
| 12192 | 441:cbce1fd3b1b7 | Chris | |
| 12193 | 0:513646585e45 | Chris | acts_as_versioned |
| 12194 | 441:cbce1fd3b1b7 | Chris | |
| 12195 | 1464:261b3d9a4903 | Chris | after_save :send_notification |
| 12196 | |||
| 12197 | 0:513646585e45 | Chris | def visible?(user=User.current) |
| 12198 | page.visible?(user) |
||
| 12199 | end |
||
| 12200 | 441:cbce1fd3b1b7 | Chris | |
| 12201 | 0:513646585e45 | Chris | def project |
| 12202 | page.project |
||
| 12203 | end |
||
| 12204 | 441:cbce1fd3b1b7 | Chris | |
| 12205 | 0:513646585e45 | Chris | def attachments |
| 12206 | page.nil? ? [] : page.attachments |
||
| 12207 | end |
||
| 12208 | 441:cbce1fd3b1b7 | Chris | |
| 12209 | 0:513646585e45 | Chris | # Returns the mail adresses of users that should be notified |
| 12210 | def recipients |
||
| 12211 | notified = project.notified_users |
||
| 12212 | notified.reject! {|user| !visible?(user)}
|
||
| 12213 | notified.collect(&:mail) |
||
| 12214 | end |
||
| 12215 | 441:cbce1fd3b1b7 | Chris | |
| 12216 | 909:cbb26bc654de | Chris | # Return true if the content is the current page content |
| 12217 | def current_version? |
||
| 12218 | true |
||
| 12219 | end |
||
| 12220 | |||
| 12221 | 0:513646585e45 | Chris | class Version |
| 12222 | belongs_to :page, :class_name => '::WikiPage', :foreign_key => 'page_id' |
||
| 12223 | belongs_to :author, :class_name => '::User', :foreign_key => 'author_id' |
||
| 12224 | attr_protected :data |
||
| 12225 | |||
| 12226 | acts_as_event :title => Proc.new {|o| "#{l(:label_wiki_edit)}: #{o.page.title} (##{o.version})"},
|
||
| 12227 | :description => :comments, |
||
| 12228 | :datetime => :updated_on, |
||
| 12229 | :type => 'wiki-page', |
||
| 12230 | 1464:261b3d9a4903 | Chris | :group => :page, |
| 12231 | 37:94944d00e43c | chris | :url => Proc.new {|o| {:controller => 'wiki', :action => 'show', :project_id => o.page.wiki.project, :id => o.page.title, :version => o.version}}
|
| 12232 | 0:513646585e45 | Chris | |
| 12233 | acts_as_activity_provider :type => 'wiki_edits', |
||
| 12234 | :timestamp => "#{WikiContent.versioned_table_name}.updated_on",
|
||
| 12235 | :author_key => "#{WikiContent.versioned_table_name}.author_id",
|
||
| 12236 | :permission => :view_wiki_edits, |
||
| 12237 | :find_options => {:select => "#{WikiContent.versioned_table_name}.updated_on, #{WikiContent.versioned_table_name}.comments, " +
|
||
| 12238 | "#{WikiContent.versioned_table_name}.#{WikiContent.version_column}, #{WikiPage.table_name}.title, " +
|
||
| 12239 | "#{WikiContent.versioned_table_name}.page_id, #{WikiContent.versioned_table_name}.author_id, " +
|
||
| 12240 | "#{WikiContent.versioned_table_name}.id",
|
||
| 12241 | :joins => "LEFT JOIN #{WikiPage.table_name} ON #{WikiPage.table_name}.id = #{WikiContent.versioned_table_name}.page_id " +
|
||
| 12242 | "LEFT JOIN #{Wiki.table_name} ON #{Wiki.table_name}.id = #{WikiPage.table_name}.wiki_id " +
|
||
| 12243 | "LEFT JOIN #{Project.table_name} ON #{Project.table_name}.id = #{Wiki.table_name}.project_id"}
|
||
| 12244 | |||
| 12245 | 1115:433d4f72a19b | Chris | after_destroy :page_update_after_destroy |
| 12246 | |||
| 12247 | 0:513646585e45 | Chris | def text=(plain) |
| 12248 | case Setting.wiki_compression |
||
| 12249 | when 'gzip' |
||
| 12250 | begin |
||
| 12251 | self.data = Zlib::Deflate.deflate(plain, Zlib::BEST_COMPRESSION) |
||
| 12252 | self.compression = 'gzip' |
||
| 12253 | rescue |
||
| 12254 | self.data = plain |
||
| 12255 | self.compression = '' |
||
| 12256 | end |
||
| 12257 | else |
||
| 12258 | self.data = plain |
||
| 12259 | self.compression = '' |
||
| 12260 | end |
||
| 12261 | plain |
||
| 12262 | end |
||
| 12263 | 441:cbce1fd3b1b7 | Chris | |
| 12264 | 0:513646585e45 | Chris | def text |
| 12265 | 1115:433d4f72a19b | Chris | @text ||= begin |
| 12266 | str = case compression |
||
| 12267 | when 'gzip' |
||
| 12268 | Zlib::Inflate.inflate(data) |
||
| 12269 | else |
||
| 12270 | # uncompressed data |
||
| 12271 | data |
||
| 12272 | end |
||
| 12273 | 909:cbb26bc654de | Chris | str.force_encoding("UTF-8") if str.respond_to?(:force_encoding)
|
| 12274 | str |
||
| 12275 | 441:cbce1fd3b1b7 | Chris | end |
| 12276 | 0:513646585e45 | Chris | end |
| 12277 | 441:cbce1fd3b1b7 | Chris | |
| 12278 | 0:513646585e45 | Chris | def project |
| 12279 | page.project |
||
| 12280 | end |
||
| 12281 | 441:cbce1fd3b1b7 | Chris | |
| 12282 | 909:cbb26bc654de | Chris | # Return true if the content is the current page content |
| 12283 | def current_version? |
||
| 12284 | page.content.version == self.version |
||
| 12285 | end |
||
| 12286 | |||
| 12287 | 0:513646585e45 | Chris | # Returns the previous version or nil |
| 12288 | def previous |
||
| 12289 | 1115:433d4f72a19b | Chris | @previous ||= WikiContent::Version. |
| 12290 | reorder('version DESC').
|
||
| 12291 | includes(:author). |
||
| 12292 | where("wiki_content_id = ? AND version < ?", wiki_content_id, version).first
|
||
| 12293 | end |
||
| 12294 | |||
| 12295 | # Returns the next version or nil |
||
| 12296 | def next |
||
| 12297 | @next ||= WikiContent::Version. |
||
| 12298 | reorder('version ASC').
|
||
| 12299 | includes(:author). |
||
| 12300 | where("wiki_content_id = ? AND version > ?", wiki_content_id, version).first
|
||
| 12301 | end |
||
| 12302 | |||
| 12303 | private |
||
| 12304 | |||
| 12305 | # Updates page's content if the latest version is removed |
||
| 12306 | # or destroys the page if it was the only version |
||
| 12307 | def page_update_after_destroy |
||
| 12308 | latest = page.content.versions.reorder("#{self.class.table_name}.version DESC").first
|
||
| 12309 | if latest && page.content.version != latest.version |
||
| 12310 | raise ActiveRecord::Rollback unless page.content.revert_to!(latest) |
||
| 12311 | elsif latest.nil? |
||
| 12312 | raise ActiveRecord::Rollback unless page.destroy |
||
| 12313 | end |
||
| 12314 | 0:513646585e45 | Chris | end |
| 12315 | end |
||
| 12316 | 1464:261b3d9a4903 | Chris | |
| 12317 | private |
||
| 12318 | |||
| 12319 | def send_notification |
||
| 12320 | # new_record? returns false in after_save callbacks |
||
| 12321 | if id_changed? |
||
| 12322 | if Setting.notified_events.include?('wiki_content_added')
|
||
| 12323 | Mailer.wiki_content_added(self).deliver |
||
| 12324 | end |
||
| 12325 | elsif text_changed? |
||
| 12326 | if Setting.notified_events.include?('wiki_content_updated')
|
||
| 12327 | Mailer.wiki_content_updated(self).deliver |
||
| 12328 | end |
||
| 12329 | end |
||
| 12330 | end |
||
| 12331 | 0:513646585e45 | Chris | end |
| 12332 | # Redmine - project management software |
||
| 12333 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 12334 | 0:513646585e45 | Chris | # |
| 12335 | # This program is free software; you can redistribute it and/or |
||
| 12336 | # modify it under the terms of the GNU General Public License |
||
| 12337 | # as published by the Free Software Foundation; either version 2 |
||
| 12338 | # of the License, or (at your option) any later version. |
||
| 12339 | 909:cbb26bc654de | Chris | # |
| 12340 | 0:513646585e45 | Chris | # This program is distributed in the hope that it will be useful, |
| 12341 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 12342 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 12343 | # GNU General Public License for more details. |
||
| 12344 | 909:cbb26bc654de | Chris | # |
| 12345 | 0:513646585e45 | Chris | # You should have received a copy of the GNU General Public License |
| 12346 | # along with this program; if not, write to the Free Software |
||
| 12347 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 12348 | |||
| 12349 | require 'diff' |
||
| 12350 | require 'enumerator' |
||
| 12351 | |||
| 12352 | class WikiPage < ActiveRecord::Base |
||
| 12353 | 1115:433d4f72a19b | Chris | include Redmine::SafeAttributes |
| 12354 | |||
| 12355 | 0:513646585e45 | Chris | belongs_to :wiki |
| 12356 | has_one :content, :class_name => 'WikiContent', :foreign_key => 'page_id', :dependent => :destroy |
||
| 12357 | acts_as_attachable :delete_permission => :delete_wiki_pages_attachments |
||
| 12358 | acts_as_tree :dependent => :nullify, :order => 'title' |
||
| 12359 | |||
| 12360 | acts_as_watchable |
||
| 12361 | acts_as_event :title => Proc.new {|o| "#{l(:label_wiki)}: #{o.title}"},
|
||
| 12362 | :description => :text, |
||
| 12363 | :datetime => :created_on, |
||
| 12364 | 37:94944d00e43c | chris | :url => Proc.new {|o| {:controller => 'wiki', :action => 'show', :project_id => o.wiki.project, :id => o.title}}
|
| 12365 | 0:513646585e45 | Chris | |
| 12366 | 909:cbb26bc654de | Chris | acts_as_searchable :columns => ['title', "#{WikiContent.table_name}.text"],
|
| 12367 | 0:513646585e45 | Chris | :include => [{:wiki => :project}, :content],
|
| 12368 | 441:cbce1fd3b1b7 | Chris | :permission => :view_wiki_pages, |
| 12369 | 0:513646585e45 | Chris | :project_key => "#{Wiki.table_name}.project_id"
|
| 12370 | |||
| 12371 | attr_accessor :redirect_existing_links |
||
| 12372 | 909:cbb26bc654de | Chris | |
| 12373 | 0:513646585e45 | Chris | validates_presence_of :title |
| 12374 | 1464:261b3d9a4903 | Chris | validates_format_of :title, :with => /\A[^,\.\/\?\;\|\s]*\z/ |
| 12375 | 0:513646585e45 | Chris | validates_uniqueness_of :title, :scope => :wiki_id, :case_sensitive => false |
| 12376 | validates_associated :content |
||
| 12377 | 909:cbb26bc654de | Chris | |
| 12378 | validate :validate_parent_title |
||
| 12379 | before_destroy :remove_redirects |
||
| 12380 | before_save :handle_redirects |
||
| 12381 | |||
| 12382 | 441:cbce1fd3b1b7 | Chris | # eager load information about last updates, without loading text |
| 12383 | 1464:261b3d9a4903 | Chris | scope :with_updated_on, lambda {
|
| 12384 | select("#{WikiPage.table_name}.*, #{WikiContent.table_name}.updated_on, #{WikiContent.table_name}.version").
|
||
| 12385 | joins("LEFT JOIN #{WikiContent.table_name} ON #{WikiContent.table_name}.page_id = #{WikiPage.table_name}.id")
|
||
| 12386 | 441:cbce1fd3b1b7 | Chris | } |
| 12387 | 909:cbb26bc654de | Chris | |
| 12388 | 0:513646585e45 | Chris | # Wiki pages that are protected by default |
| 12389 | DEFAULT_PROTECTED_PAGES = %w(sidebar) |
||
| 12390 | 909:cbb26bc654de | Chris | |
| 12391 | 1115:433d4f72a19b | Chris | safe_attributes 'parent_id', 'parent_title', |
| 12392 | :if => lambda {|page, user| page.new_record? || user.allowed_to?(:rename_wiki_pages, page.project)}
|
||
| 12393 | |||
| 12394 | def initialize(attributes=nil, *args) |
||
| 12395 | super |
||
| 12396 | 0:513646585e45 | Chris | if new_record? && DEFAULT_PROTECTED_PAGES.include?(title.to_s.downcase) |
| 12397 | self.protected = true |
||
| 12398 | end |
||
| 12399 | end |
||
| 12400 | 909:cbb26bc654de | Chris | |
| 12401 | 0:513646585e45 | Chris | def visible?(user=User.current) |
| 12402 | !user.nil? && user.allowed_to?(:view_wiki_pages, project) |
||
| 12403 | end |
||
| 12404 | |||
| 12405 | def title=(value) |
||
| 12406 | value = Wiki.titleize(value) |
||
| 12407 | @previous_title = read_attribute(:title) if @previous_title.blank? |
||
| 12408 | write_attribute(:title, value) |
||
| 12409 | end |
||
| 12410 | |||
| 12411 | 909:cbb26bc654de | Chris | def handle_redirects |
| 12412 | self.title = Wiki.titleize(title) |
||
| 12413 | 0:513646585e45 | Chris | # Manage redirects if the title has changed |
| 12414 | if !@previous_title.blank? && (@previous_title != title) && !new_record? |
||
| 12415 | # Update redirects that point to the old title |
||
| 12416 | 1517:dffacf8a6908 | Chris | wiki.redirects.where(:redirects_to => @previous_title).each do |r| |
| 12417 | 0:513646585e45 | Chris | r.redirects_to = title |
| 12418 | r.title == r.redirects_to ? r.destroy : r.save |
||
| 12419 | end |
||
| 12420 | # Remove redirects for the new title |
||
| 12421 | 1517:dffacf8a6908 | Chris | wiki.redirects.where(:title => title).each(&:destroy) |
| 12422 | 0:513646585e45 | Chris | # Create a redirect to the new title |
| 12423 | wiki.redirects << WikiRedirect.new(:title => @previous_title, :redirects_to => title) unless redirect_existing_links == "0" |
||
| 12424 | @previous_title = nil |
||
| 12425 | end |
||
| 12426 | end |
||
| 12427 | 909:cbb26bc654de | Chris | |
| 12428 | def remove_redirects |
||
| 12429 | 0:513646585e45 | Chris | # Remove redirects to this page |
| 12430 | 1517:dffacf8a6908 | Chris | wiki.redirects.where(:redirects_to => title).each(&:destroy) |
| 12431 | 0:513646585e45 | Chris | end |
| 12432 | 909:cbb26bc654de | Chris | |
| 12433 | 0:513646585e45 | Chris | def pretty_title |
| 12434 | WikiPage.pretty_title(title) |
||
| 12435 | end |
||
| 12436 | 909:cbb26bc654de | Chris | |
| 12437 | 0:513646585e45 | Chris | def content_for_version(version=nil) |
| 12438 | 1517:dffacf8a6908 | Chris | if content |
| 12439 | result = content.versions.find_by_version(version.to_i) if version |
||
| 12440 | result ||= content |
||
| 12441 | result |
||
| 12442 | end |
||
| 12443 | 0:513646585e45 | Chris | end |
| 12444 | 909:cbb26bc654de | Chris | |
| 12445 | 0:513646585e45 | Chris | def diff(version_to=nil, version_from=nil) |
| 12446 | version_to = version_to ? version_to.to_i : self.content.version |
||
| 12447 | 1115:433d4f72a19b | Chris | content_to = content.versions.find_by_version(version_to) |
| 12448 | content_from = version_from ? content.versions.find_by_version(version_from.to_i) : content_to.try(:previous) |
||
| 12449 | return nil unless content_to && content_from |
||
| 12450 | 909:cbb26bc654de | Chris | |
| 12451 | 1115:433d4f72a19b | Chris | if content_from.version > content_to.version |
| 12452 | content_to, content_from = content_from, content_to |
||
| 12453 | end |
||
| 12454 | 909:cbb26bc654de | Chris | |
| 12455 | 0:513646585e45 | Chris | (content_to && content_from) ? WikiDiff.new(content_to, content_from) : nil |
| 12456 | end |
||
| 12457 | 909:cbb26bc654de | Chris | |
| 12458 | 0:513646585e45 | Chris | def annotate(version=nil) |
| 12459 | version = version ? version.to_i : self.content.version |
||
| 12460 | c = content.versions.find_by_version(version) |
||
| 12461 | c ? WikiAnnotate.new(c) : nil |
||
| 12462 | end |
||
| 12463 | 909:cbb26bc654de | Chris | |
| 12464 | 0:513646585e45 | Chris | def self.pretty_title(str) |
| 12465 | (str && str.is_a?(String)) ? str.tr('_', ' ') : str
|
||
| 12466 | end |
||
| 12467 | 909:cbb26bc654de | Chris | |
| 12468 | 0:513646585e45 | Chris | def project |
| 12469 | wiki.project |
||
| 12470 | end |
||
| 12471 | 909:cbb26bc654de | Chris | |
| 12472 | 0:513646585e45 | Chris | def text |
| 12473 | content.text if content |
||
| 12474 | end |
||
| 12475 | 909:cbb26bc654de | Chris | |
| 12476 | 441:cbce1fd3b1b7 | Chris | def updated_on |
| 12477 | unless @updated_on |
||
| 12478 | if time = read_attribute(:updated_on) |
||
| 12479 | # content updated_on was eager loaded with the page |
||
| 12480 | 1115:433d4f72a19b | Chris | begin |
| 12481 | @updated_on = (self.class.default_timezone == :utc ? Time.parse(time.to_s).utc : Time.parse(time.to_s).localtime) |
||
| 12482 | rescue |
||
| 12483 | end |
||
| 12484 | 441:cbce1fd3b1b7 | Chris | else |
| 12485 | @updated_on = content && content.updated_on |
||
| 12486 | end |
||
| 12487 | end |
||
| 12488 | @updated_on |
||
| 12489 | end |
||
| 12490 | 909:cbb26bc654de | Chris | |
| 12491 | 0:513646585e45 | Chris | # Returns true if usr is allowed to edit the page, otherwise false |
| 12492 | def editable_by?(usr) |
||
| 12493 | !protected? || usr.allowed_to?(:protect_wiki_pages, wiki.project) |
||
| 12494 | end |
||
| 12495 | 909:cbb26bc654de | Chris | |
| 12496 | 0:513646585e45 | Chris | def attachments_deletable?(usr=User.current) |
| 12497 | editable_by?(usr) && super(usr) |
||
| 12498 | end |
||
| 12499 | 909:cbb26bc654de | Chris | |
| 12500 | 0:513646585e45 | Chris | def parent_title |
| 12501 | @parent_title || (self.parent && self.parent.pretty_title) |
||
| 12502 | end |
||
| 12503 | 909:cbb26bc654de | Chris | |
| 12504 | 0:513646585e45 | Chris | def parent_title=(t) |
| 12505 | @parent_title = t |
||
| 12506 | parent_page = t.blank? ? nil : self.wiki.find_page(t) |
||
| 12507 | self.parent = parent_page |
||
| 12508 | end |
||
| 12509 | 37:94944d00e43c | chris | |
| 12510 | 1115:433d4f72a19b | Chris | # Saves the page and its content if text was changed |
| 12511 | 1464:261b3d9a4903 | Chris | def save_with_content(content) |
| 12512 | 1115:433d4f72a19b | Chris | ret = nil |
| 12513 | transaction do |
||
| 12514 | 1464:261b3d9a4903 | Chris | self.content = content |
| 12515 | 1115:433d4f72a19b | Chris | if new_record? |
| 12516 | # Rails automatically saves associated content |
||
| 12517 | ret = save |
||
| 12518 | else |
||
| 12519 | ret = save && (content.text_changed? ? content.save : true) |
||
| 12520 | end |
||
| 12521 | raise ActiveRecord::Rollback unless ret |
||
| 12522 | end |
||
| 12523 | ret |
||
| 12524 | end |
||
| 12525 | |||
| 12526 | 0:513646585e45 | Chris | protected |
| 12527 | 909:cbb26bc654de | Chris | |
| 12528 | def validate_parent_title |
||
| 12529 | 0:513646585e45 | Chris | errors.add(:parent_title, :invalid) if !@parent_title.blank? && parent.nil? |
| 12530 | errors.add(:parent_title, :circular_dependency) if parent && (parent == self || parent.ancestors.include?(self)) |
||
| 12531 | errors.add(:parent_title, :not_same_project) if parent && (parent.wiki_id != wiki_id) |
||
| 12532 | end |
||
| 12533 | end |
||
| 12534 | |||
| 12535 | 245:051f544170fe | Chris | class WikiDiff < Redmine::Helpers::Diff |
| 12536 | attr_reader :content_to, :content_from |
||
| 12537 | 909:cbb26bc654de | Chris | |
| 12538 | 0:513646585e45 | Chris | def initialize(content_to, content_from) |
| 12539 | @content_to = content_to |
||
| 12540 | @content_from = content_from |
||
| 12541 | 245:051f544170fe | Chris | super(content_to.text, content_from.text) |
| 12542 | 0:513646585e45 | Chris | end |
| 12543 | end |
||
| 12544 | |||
| 12545 | class WikiAnnotate |
||
| 12546 | attr_reader :lines, :content |
||
| 12547 | 909:cbb26bc654de | Chris | |
| 12548 | 0:513646585e45 | Chris | def initialize(content) |
| 12549 | @content = content |
||
| 12550 | current = content |
||
| 12551 | current_lines = current.text.split(/\r?\n/) |
||
| 12552 | @lines = current_lines.collect {|t| [nil, nil, t]}
|
||
| 12553 | positions = [] |
||
| 12554 | current_lines.size.times {|i| positions << i}
|
||
| 12555 | while (current.previous) |
||
| 12556 | d = current.previous.text.split(/\r?\n/).diff(current.text.split(/\r?\n/)).diffs.flatten |
||
| 12557 | d.each_slice(3) do |s| |
||
| 12558 | sign, line = s[0], s[1] |
||
| 12559 | if sign == '+' && positions[line] && positions[line] != -1 |
||
| 12560 | if @lines[positions[line]][0].nil? |
||
| 12561 | @lines[positions[line]][0] = current.version |
||
| 12562 | @lines[positions[line]][1] = current.author |
||
| 12563 | end |
||
| 12564 | end |
||
| 12565 | end |
||
| 12566 | d.each_slice(3) do |s| |
||
| 12567 | sign, line = s[0], s[1] |
||
| 12568 | if sign == '-' |
||
| 12569 | positions.insert(line, -1) |
||
| 12570 | else |
||
| 12571 | positions[line] = nil |
||
| 12572 | end |
||
| 12573 | end |
||
| 12574 | positions.compact! |
||
| 12575 | # Stop if every line is annotated |
||
| 12576 | break unless @lines.detect { |line| line[0].nil? }
|
||
| 12577 | current = current.previous |
||
| 12578 | end |
||
| 12579 | 909:cbb26bc654de | Chris | @lines.each { |line|
|
| 12580 | 507:0c939c159af4 | Chris | line[0] ||= current.version |
| 12581 | # if the last known version is > 1 (eg. history was cleared), we don't know the author |
||
| 12582 | line[1] ||= current.author if current.version == 1 |
||
| 12583 | } |
||
| 12584 | 0:513646585e45 | Chris | end |
| 12585 | end |
||
| 12586 | 909:cbb26bc654de | Chris | # Redmine - project management software |
| 12587 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 12588 | 0:513646585e45 | Chris | # |
| 12589 | # This program is free software; you can redistribute it and/or |
||
| 12590 | # modify it under the terms of the GNU General Public License |
||
| 12591 | # as published by the Free Software Foundation; either version 2 |
||
| 12592 | # of the License, or (at your option) any later version. |
||
| 12593 | 909:cbb26bc654de | Chris | # |
| 12594 | 0:513646585e45 | Chris | # This program is distributed in the hope that it will be useful, |
| 12595 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 12596 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 12597 | # GNU General Public License for more details. |
||
| 12598 | 909:cbb26bc654de | Chris | # |
| 12599 | 0:513646585e45 | Chris | # You should have received a copy of the GNU General Public License |
| 12600 | # along with this program; if not, write to the Free Software |
||
| 12601 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 12602 | |||
| 12603 | class WikiRedirect < ActiveRecord::Base |
||
| 12604 | belongs_to :wiki |
||
| 12605 | 909:cbb26bc654de | Chris | |
| 12606 | 0:513646585e45 | Chris | validates_presence_of :title, :redirects_to |
| 12607 | validates_length_of :title, :redirects_to, :maximum => 255 |
||
| 12608 | end |
||
| 12609 | 1115:433d4f72a19b | Chris | # Redmine - project management software |
| 12610 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 12611 | 1115:433d4f72a19b | Chris | # |
| 12612 | # This program is free software; you can redistribute it and/or |
||
| 12613 | # modify it under the terms of the GNU General Public License |
||
| 12614 | # as published by the Free Software Foundation; either version 2 |
||
| 12615 | # of the License, or (at your option) any later version. |
||
| 12616 | # |
||
| 12617 | # This program is distributed in the hope that it will be useful, |
||
| 12618 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 12619 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 12620 | # GNU General Public License for more details. |
||
| 12621 | # |
||
| 12622 | # You should have received a copy of the GNU General Public License |
||
| 12623 | # along with this program; if not, write to the Free Software |
||
| 12624 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 12625 | |||
| 12626 | class WorkflowPermission < WorkflowRule |
||
| 12627 | validates_inclusion_of :rule, :in => %w(readonly required) |
||
| 12628 | validate :validate_field_name |
||
| 12629 | |||
| 12630 | # Replaces the workflow permissions for the given tracker and role |
||
| 12631 | # |
||
| 12632 | # Example: |
||
| 12633 | # WorkflowPermission.replace_permissions role, tracker, {'due_date' => {'1' => 'readonly', '2' => 'required'}}
|
||
| 12634 | def self.replace_permissions(tracker, role, permissions) |
||
| 12635 | destroy_all(:tracker_id => tracker.id, :role_id => role.id) |
||
| 12636 | |||
| 12637 | permissions.each { |field, rule_by_status_id|
|
||
| 12638 | rule_by_status_id.each { |status_id, rule|
|
||
| 12639 | if rule.present? |
||
| 12640 | WorkflowPermission.create(:role_id => role.id, :tracker_id => tracker.id, :old_status_id => status_id, :field_name => field, :rule => rule) |
||
| 12641 | end |
||
| 12642 | } |
||
| 12643 | } |
||
| 12644 | end |
||
| 12645 | |||
| 12646 | protected |
||
| 12647 | |||
| 12648 | def validate_field_name |
||
| 12649 | unless Tracker::CORE_FIELDS_ALL.include?(field_name) || field_name.to_s.match(/^\d+$/) |
||
| 12650 | errors.add :field_name, :invalid |
||
| 12651 | end |
||
| 12652 | end |
||
| 12653 | end |
||
| 12654 | # Redmine - project management software |
||
| 12655 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 12656 | 1115:433d4f72a19b | Chris | # |
| 12657 | # This program is free software; you can redistribute it and/or |
||
| 12658 | # modify it under the terms of the GNU General Public License |
||
| 12659 | # as published by the Free Software Foundation; either version 2 |
||
| 12660 | # of the License, or (at your option) any later version. |
||
| 12661 | # |
||
| 12662 | # This program is distributed in the hope that it will be useful, |
||
| 12663 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 12664 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 12665 | # GNU General Public License for more details. |
||
| 12666 | # |
||
| 12667 | # You should have received a copy of the GNU General Public License |
||
| 12668 | # along with this program; if not, write to the Free Software |
||
| 12669 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 12670 | |||
| 12671 | class WorkflowRule < ActiveRecord::Base |
||
| 12672 | self.table_name = "#{table_name_prefix}workflows#{table_name_suffix}"
|
||
| 12673 | |||
| 12674 | belongs_to :role |
||
| 12675 | belongs_to :tracker |
||
| 12676 | belongs_to :old_status, :class_name => 'IssueStatus', :foreign_key => 'old_status_id' |
||
| 12677 | belongs_to :new_status, :class_name => 'IssueStatus', :foreign_key => 'new_status_id' |
||
| 12678 | |||
| 12679 | validates_presence_of :role, :tracker, :old_status |
||
| 12680 | |||
| 12681 | # Copies workflows from source to targets |
||
| 12682 | def self.copy(source_tracker, source_role, target_trackers, target_roles) |
||
| 12683 | unless source_tracker.is_a?(Tracker) || source_role.is_a?(Role) |
||
| 12684 | raise ArgumentError.new("source_tracker or source_role must be specified")
|
||
| 12685 | end |
||
| 12686 | |||
| 12687 | target_trackers = [target_trackers].flatten.compact |
||
| 12688 | target_roles = [target_roles].flatten.compact |
||
| 12689 | |||
| 12690 | target_trackers = Tracker.sorted.all if target_trackers.empty? |
||
| 12691 | target_roles = Role.all if target_roles.empty? |
||
| 12692 | |||
| 12693 | target_trackers.each do |target_tracker| |
||
| 12694 | target_roles.each do |target_role| |
||
| 12695 | copy_one(source_tracker || target_tracker, |
||
| 12696 | source_role || target_role, |
||
| 12697 | target_tracker, |
||
| 12698 | target_role) |
||
| 12699 | end |
||
| 12700 | end |
||
| 12701 | end |
||
| 12702 | |||
| 12703 | # Copies a single set of workflows from source to target |
||
| 12704 | def self.copy_one(source_tracker, source_role, target_tracker, target_role) |
||
| 12705 | unless source_tracker.is_a?(Tracker) && !source_tracker.new_record? && |
||
| 12706 | source_role.is_a?(Role) && !source_role.new_record? && |
||
| 12707 | target_tracker.is_a?(Tracker) && !target_tracker.new_record? && |
||
| 12708 | target_role.is_a?(Role) && !target_role.new_record? |
||
| 12709 | |||
| 12710 | raise ArgumentError.new("arguments can not be nil or unsaved objects")
|
||
| 12711 | end |
||
| 12712 | |||
| 12713 | if source_tracker == target_tracker && source_role == target_role |
||
| 12714 | false |
||
| 12715 | else |
||
| 12716 | transaction do |
||
| 12717 | delete_all :tracker_id => target_tracker.id, :role_id => target_role.id |
||
| 12718 | 1464:261b3d9a4903 | Chris | connection.insert "INSERT INTO #{WorkflowRule.table_name} (tracker_id, role_id, old_status_id, new_status_id, author, assignee, field_name, #{connection.quote_column_name 'rule'}, type)" +
|
| 12719 | " SELECT #{target_tracker.id}, #{target_role.id}, old_status_id, new_status_id, author, assignee, field_name, #{connection.quote_column_name 'rule'}, type" +
|
||
| 12720 | 1115:433d4f72a19b | Chris | " FROM #{WorkflowRule.table_name}" +
|
| 12721 | " WHERE tracker_id = #{source_tracker.id} AND role_id = #{source_role.id}"
|
||
| 12722 | end |
||
| 12723 | true |
||
| 12724 | end |
||
| 12725 | end |
||
| 12726 | end |
||
| 12727 | # Redmine - project management software |
||
| 12728 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang |
| 12729 | 1115:433d4f72a19b | Chris | # |
| 12730 | # This program is free software; you can redistribute it and/or |
||
| 12731 | # modify it under the terms of the GNU General Public License |
||
| 12732 | # as published by the Free Software Foundation; either version 2 |
||
| 12733 | # of the License, or (at your option) any later version. |
||
| 12734 | # |
||
| 12735 | # This program is distributed in the hope that it will be useful, |
||
| 12736 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 12737 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 12738 | # GNU General Public License for more details. |
||
| 12739 | # |
||
| 12740 | # You should have received a copy of the GNU General Public License |
||
| 12741 | # along with this program; if not, write to the Free Software |
||
| 12742 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||
| 12743 | |||
| 12744 | class WorkflowTransition < WorkflowRule |
||
| 12745 | validates_presence_of :new_status |
||
| 12746 | |||
| 12747 | # Returns workflow transitions count by tracker and role |
||
| 12748 | def self.count_by_tracker_and_role |
||
| 12749 | counts = connection.select_all("SELECT role_id, tracker_id, count(id) AS c FROM #{table_name} WHERE type = 'WorkflowTransition' GROUP BY role_id, tracker_id")
|
||
| 12750 | roles = Role.sorted.all |
||
| 12751 | trackers = Tracker.sorted.all |
||
| 12752 | |||
| 12753 | result = [] |
||
| 12754 | trackers.each do |tracker| |
||
| 12755 | t = [] |
||
| 12756 | roles.each do |role| |
||
| 12757 | row = counts.detect {|c| c['role_id'].to_s == role.id.to_s && c['tracker_id'].to_s == tracker.id.to_s}
|
||
| 12758 | t << [role, (row.nil? ? 0 : row['c'].to_i)] |
||
| 12759 | end |
||
| 12760 | result << [tracker, t] |
||
| 12761 | end |
||
| 12762 | |||
| 12763 | result |
||
| 12764 | end |
||
| 12765 | end |