Redmine 3.4.4
This commit is contained in:
commit
64924a6376
2112 changed files with 259028 additions and 0 deletions
492
app/models/attachment.rb
Normal file
492
app/models/attachment.rb
Normal file
|
@ -0,0 +1,492 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
require "digest"
|
||||
require "fileutils"
|
||||
|
||||
class Attachment < ActiveRecord::Base
|
||||
include Redmine::SafeAttributes
|
||||
belongs_to :container, :polymorphic => true
|
||||
belongs_to :author, :class_name => "User"
|
||||
|
||||
validates_presence_of :filename, :author
|
||||
validates_length_of :filename, :maximum => 255
|
||||
validates_length_of :disk_filename, :maximum => 255
|
||||
validates_length_of :description, :maximum => 255
|
||||
validate :validate_max_file_size, :validate_file_extension
|
||||
attr_protected :id
|
||||
|
||||
acts_as_event :title => :filename,
|
||||
:url => Proc.new {|o| {:controller => 'attachments', :action => 'show', :id => o.id, :filename => o.filename}}
|
||||
|
||||
acts_as_activity_provider :type => 'files',
|
||||
:permission => :view_files,
|
||||
:author_key => :author_id,
|
||||
:scope => select("#{Attachment.table_name}.*").
|
||||
joins("LEFT JOIN #{Version.table_name} ON #{Attachment.table_name}.container_type='Version' AND #{Version.table_name}.id = #{Attachment.table_name}.container_id " +
|
||||
"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 )")
|
||||
|
||||
acts_as_activity_provider :type => 'documents',
|
||||
:permission => :view_documents,
|
||||
:author_key => :author_id,
|
||||
:scope => select("#{Attachment.table_name}.*").
|
||||
joins("LEFT JOIN #{Document.table_name} ON #{Attachment.table_name}.container_type='Document' AND #{Document.table_name}.id = #{Attachment.table_name}.container_id " +
|
||||
"LEFT JOIN #{Project.table_name} ON #{Document.table_name}.project_id = #{Project.table_name}.id")
|
||||
|
||||
cattr_accessor :storage_path
|
||||
@@storage_path = Redmine::Configuration['attachments_storage_path'] || File.join(Rails.root, "files")
|
||||
|
||||
cattr_accessor :thumbnails_storage_path
|
||||
@@thumbnails_storage_path = File.join(Rails.root, "tmp", "thumbnails")
|
||||
|
||||
before_create :files_to_final_location
|
||||
after_rollback :delete_from_disk, :on => :create
|
||||
after_commit :delete_from_disk, :on => :destroy
|
||||
after_commit :reuse_existing_file_if_possible, :on => :create
|
||||
|
||||
safe_attributes 'filename', 'content_type', 'description'
|
||||
|
||||
# Returns an unsaved copy of the attachment
|
||||
def copy(attributes=nil)
|
||||
copy = self.class.new
|
||||
copy.attributes = self.attributes.dup.except("id", "downloads")
|
||||
copy.attributes = attributes if attributes
|
||||
copy
|
||||
end
|
||||
|
||||
def validate_max_file_size
|
||||
if @temp_file && self.filesize > Setting.attachment_max_size.to_i.kilobytes
|
||||
errors.add(:base, l(:error_attachment_too_big, :max_size => Setting.attachment_max_size.to_i.kilobytes))
|
||||
end
|
||||
end
|
||||
|
||||
def validate_file_extension
|
||||
if @temp_file
|
||||
extension = File.extname(filename)
|
||||
unless self.class.valid_extension?(extension)
|
||||
errors.add(:base, l(:error_attachment_extension_not_allowed, :extension => extension))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def file=(incoming_file)
|
||||
unless incoming_file.nil?
|
||||
@temp_file = incoming_file
|
||||
if @temp_file.respond_to?(:original_filename)
|
||||
self.filename = @temp_file.original_filename
|
||||
self.filename.force_encoding("UTF-8")
|
||||
end
|
||||
if @temp_file.respond_to?(:content_type)
|
||||
self.content_type = @temp_file.content_type.to_s.chomp
|
||||
end
|
||||
self.filesize = @temp_file.size
|
||||
end
|
||||
end
|
||||
|
||||
def file
|
||||
nil
|
||||
end
|
||||
|
||||
def filename=(arg)
|
||||
write_attribute :filename, sanitize_filename(arg.to_s)
|
||||
filename
|
||||
end
|
||||
|
||||
# Copies the temporary file to its final location
|
||||
# and computes its MD5 hash
|
||||
def files_to_final_location
|
||||
if @temp_file
|
||||
self.disk_directory = target_directory
|
||||
self.disk_filename = Attachment.disk_filename(filename, disk_directory)
|
||||
logger.info("Saving attachment '#{self.diskfile}' (#{@temp_file.size} bytes)") if logger
|
||||
path = File.dirname(diskfile)
|
||||
unless File.directory?(path)
|
||||
FileUtils.mkdir_p(path)
|
||||
end
|
||||
sha = Digest::SHA256.new
|
||||
File.open(diskfile, "wb") do |f|
|
||||
if @temp_file.respond_to?(:read)
|
||||
buffer = ""
|
||||
while (buffer = @temp_file.read(8192))
|
||||
f.write(buffer)
|
||||
sha.update(buffer)
|
||||
end
|
||||
else
|
||||
f.write(@temp_file)
|
||||
sha.update(@temp_file)
|
||||
end
|
||||
end
|
||||
self.digest = sha.hexdigest
|
||||
end
|
||||
@temp_file = nil
|
||||
|
||||
if content_type.blank? && filename.present?
|
||||
self.content_type = Redmine::MimeType.of(filename)
|
||||
end
|
||||
# Don't save the content type if it's longer than the authorized length
|
||||
if self.content_type && self.content_type.length > 255
|
||||
self.content_type = nil
|
||||
end
|
||||
end
|
||||
|
||||
# Deletes the file from the file system if it's not referenced by other attachments
|
||||
def delete_from_disk
|
||||
if Attachment.where("disk_filename = ? AND id <> ?", disk_filename, id).empty?
|
||||
delete_from_disk!
|
||||
end
|
||||
end
|
||||
|
||||
# Returns file's location on disk
|
||||
def diskfile
|
||||
File.join(self.class.storage_path, disk_directory.to_s, disk_filename.to_s)
|
||||
end
|
||||
|
||||
def title
|
||||
title = filename.to_s
|
||||
if description.present?
|
||||
title << " (#{description})"
|
||||
end
|
||||
title
|
||||
end
|
||||
|
||||
def increment_download
|
||||
increment!(:downloads)
|
||||
end
|
||||
|
||||
def project
|
||||
container.try(:project)
|
||||
end
|
||||
|
||||
def visible?(user=User.current)
|
||||
if container_id
|
||||
container && container.attachments_visible?(user)
|
||||
else
|
||||
author == user
|
||||
end
|
||||
end
|
||||
|
||||
def editable?(user=User.current)
|
||||
if container_id
|
||||
container && container.attachments_editable?(user)
|
||||
else
|
||||
author == user
|
||||
end
|
||||
end
|
||||
|
||||
def deletable?(user=User.current)
|
||||
if container_id
|
||||
container && container.attachments_deletable?(user)
|
||||
else
|
||||
author == user
|
||||
end
|
||||
end
|
||||
|
||||
def image?
|
||||
!!(self.filename =~ /\.(bmp|gif|jpg|jpe|jpeg|png)$/i)
|
||||
end
|
||||
|
||||
def thumbnailable?
|
||||
image?
|
||||
end
|
||||
|
||||
# Returns the full path the attachment thumbnail, or nil
|
||||
# if the thumbnail cannot be generated.
|
||||
def thumbnail(options={})
|
||||
if thumbnailable? && readable?
|
||||
size = options[:size].to_i
|
||||
if size > 0
|
||||
# Limit the number of thumbnails per image
|
||||
size = (size / 50) * 50
|
||||
# Maximum thumbnail size
|
||||
size = 800 if size > 800
|
||||
else
|
||||
size = Setting.thumbnails_size.to_i
|
||||
end
|
||||
size = 100 unless size > 0
|
||||
target = File.join(self.class.thumbnails_storage_path, "#{id}_#{digest}_#{size}.thumb")
|
||||
|
||||
begin
|
||||
Redmine::Thumbnail.generate(self.diskfile, target, size)
|
||||
rescue => e
|
||||
logger.error "An error occured while generating thumbnail for #{disk_filename} to #{target}\nException was: #{e.message}" if logger
|
||||
return nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Deletes all thumbnails
|
||||
def self.clear_thumbnails
|
||||
Dir.glob(File.join(thumbnails_storage_path, "*.thumb")).each do |file|
|
||||
File.delete file
|
||||
end
|
||||
end
|
||||
|
||||
def is_text?
|
||||
Redmine::MimeType.is_type?('text', filename)
|
||||
end
|
||||
|
||||
def is_image?
|
||||
Redmine::MimeType.is_type?('image', filename)
|
||||
end
|
||||
|
||||
def is_diff?
|
||||
self.filename =~ /\.(patch|diff)$/i
|
||||
end
|
||||
|
||||
def is_pdf?
|
||||
Redmine::MimeType.of(filename) == "application/pdf"
|
||||
end
|
||||
|
||||
def previewable?
|
||||
is_text? || is_image?
|
||||
end
|
||||
|
||||
# Returns true if the file is readable
|
||||
def readable?
|
||||
disk_filename.present? && File.readable?(diskfile)
|
||||
end
|
||||
|
||||
# Returns the attachment token
|
||||
def token
|
||||
"#{id}.#{digest}"
|
||||
end
|
||||
|
||||
# Finds an attachment that matches the given token and that has no container
|
||||
def self.find_by_token(token)
|
||||
if token.to_s =~ /^(\d+)\.([0-9a-f]+)$/
|
||||
attachment_id, attachment_digest = $1, $2
|
||||
attachment = Attachment.where(:id => attachment_id, :digest => attachment_digest).first
|
||||
if attachment && attachment.container.nil?
|
||||
attachment
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Bulk attaches a set of files to an object
|
||||
#
|
||||
# Returns a Hash of the results:
|
||||
# :files => array of the attached files
|
||||
# :unsaved => array of the files that could not be attached
|
||||
def self.attach_files(obj, attachments)
|
||||
result = obj.save_attachments(attachments, User.current)
|
||||
obj.attach_saved_attachments
|
||||
result
|
||||
end
|
||||
|
||||
# Updates the filename and description of a set of attachments
|
||||
# with the given hash of attributes. Returns true if all
|
||||
# attachments were updated.
|
||||
#
|
||||
# Example:
|
||||
# Attachment.update_attachments(attachments, {
|
||||
# 4 => {:filename => 'foo'},
|
||||
# 7 => {:filename => 'bar', :description => 'file description'}
|
||||
# })
|
||||
#
|
||||
def self.update_attachments(attachments, params)
|
||||
params = params.transform_keys {|key| key.to_i}
|
||||
|
||||
saved = true
|
||||
transaction do
|
||||
attachments.each do |attachment|
|
||||
if p = params[attachment.id]
|
||||
attachment.filename = p[:filename] if p.key?(:filename)
|
||||
attachment.description = p[:description] if p.key?(:description)
|
||||
saved &&= attachment.save
|
||||
end
|
||||
end
|
||||
unless saved
|
||||
raise ActiveRecord::Rollback
|
||||
end
|
||||
end
|
||||
saved
|
||||
end
|
||||
|
||||
def self.latest_attach(attachments, filename)
|
||||
attachments.sort_by(&:created_on).reverse.detect do |att|
|
||||
filename.casecmp(att.filename) == 0
|
||||
end
|
||||
end
|
||||
|
||||
def self.prune(age=1.day)
|
||||
Attachment.where("created_on < ? AND (container_type IS NULL OR container_type = '')", Time.now - age).destroy_all
|
||||
end
|
||||
|
||||
# Moves an existing attachment to its target directory
|
||||
def move_to_target_directory!
|
||||
return unless !new_record? & readable?
|
||||
|
||||
src = diskfile
|
||||
self.disk_directory = target_directory
|
||||
dest = diskfile
|
||||
|
||||
return if src == dest
|
||||
|
||||
if !FileUtils.mkdir_p(File.dirname(dest))
|
||||
logger.error "Could not create directory #{File.dirname(dest)}" if logger
|
||||
return
|
||||
end
|
||||
|
||||
if !FileUtils.mv(src, dest)
|
||||
logger.error "Could not move attachment from #{src} to #{dest}" if logger
|
||||
return
|
||||
end
|
||||
|
||||
update_column :disk_directory, disk_directory
|
||||
end
|
||||
|
||||
# Moves existing attachments that are stored at the root of the files
|
||||
# directory (ie. created before Redmine 2.3) to their target subdirectories
|
||||
def self.move_from_root_to_target_directory
|
||||
Attachment.where("disk_directory IS NULL OR disk_directory = ''").find_each do |attachment|
|
||||
attachment.move_to_target_directory!
|
||||
end
|
||||
end
|
||||
|
||||
# Updates digests to SHA256 for all attachments that have a MD5 digest
|
||||
# (ie. created before Redmine 3.4)
|
||||
def self.update_digests_to_sha256
|
||||
Attachment.where("length(digest) < 64").find_each do |attachment|
|
||||
attachment.update_digest_to_sha256!
|
||||
end
|
||||
end
|
||||
|
||||
# Updates attachment digest to SHA256
|
||||
def update_digest_to_sha256!
|
||||
if readable?
|
||||
sha = Digest::SHA256.new
|
||||
File.open(diskfile, 'rb') do |f|
|
||||
while buffer = f.read(8192)
|
||||
sha.update(buffer)
|
||||
end
|
||||
end
|
||||
update_column :digest, sha.hexdigest
|
||||
end
|
||||
end
|
||||
|
||||
# Returns true if the extension is allowed regarding allowed/denied
|
||||
# extensions defined in application settings, otherwise false
|
||||
def self.valid_extension?(extension)
|
||||
denied, allowed = [:attachment_extensions_denied, :attachment_extensions_allowed].map do |setting|
|
||||
Setting.send(setting)
|
||||
end
|
||||
if denied.present? && extension_in?(extension, denied)
|
||||
return false
|
||||
end
|
||||
if allowed.present? && !extension_in?(extension, allowed)
|
||||
return false
|
||||
end
|
||||
true
|
||||
end
|
||||
|
||||
# Returns true if extension belongs to extensions list.
|
||||
def self.extension_in?(extension, extensions)
|
||||
extension = extension.downcase.sub(/\A\.+/, '')
|
||||
|
||||
unless extensions.is_a?(Array)
|
||||
extensions = extensions.to_s.split(",").map(&:strip)
|
||||
end
|
||||
extensions = extensions.map {|s| s.downcase.sub(/\A\.+/, '')}.reject(&:blank?)
|
||||
extensions.include?(extension)
|
||||
end
|
||||
|
||||
# Returns true if attachment's extension belongs to extensions list.
|
||||
def extension_in?(extensions)
|
||||
self.class.extension_in?(File.extname(filename), extensions)
|
||||
end
|
||||
|
||||
# returns either MD5 or SHA256 depending on the way self.digest was computed
|
||||
def digest_type
|
||||
digest.size < 64 ? "MD5" : "SHA256" if digest.present?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def reuse_existing_file_if_possible
|
||||
original_diskfile = nil
|
||||
|
||||
reused = with_lock do
|
||||
if existing = Attachment
|
||||
.where(digest: self.digest, filesize: self.filesize)
|
||||
.where('id <> ? and disk_filename <> ?',
|
||||
self.id, self.disk_filename)
|
||||
.first
|
||||
existing.with_lock do
|
||||
|
||||
original_diskfile = self.diskfile
|
||||
existing_diskfile = existing.diskfile
|
||||
|
||||
if File.readable?(original_diskfile) &&
|
||||
File.readable?(existing_diskfile) &&
|
||||
FileUtils.identical?(original_diskfile, existing_diskfile)
|
||||
|
||||
self.update_columns disk_directory: existing.disk_directory,
|
||||
disk_filename: existing.disk_filename
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
if reused
|
||||
File.delete(original_diskfile)
|
||||
end
|
||||
rescue ActiveRecord::StatementInvalid, ActiveRecord::RecordNotFound
|
||||
# Catch and ignore lock errors. It is not critical if deduplication does
|
||||
# not happen, therefore we do not retry.
|
||||
# with_lock throws ActiveRecord::RecordNotFound if the record isnt there
|
||||
# anymore, thats why this is caught and ignored as well.
|
||||
end
|
||||
|
||||
|
||||
# Physically deletes the file from the file system
|
||||
def delete_from_disk!
|
||||
if disk_filename.present? && File.exist?(diskfile)
|
||||
File.delete(diskfile)
|
||||
end
|
||||
end
|
||||
|
||||
def sanitize_filename(value)
|
||||
# get only the filename, not the whole path
|
||||
just_filename = value.gsub(/\A.*(\\|\/)/m, '')
|
||||
|
||||
# Finally, replace invalid characters with underscore
|
||||
just_filename.gsub(/[\/\?\%\*\:\|\"\'<>\n\r]+/, '_')
|
||||
end
|
||||
|
||||
# Returns the subdirectory in which the attachment will be saved
|
||||
def target_directory
|
||||
time = created_on || DateTime.now
|
||||
time.strftime("%Y/%m")
|
||||
end
|
||||
|
||||
# Returns an ASCII or hashed filename that do not
|
||||
# exists yet in the given subdirectory
|
||||
def self.disk_filename(filename, directory=nil)
|
||||
timestamp = DateTime.now.strftime("%y%m%d%H%M%S")
|
||||
ascii = ''
|
||||
if filename =~ %r{^[a-zA-Z0-9_\.\-]*$} && filename.length <= 50
|
||||
ascii = filename
|
||||
else
|
||||
ascii = Digest::MD5.hexdigest(filename)
|
||||
# keep the extension if any
|
||||
ascii << $1 if filename =~ %r{(\.[a-zA-Z0-9]+)$}
|
||||
end
|
||||
while File.exist?(File.join(storage_path, directory.to_s, "#{timestamp}_#{ascii}"))
|
||||
timestamp.succ!
|
||||
end
|
||||
"#{timestamp}_#{ascii}"
|
||||
end
|
||||
end
|
109
app/models/auth_source.rb
Normal file
109
app/models/auth_source.rb
Normal file
|
@ -0,0 +1,109 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
# Generic exception for when the AuthSource can not be reached
|
||||
# (eg. can not connect to the LDAP)
|
||||
class AuthSourceException < Exception; end
|
||||
class AuthSourceTimeoutException < AuthSourceException; end
|
||||
|
||||
class AuthSource < ActiveRecord::Base
|
||||
include Redmine::SafeAttributes
|
||||
include Redmine::SubclassFactory
|
||||
include Redmine::Ciphering
|
||||
|
||||
has_many :users
|
||||
|
||||
validates_presence_of :name
|
||||
validates_uniqueness_of :name
|
||||
validates_length_of :name, :maximum => 60
|
||||
attr_protected :id
|
||||
|
||||
safe_attributes 'name',
|
||||
'host',
|
||||
'port',
|
||||
'account',
|
||||
'account_password',
|
||||
'base_dn',
|
||||
'attr_login',
|
||||
'attr_firstname',
|
||||
'attr_lastname',
|
||||
'attr_mail',
|
||||
'onthefly_register',
|
||||
'tls',
|
||||
'filter',
|
||||
'timeout'
|
||||
|
||||
def authenticate(login, password)
|
||||
end
|
||||
|
||||
def test_connection
|
||||
end
|
||||
|
||||
def auth_method_name
|
||||
"Abstract"
|
||||
end
|
||||
|
||||
def account_password
|
||||
read_ciphered_attribute(:account_password)
|
||||
end
|
||||
|
||||
def account_password=(arg)
|
||||
write_ciphered_attribute(:account_password, arg)
|
||||
end
|
||||
|
||||
def searchable?
|
||||
false
|
||||
end
|
||||
|
||||
def self.search(q)
|
||||
results = []
|
||||
AuthSource.all.each do |source|
|
||||
begin
|
||||
if source.searchable?
|
||||
results += source.search(q)
|
||||
end
|
||||
rescue AuthSourceException => e
|
||||
logger.error "Error while searching users in #{source.name}: #{e.message}"
|
||||
end
|
||||
end
|
||||
results
|
||||
end
|
||||
|
||||
def allow_password_changes?
|
||||
self.class.allow_password_changes?
|
||||
end
|
||||
|
||||
# Does this auth source backend allow password changes?
|
||||
def self.allow_password_changes?
|
||||
false
|
||||
end
|
||||
|
||||
# Try to authenticate a user not yet registered against available sources
|
||||
def self.authenticate(login, password)
|
||||
AuthSource.where(:onthefly_register => true).each do |source|
|
||||
begin
|
||||
logger.debug "Authenticating '#{login}' against '#{source.name}'" if logger && logger.debug?
|
||||
attrs = source.authenticate(login, password)
|
||||
rescue => e
|
||||
logger.error "Error during authentication: #{e.message}"
|
||||
attrs = nil
|
||||
end
|
||||
return attrs if attrs
|
||||
end
|
||||
return nil
|
||||
end
|
||||
end
|
209
app/models/auth_source_ldap.rb
Normal file
209
app/models/auth_source_ldap.rb
Normal file
|
@ -0,0 +1,209 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
require 'net/ldap'
|
||||
require 'net/ldap/dn'
|
||||
require 'timeout'
|
||||
|
||||
class AuthSourceLdap < AuthSource
|
||||
NETWORK_EXCEPTIONS = [
|
||||
Net::LDAP::LdapError,
|
||||
Errno::ECONNABORTED, Errno::ECONNREFUSED, Errno::ECONNRESET,
|
||||
Errno::EHOSTDOWN, Errno::EHOSTUNREACH,
|
||||
SocketError
|
||||
]
|
||||
|
||||
validates_presence_of :host, :port, :attr_login
|
||||
validates_length_of :name, :host, :maximum => 60, :allow_nil => true
|
||||
validates_length_of :account, :account_password, :base_dn, :maximum => 255, :allow_blank => true
|
||||
validates_length_of :attr_login, :attr_firstname, :attr_lastname, :attr_mail, :maximum => 30, :allow_nil => true
|
||||
validates_numericality_of :port, :only_integer => true
|
||||
validates_numericality_of :timeout, :only_integer => true, :allow_blank => true
|
||||
validate :validate_filter
|
||||
|
||||
before_validation :strip_ldap_attributes
|
||||
|
||||
def initialize(attributes=nil, *args)
|
||||
super
|
||||
self.port = 389 if self.port == 0
|
||||
end
|
||||
|
||||
def authenticate(login, password)
|
||||
return nil if login.blank? || password.blank?
|
||||
|
||||
with_timeout do
|
||||
attrs = get_user_dn(login, password)
|
||||
if attrs && attrs[:dn] && authenticate_dn(attrs[:dn], password)
|
||||
logger.debug "Authentication successful for '#{login}'" if logger && logger.debug?
|
||||
return attrs.except(:dn)
|
||||
end
|
||||
end
|
||||
rescue *NETWORK_EXCEPTIONS => e
|
||||
raise AuthSourceException.new(e.message)
|
||||
end
|
||||
|
||||
# Test the connection to the LDAP
|
||||
def test_connection
|
||||
with_timeout do
|
||||
ldap_con = initialize_ldap_con(self.account, self.account_password)
|
||||
ldap_con.open { }
|
||||
|
||||
if self.account.present? && !self.account.include?("$login") && self.account_password.present?
|
||||
ldap_auth = authenticate_dn(self.account, self.account_password)
|
||||
raise AuthSourceException.new(l(:error_ldap_bind_credentials)) if !ldap_auth
|
||||
end
|
||||
end
|
||||
rescue *NETWORK_EXCEPTIONS => e
|
||||
raise AuthSourceException.new(e.message)
|
||||
end
|
||||
|
||||
def auth_method_name
|
||||
"LDAP"
|
||||
end
|
||||
|
||||
# Returns true if this source can be searched for users
|
||||
def searchable?
|
||||
!account.to_s.include?("$login") && %w(login firstname lastname mail).all? {|a| send("attr_#{a}?")}
|
||||
end
|
||||
|
||||
# Searches the source for users and returns an array of results
|
||||
def search(q)
|
||||
q = q.to_s.strip
|
||||
return [] unless searchable? && q.present?
|
||||
|
||||
results = []
|
||||
search_filter = base_filter & Net::LDAP::Filter.begins(self.attr_login, q)
|
||||
ldap_con = initialize_ldap_con(self.account, self.account_password)
|
||||
ldap_con.search(:base => self.base_dn,
|
||||
:filter => search_filter,
|
||||
:attributes => ['dn', self.attr_login, self.attr_firstname, self.attr_lastname, self.attr_mail],
|
||||
:size => 10) do |entry|
|
||||
attrs = get_user_attributes_from_ldap_entry(entry)
|
||||
attrs[:login] = AuthSourceLdap.get_attr(entry, self.attr_login)
|
||||
results << attrs
|
||||
end
|
||||
results
|
||||
rescue *NETWORK_EXCEPTIONS => e
|
||||
raise AuthSourceException.new(e.message)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def with_timeout(&block)
|
||||
timeout = self.timeout
|
||||
timeout = 20 unless timeout && timeout > 0
|
||||
Timeout.timeout(timeout) do
|
||||
return yield
|
||||
end
|
||||
rescue Timeout::Error => e
|
||||
raise AuthSourceTimeoutException.new(e.message)
|
||||
end
|
||||
|
||||
def ldap_filter
|
||||
if filter.present?
|
||||
Net::LDAP::Filter.construct(filter)
|
||||
end
|
||||
rescue Net::LDAP::LdapError, Net::LDAP::FilterSyntaxInvalidError
|
||||
nil
|
||||
end
|
||||
|
||||
def base_filter
|
||||
filter = Net::LDAP::Filter.eq("objectClass", "*")
|
||||
if f = ldap_filter
|
||||
filter = filter & f
|
||||
end
|
||||
filter
|
||||
end
|
||||
|
||||
def validate_filter
|
||||
if filter.present? && ldap_filter.nil?
|
||||
errors.add(:filter, :invalid)
|
||||
end
|
||||
end
|
||||
|
||||
def strip_ldap_attributes
|
||||
[:attr_login, :attr_firstname, :attr_lastname, :attr_mail].each do |attr|
|
||||
write_attribute(attr, read_attribute(attr).strip) unless read_attribute(attr).nil?
|
||||
end
|
||||
end
|
||||
|
||||
def initialize_ldap_con(ldap_user, ldap_password)
|
||||
options = { :host => self.host,
|
||||
:port => self.port,
|
||||
:encryption => (self.tls ? :simple_tls : nil)
|
||||
}
|
||||
options.merge!(:auth => { :method => :simple, :username => ldap_user, :password => ldap_password }) unless ldap_user.blank? && ldap_password.blank?
|
||||
Net::LDAP.new options
|
||||
end
|
||||
|
||||
def get_user_attributes_from_ldap_entry(entry)
|
||||
{
|
||||
:dn => entry.dn,
|
||||
:firstname => AuthSourceLdap.get_attr(entry, self.attr_firstname),
|
||||
:lastname => AuthSourceLdap.get_attr(entry, self.attr_lastname),
|
||||
:mail => AuthSourceLdap.get_attr(entry, self.attr_mail),
|
||||
:auth_source_id => self.id
|
||||
}
|
||||
end
|
||||
|
||||
# Return the attributes needed for the LDAP search. It will only
|
||||
# include the user attributes if on-the-fly registration is enabled
|
||||
def search_attributes
|
||||
if onthefly_register?
|
||||
['dn', self.attr_firstname, self.attr_lastname, self.attr_mail]
|
||||
else
|
||||
['dn']
|
||||
end
|
||||
end
|
||||
|
||||
# Check if a DN (user record) authenticates with the password
|
||||
def authenticate_dn(dn, password)
|
||||
if dn.present? && password.present?
|
||||
initialize_ldap_con(dn, password).bind
|
||||
end
|
||||
end
|
||||
|
||||
# Get the user's dn and any attributes for them, given their login
|
||||
def get_user_dn(login, password)
|
||||
ldap_con = nil
|
||||
if self.account && self.account.include?("$login")
|
||||
ldap_con = initialize_ldap_con(self.account.sub("$login", Net::LDAP::DN.escape(login)), password)
|
||||
else
|
||||
ldap_con = initialize_ldap_con(self.account, self.account_password)
|
||||
end
|
||||
attrs = {}
|
||||
search_filter = base_filter & Net::LDAP::Filter.eq(self.attr_login, login)
|
||||
ldap_con.search( :base => self.base_dn,
|
||||
:filter => search_filter,
|
||||
:attributes=> search_attributes) do |entry|
|
||||
if onthefly_register?
|
||||
attrs = get_user_attributes_from_ldap_entry(entry)
|
||||
else
|
||||
attrs = {:dn => entry.dn}
|
||||
end
|
||||
logger.debug "DN found for #{login}: #{attrs[:dn]}" if logger && logger.debug?
|
||||
end
|
||||
attrs
|
||||
end
|
||||
|
||||
def self.get_attr(entry, attr_name)
|
||||
if !attr_name.blank?
|
||||
value = entry[attr_name].is_a?(Array) ? entry[attr_name].first : entry[attr_name]
|
||||
value.to_s.force_encoding('UTF-8')
|
||||
end
|
||||
end
|
||||
end
|
96
app/models/board.rb
Normal file
96
app/models/board.rb
Normal file
|
@ -0,0 +1,96 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
class Board < ActiveRecord::Base
|
||||
include Redmine::SafeAttributes
|
||||
belongs_to :project
|
||||
has_many :messages, lambda {order("#{Message.table_name}.created_on DESC")}, :dependent => :destroy
|
||||
belongs_to :last_message, :class_name => 'Message'
|
||||
acts_as_tree :dependent => :nullify
|
||||
acts_as_positioned :scope => [:project_id, :parent_id]
|
||||
acts_as_watchable
|
||||
|
||||
validates_presence_of :name, :description
|
||||
validates_length_of :name, :maximum => 30
|
||||
validates_length_of :description, :maximum => 255
|
||||
validate :validate_board
|
||||
attr_protected :id
|
||||
|
||||
scope :visible, lambda {|*args|
|
||||
joins(:project).
|
||||
where(Project.allowed_to_condition(args.shift || User.current, :view_messages, *args))
|
||||
}
|
||||
|
||||
safe_attributes 'name', 'description', 'parent_id', 'position'
|
||||
|
||||
def visible?(user=User.current)
|
||||
!user.nil? && user.allowed_to?(:view_messages, project)
|
||||
end
|
||||
|
||||
def reload(*args)
|
||||
@valid_parents = nil
|
||||
super
|
||||
end
|
||||
|
||||
def to_s
|
||||
name
|
||||
end
|
||||
|
||||
# Returns a scope for the board topics (messages without parent)
|
||||
def topics
|
||||
messages.where(:parent_id => nil)
|
||||
end
|
||||
|
||||
def valid_parents
|
||||
@valid_parents ||= project.boards - self_and_descendants
|
||||
end
|
||||
|
||||
def reset_counters!
|
||||
self.class.reset_counters!(id)
|
||||
end
|
||||
|
||||
# Updates topics_count, messages_count and last_message_id attributes for +board_id+
|
||||
def self.reset_counters!(board_id)
|
||||
board_id = board_id.to_i
|
||||
Board.where(:id => board_id).
|
||||
update_all(["topics_count = (SELECT COUNT(*) FROM #{Message.table_name} WHERE board_id=:id AND parent_id IS NULL)," +
|
||||
" messages_count = (SELECT COUNT(*) FROM #{Message.table_name} WHERE board_id=:id)," +
|
||||
" last_message_id = (SELECT MAX(id) FROM #{Message.table_name} WHERE board_id=:id)", :id => board_id])
|
||||
end
|
||||
|
||||
def self.board_tree(boards, parent_id=nil, level=0)
|
||||
tree = []
|
||||
boards.select {|board| board.parent_id == parent_id}.sort_by(&:position).each do |board|
|
||||
tree << [board, level]
|
||||
tree += board_tree(boards, board.id, level+1)
|
||||
end
|
||||
if block_given?
|
||||
tree.each do |board, level|
|
||||
yield board, level
|
||||
end
|
||||
end
|
||||
tree
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def validate_board
|
||||
if parent_id && parent_id_changed?
|
||||
errors.add(:parent_id, :invalid) unless valid_parents.include?(parent)
|
||||
end
|
||||
end
|
||||
end
|
34
app/models/change.rb
Normal file
34
app/models/change.rb
Normal file
|
@ -0,0 +1,34 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
class Change < ActiveRecord::Base
|
||||
belongs_to :changeset
|
||||
|
||||
validates_presence_of :changeset_id, :action, :path
|
||||
before_save :init_path
|
||||
before_validation :replace_invalid_utf8_of_path
|
||||
attr_protected :id
|
||||
|
||||
def replace_invalid_utf8_of_path
|
||||
self.path = Redmine::CodesetUtil.replace_invalid_utf8(self.path)
|
||||
self.from_path = Redmine::CodesetUtil.replace_invalid_utf8(self.from_path)
|
||||
end
|
||||
|
||||
def init_path
|
||||
self.path ||= ""
|
||||
end
|
||||
end
|
296
app/models/changeset.rb
Normal file
296
app/models/changeset.rb
Normal file
|
@ -0,0 +1,296 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
class Changeset < ActiveRecord::Base
|
||||
belongs_to :repository
|
||||
belongs_to :user
|
||||
has_many :filechanges, :class_name => 'Change', :dependent => :delete_all
|
||||
has_and_belongs_to_many :issues
|
||||
has_and_belongs_to_many :parents,
|
||||
:class_name => "Changeset",
|
||||
:join_table => "#{table_name_prefix}changeset_parents#{table_name_suffix}",
|
||||
:association_foreign_key => 'parent_id', :foreign_key => 'changeset_id'
|
||||
has_and_belongs_to_many :children,
|
||||
:class_name => "Changeset",
|
||||
:join_table => "#{table_name_prefix}changeset_parents#{table_name_suffix}",
|
||||
:association_foreign_key => 'changeset_id', :foreign_key => 'parent_id'
|
||||
|
||||
acts_as_event :title => Proc.new {|o| o.title},
|
||||
:description => :long_comments,
|
||||
:datetime => :committed_on,
|
||||
:url => Proc.new {|o| {:controller => 'repositories', :action => 'revision', :id => o.repository.project, :repository_id => o.repository.identifier_param, :rev => o.identifier}}
|
||||
|
||||
acts_as_searchable :columns => 'comments',
|
||||
:preload => {:repository => :project},
|
||||
:project_key => "#{Repository.table_name}.project_id",
|
||||
:date_column => :committed_on
|
||||
|
||||
acts_as_activity_provider :timestamp => "#{table_name}.committed_on",
|
||||
:author_key => :user_id,
|
||||
:scope => preload(:user, {:repository => :project})
|
||||
|
||||
validates_presence_of :repository_id, :revision, :committed_on, :commit_date
|
||||
validates_uniqueness_of :revision, :scope => :repository_id
|
||||
validates_uniqueness_of :scmid, :scope => :repository_id, :allow_nil => true
|
||||
attr_protected :id
|
||||
|
||||
scope :visible, lambda {|*args|
|
||||
joins(:repository => :project).
|
||||
where(Project.allowed_to_condition(args.shift || User.current, :view_changesets, *args))
|
||||
}
|
||||
|
||||
after_create :scan_for_issues
|
||||
before_create :before_create_cs
|
||||
|
||||
def revision=(r)
|
||||
write_attribute :revision, (r.nil? ? nil : r.to_s)
|
||||
end
|
||||
|
||||
# Returns the identifier of this changeset; depending on repository backends
|
||||
def identifier
|
||||
if repository.class.respond_to? :changeset_identifier
|
||||
repository.class.changeset_identifier self
|
||||
else
|
||||
revision.to_s
|
||||
end
|
||||
end
|
||||
|
||||
def committed_on=(date)
|
||||
self.commit_date = date
|
||||
super
|
||||
end
|
||||
|
||||
# Returns the readable identifier
|
||||
def format_identifier
|
||||
if repository.class.respond_to? :format_changeset_identifier
|
||||
repository.class.format_changeset_identifier self
|
||||
else
|
||||
identifier
|
||||
end
|
||||
end
|
||||
|
||||
def project
|
||||
repository.project
|
||||
end
|
||||
|
||||
def author
|
||||
user || committer.to_s.split('<').first
|
||||
end
|
||||
|
||||
def before_create_cs
|
||||
self.committer = self.class.to_utf8(self.committer, repository.repo_log_encoding)
|
||||
self.comments = self.class.normalize_comments(
|
||||
self.comments, repository.repo_log_encoding)
|
||||
self.user = repository.find_committer_user(self.committer)
|
||||
end
|
||||
|
||||
def scan_for_issues
|
||||
scan_comment_for_issue_ids
|
||||
end
|
||||
|
||||
TIMELOG_RE = /
|
||||
(
|
||||
((\d+)(h|hours?))((\d+)(m|min)?)?
|
||||
|
|
||||
((\d+)(h|hours?|m|min))
|
||||
|
|
||||
(\d+):(\d+)
|
||||
|
|
||||
(\d+([\.,]\d+)?)h?
|
||||
)
|
||||
/x
|
||||
|
||||
def scan_comment_for_issue_ids
|
||||
return if comments.blank?
|
||||
# keywords used to reference issues
|
||||
ref_keywords = Setting.commit_ref_keywords.downcase.split(",").collect(&:strip)
|
||||
ref_keywords_any = ref_keywords.delete('*')
|
||||
# keywords used to fix issues
|
||||
fix_keywords = Setting.commit_update_keywords_array.map {|r| r['keywords']}.flatten.compact
|
||||
|
||||
kw_regexp = (ref_keywords + fix_keywords).collect{|kw| Regexp.escape(kw)}.join("|")
|
||||
|
||||
referenced_issues = []
|
||||
|
||||
comments.scan(/([\s\(\[,-]|^)((#{kw_regexp})[\s:]+)?(#\d+(\s+@#{TIMELOG_RE})?([\s,;&]+#\d+(\s+@#{TIMELOG_RE})?)*)(?=[[:punct:]]|\s|<|$)/i) do |match|
|
||||
action, refs = match[2].to_s.downcase, match[3]
|
||||
next unless action.present? || ref_keywords_any
|
||||
|
||||
refs.scan(/#(\d+)(\s+@#{TIMELOG_RE})?/).each do |m|
|
||||
issue, hours = find_referenced_issue_by_id(m[0].to_i), m[2]
|
||||
if issue && !issue_linked_to_same_commit?(issue)
|
||||
referenced_issues << issue
|
||||
# Don't update issues or log time when importing old commits
|
||||
unless repository.created_on && committed_on && committed_on < repository.created_on
|
||||
fix_issue(issue, action) if fix_keywords.include?(action)
|
||||
log_time(issue, hours) if hours && Setting.commit_logtime_enabled?
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
referenced_issues.uniq!
|
||||
self.issues = referenced_issues unless referenced_issues.empty?
|
||||
end
|
||||
|
||||
def short_comments
|
||||
@short_comments || split_comments.first
|
||||
end
|
||||
|
||||
def long_comments
|
||||
@long_comments || split_comments.last
|
||||
end
|
||||
|
||||
def text_tag(ref_project=nil)
|
||||
repo = ""
|
||||
if repository && repository.identifier.present?
|
||||
repo = "#{repository.identifier}|"
|
||||
end
|
||||
tag = if scmid?
|
||||
"commit:#{repo}#{scmid}"
|
||||
else
|
||||
"#{repo}r#{revision}"
|
||||
end
|
||||
if ref_project && project && ref_project != project
|
||||
tag = "#{project.identifier}:#{tag}"
|
||||
end
|
||||
tag
|
||||
end
|
||||
|
||||
# Returns the title used for the changeset in the activity/search results
|
||||
def title
|
||||
repo = (repository && repository.identifier.present?) ? " (#{repository.identifier})" : ''
|
||||
comm = short_comments.blank? ? '' : (': ' + short_comments)
|
||||
"#{l(:label_revision)} #{format_identifier}#{repo}#{comm}"
|
||||
end
|
||||
|
||||
# Returns the previous changeset
|
||||
def previous
|
||||
@previous ||= Changeset.where(["id < ? AND repository_id = ?", id, repository_id]).order('id DESC').first
|
||||
end
|
||||
|
||||
# Returns the next changeset
|
||||
def next
|
||||
@next ||= Changeset.where(["id > ? AND repository_id = ?", id, repository_id]).order('id ASC').first
|
||||
end
|
||||
|
||||
# Creates a new Change from it's common parameters
|
||||
def create_change(change)
|
||||
Change.create(:changeset => self,
|
||||
:action => change[:action],
|
||||
:path => change[:path],
|
||||
:from_path => change[:from_path],
|
||||
:from_revision => change[:from_revision])
|
||||
end
|
||||
|
||||
# Finds an issue that can be referenced by the commit message
|
||||
def find_referenced_issue_by_id(id)
|
||||
return nil if id.blank?
|
||||
issue = Issue.find_by_id(id.to_i)
|
||||
if Setting.commit_cross_project_ref?
|
||||
# all issues can be referenced/fixed
|
||||
elsif issue
|
||||
# issue that belong to the repository project, a subproject or a parent project only
|
||||
unless issue.project &&
|
||||
(project == issue.project || project.is_ancestor_of?(issue.project) ||
|
||||
project.is_descendant_of?(issue.project))
|
||||
issue = nil
|
||||
end
|
||||
end
|
||||
issue
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Returns true if the issue is already linked to the same commit
|
||||
# from a different repository
|
||||
def issue_linked_to_same_commit?(issue)
|
||||
repository.same_commits_in_scope(issue.changesets, self).any?
|
||||
end
|
||||
|
||||
# Updates the +issue+ according to +action+
|
||||
def fix_issue(issue, action)
|
||||
# the issue may have been updated by the closure of another one (eg. duplicate)
|
||||
issue.reload
|
||||
# don't change the status is the issue is closed
|
||||
return if issue.closed?
|
||||
|
||||
journal = issue.init_journal(user || User.anonymous,
|
||||
ll(Setting.default_language,
|
||||
:text_status_changed_by_changeset,
|
||||
text_tag(issue.project)))
|
||||
rule = Setting.commit_update_keywords_array.detect do |rule|
|
||||
rule['keywords'].include?(action) &&
|
||||
(rule['if_tracker_id'].blank? || rule['if_tracker_id'] == issue.tracker_id.to_s)
|
||||
end
|
||||
if rule
|
||||
issue.assign_attributes rule.slice(*Issue.attribute_names)
|
||||
end
|
||||
Redmine::Hook.call_hook(:model_changeset_scan_commit_for_issue_ids_pre_issue_update,
|
||||
{ :changeset => self, :issue => issue, :action => action })
|
||||
|
||||
if issue.changes.any?
|
||||
unless issue.save
|
||||
logger.warn("Issue ##{issue.id} could not be saved by changeset #{id}: #{issue.errors.full_messages}") if logger
|
||||
end
|
||||
else
|
||||
issue.clear_journal
|
||||
end
|
||||
issue
|
||||
end
|
||||
|
||||
def log_time(issue, hours)
|
||||
time_entry = TimeEntry.new(
|
||||
:user => user,
|
||||
:hours => hours,
|
||||
:issue => issue,
|
||||
:spent_on => commit_date,
|
||||
:comments => l(:text_time_logged_by_changeset, :value => text_tag(issue.project),
|
||||
:locale => Setting.default_language)
|
||||
)
|
||||
time_entry.activity = log_time_activity unless log_time_activity.nil?
|
||||
|
||||
unless time_entry.save
|
||||
logger.warn("TimeEntry could not be created by changeset #{id}: #{time_entry.errors.full_messages}") if logger
|
||||
end
|
||||
time_entry
|
||||
end
|
||||
|
||||
def log_time_activity
|
||||
if Setting.commit_logtime_activity_id.to_i > 0
|
||||
TimeEntryActivity.find_by_id(Setting.commit_logtime_activity_id.to_i)
|
||||
end
|
||||
end
|
||||
|
||||
def split_comments
|
||||
comments =~ /\A(.+?)\r?\n(.*)$/m
|
||||
@short_comments = $1 || comments
|
||||
@long_comments = $2.to_s.strip
|
||||
return @short_comments, @long_comments
|
||||
end
|
||||
|
||||
public
|
||||
|
||||
# Strips and reencodes a commit log before insertion into the database
|
||||
def self.normalize_comments(str, encoding)
|
||||
Changeset.to_utf8(str.to_s.strip, encoding)
|
||||
end
|
||||
|
||||
def self.to_utf8(str, encoding)
|
||||
Redmine::CodesetUtil.to_utf8(str, encoding)
|
||||
end
|
||||
end
|
38
app/models/comment.rb
Normal file
38
app/models/comment.rb
Normal file
|
@ -0,0 +1,38 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
class Comment < ActiveRecord::Base
|
||||
include Redmine::SafeAttributes
|
||||
belongs_to :commented, :polymorphic => true, :counter_cache => true
|
||||
belongs_to :author, :class_name => 'User'
|
||||
|
||||
validates_presence_of :commented, :author, :comments
|
||||
attr_protected :id
|
||||
|
||||
after_create :send_notification
|
||||
|
||||
safe_attributes 'comments'
|
||||
|
||||
private
|
||||
|
||||
def send_notification
|
||||
mailer_method = "#{commented.class.name.underscore}_comment_added"
|
||||
if Setting.notified_events.include?(mailer_method)
|
||||
Mailer.send(mailer_method, self).deliver
|
||||
end
|
||||
end
|
||||
end
|
333
app/models/custom_field.rb
Normal file
333
app/models/custom_field.rb
Normal file
|
@ -0,0 +1,333 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
class CustomField < ActiveRecord::Base
|
||||
include Redmine::SafeAttributes
|
||||
include Redmine::SubclassFactory
|
||||
|
||||
has_many :enumerations,
|
||||
lambda { order(:position) },
|
||||
:class_name => 'CustomFieldEnumeration',
|
||||
:dependent => :delete_all
|
||||
has_many :custom_values, :dependent => :delete_all
|
||||
has_and_belongs_to_many :roles, :join_table => "#{table_name_prefix}custom_fields_roles#{table_name_suffix}", :foreign_key => "custom_field_id"
|
||||
acts_as_positioned
|
||||
serialize :possible_values
|
||||
store :format_store
|
||||
|
||||
validates_presence_of :name, :field_format
|
||||
validates_uniqueness_of :name, :scope => :type
|
||||
validates_length_of :name, :maximum => 30
|
||||
validates_length_of :regexp, maximum: 255
|
||||
validates_inclusion_of :field_format, :in => Proc.new { Redmine::FieldFormat.available_formats }
|
||||
validate :validate_custom_field
|
||||
attr_protected :id
|
||||
|
||||
before_validation :set_searchable
|
||||
before_save do |field|
|
||||
field.format.before_custom_field_save(field)
|
||||
end
|
||||
after_save :handle_multiplicity_change
|
||||
after_save do |field|
|
||||
if field.visible_changed? && field.visible
|
||||
field.roles.clear
|
||||
end
|
||||
end
|
||||
|
||||
scope :sorted, lambda { order(:position) }
|
||||
scope :visible, lambda {|*args|
|
||||
user = args.shift || User.current
|
||||
if user.admin?
|
||||
# nop
|
||||
elsif user.memberships.any?
|
||||
where("#{table_name}.visible = ? OR #{table_name}.id IN (SELECT DISTINCT cfr.custom_field_id FROM #{Member.table_name} m" +
|
||||
" INNER JOIN #{MemberRole.table_name} mr ON mr.member_id = m.id" +
|
||||
" INNER JOIN #{table_name_prefix}custom_fields_roles#{table_name_suffix} cfr ON cfr.role_id = mr.role_id" +
|
||||
" WHERE m.user_id = ?)",
|
||||
true, user.id)
|
||||
else
|
||||
where(:visible => true)
|
||||
end
|
||||
}
|
||||
def visible_by?(project, user=User.current)
|
||||
visible? || user.admin?
|
||||
end
|
||||
|
||||
safe_attributes 'name',
|
||||
'field_format',
|
||||
'possible_values',
|
||||
'regexp',
|
||||
'min_length',
|
||||
'max_length',
|
||||
'is_required',
|
||||
'is_for_all',
|
||||
'is_filter',
|
||||
'position',
|
||||
'searchable',
|
||||
'default_value',
|
||||
'editable',
|
||||
'visible',
|
||||
'multiple',
|
||||
'description',
|
||||
'role_ids',
|
||||
'url_pattern',
|
||||
'text_formatting',
|
||||
'edit_tag_style',
|
||||
'user_role',
|
||||
'version_status',
|
||||
'extensions_allowed',
|
||||
'full_width_layout'
|
||||
|
||||
def format
|
||||
@format ||= Redmine::FieldFormat.find(field_format)
|
||||
end
|
||||
|
||||
def field_format=(arg)
|
||||
# cannot change format of a saved custom field
|
||||
if new_record?
|
||||
@format = nil
|
||||
super
|
||||
end
|
||||
end
|
||||
|
||||
def set_searchable
|
||||
# make sure these fields are not searchable
|
||||
self.searchable = false unless format.class.searchable_supported
|
||||
# make sure only these fields can have multiple values
|
||||
self.multiple = false unless format.class.multiple_supported
|
||||
true
|
||||
end
|
||||
|
||||
def validate_custom_field
|
||||
format.validate_custom_field(self).each do |attribute, message|
|
||||
errors.add attribute, message
|
||||
end
|
||||
|
||||
if regexp.present?
|
||||
begin
|
||||
Regexp.new(regexp)
|
||||
rescue
|
||||
errors.add(:regexp, :invalid)
|
||||
end
|
||||
end
|
||||
|
||||
if default_value.present?
|
||||
validate_field_value(default_value).each do |message|
|
||||
errors.add :default_value, message
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def possible_custom_value_options(custom_value)
|
||||
format.possible_custom_value_options(custom_value)
|
||||
end
|
||||
|
||||
def possible_values_options(object=nil)
|
||||
if object.is_a?(Array)
|
||||
object.map {|o| format.possible_values_options(self, o)}.reduce(:&) || []
|
||||
else
|
||||
format.possible_values_options(self, object) || []
|
||||
end
|
||||
end
|
||||
|
||||
def possible_values
|
||||
values = read_attribute(:possible_values)
|
||||
if values.is_a?(Array)
|
||||
values.each do |value|
|
||||
value.to_s.force_encoding('UTF-8')
|
||||
end
|
||||
values
|
||||
else
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
# Makes possible_values accept a multiline string
|
||||
def possible_values=(arg)
|
||||
if arg.is_a?(Array)
|
||||
values = arg.compact.map {|a| a.to_s.strip}.reject(&:blank?)
|
||||
write_attribute(:possible_values, values)
|
||||
else
|
||||
self.possible_values = arg.to_s.split(/[\n\r]+/)
|
||||
end
|
||||
end
|
||||
|
||||
def set_custom_field_value(custom_field_value, value)
|
||||
format.set_custom_field_value(self, custom_field_value, value)
|
||||
end
|
||||
|
||||
def cast_value(value)
|
||||
format.cast_value(self, value)
|
||||
end
|
||||
|
||||
def value_from_keyword(keyword, customized)
|
||||
format.value_from_keyword(self, keyword, customized)
|
||||
end
|
||||
|
||||
# Returns the options hash used to build a query filter for the field
|
||||
def query_filter_options(query)
|
||||
format.query_filter_options(self, query)
|
||||
end
|
||||
|
||||
def totalable?
|
||||
format.totalable_supported
|
||||
end
|
||||
|
||||
def full_width_layout?
|
||||
full_width_layout == '1'
|
||||
end
|
||||
|
||||
# Returns a ORDER BY clause that can used to sort customized
|
||||
# objects by their value of the custom field.
|
||||
# Returns nil if the custom field can not be used for sorting.
|
||||
def order_statement
|
||||
return nil if multiple?
|
||||
format.order_statement(self)
|
||||
end
|
||||
|
||||
# Returns a GROUP BY clause that can used to group by custom value
|
||||
# Returns nil if the custom field can not be used for grouping.
|
||||
def group_statement
|
||||
return nil if multiple?
|
||||
format.group_statement(self)
|
||||
end
|
||||
|
||||
def join_for_order_statement
|
||||
format.join_for_order_statement(self)
|
||||
end
|
||||
|
||||
def visibility_by_project_condition(project_key=nil, user=User.current, id_column=nil)
|
||||
if visible? || user.admin?
|
||||
"1=1"
|
||||
elsif user.anonymous?
|
||||
"1=0"
|
||||
else
|
||||
project_key ||= "#{self.class.customized_class.table_name}.project_id"
|
||||
id_column ||= id
|
||||
"#{project_key} IN (SELECT DISTINCT m.project_id FROM #{Member.table_name} m" +
|
||||
" INNER JOIN #{MemberRole.table_name} mr ON mr.member_id = m.id" +
|
||||
" INNER JOIN #{table_name_prefix}custom_fields_roles#{table_name_suffix} cfr ON cfr.role_id = mr.role_id" +
|
||||
" WHERE m.user_id = #{user.id} AND cfr.custom_field_id = #{id_column})"
|
||||
end
|
||||
end
|
||||
|
||||
def self.visibility_condition
|
||||
if user.admin?
|
||||
"1=1"
|
||||
elsif user.anonymous?
|
||||
"#{table_name}.visible"
|
||||
else
|
||||
"#{project_key} IN (SELECT DISTINCT m.project_id FROM #{Member.table_name} m" +
|
||||
" INNER JOIN #{MemberRole.table_name} mr ON mr.member_id = m.id" +
|
||||
" INNER JOIN #{table_name_prefix}custom_fields_roles#{table_name_suffix} cfr ON cfr.role_id = mr.role_id" +
|
||||
" WHERE m.user_id = #{user.id} AND cfr.custom_field_id = #{id})"
|
||||
end
|
||||
end
|
||||
|
||||
def <=>(field)
|
||||
position <=> field.position
|
||||
end
|
||||
|
||||
# Returns the class that values represent
|
||||
def value_class
|
||||
format.target_class if format.respond_to?(:target_class)
|
||||
end
|
||||
|
||||
def self.customized_class
|
||||
self.name =~ /^(.+)CustomField$/
|
||||
$1.constantize rescue nil
|
||||
end
|
||||
|
||||
# to move in project_custom_field
|
||||
def self.for_all
|
||||
where(:is_for_all => true).order(:position).to_a
|
||||
end
|
||||
|
||||
def type_name
|
||||
nil
|
||||
end
|
||||
|
||||
# Returns the error messages for the given value
|
||||
# or an empty array if value is a valid value for the custom field
|
||||
def validate_custom_value(custom_value)
|
||||
value = custom_value.value
|
||||
errs = format.validate_custom_value(custom_value)
|
||||
|
||||
unless errs.any?
|
||||
if value.is_a?(Array)
|
||||
if !multiple?
|
||||
errs << ::I18n.t('activerecord.errors.messages.invalid')
|
||||
end
|
||||
if is_required? && value.detect(&:present?).nil?
|
||||
errs << ::I18n.t('activerecord.errors.messages.blank')
|
||||
end
|
||||
else
|
||||
if is_required? && value.blank?
|
||||
errs << ::I18n.t('activerecord.errors.messages.blank')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
errs
|
||||
end
|
||||
|
||||
# Returns the error messages for the default custom field value
|
||||
def validate_field_value(value)
|
||||
validate_custom_value(CustomFieldValue.new(:custom_field => self, :value => value))
|
||||
end
|
||||
|
||||
# Returns true if value is a valid value for the custom field
|
||||
def valid_field_value?(value)
|
||||
validate_field_value(value).empty?
|
||||
end
|
||||
|
||||
def after_save_custom_value(custom_value)
|
||||
format.after_save_custom_value(self, custom_value)
|
||||
end
|
||||
|
||||
def format_in?(*args)
|
||||
args.include?(field_format)
|
||||
end
|
||||
|
||||
def self.human_attribute_name(attribute_key_name, *args)
|
||||
attr_name = attribute_key_name.to_s
|
||||
if attr_name == 'url_pattern'
|
||||
attr_name = "url"
|
||||
end
|
||||
super(attr_name, *args)
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
# Removes multiple values for the custom field after setting the multiple attribute to false
|
||||
# We kepp the value with the highest id for each customized object
|
||||
def handle_multiplicity_change
|
||||
if !new_record? && multiple_was && !multiple
|
||||
ids = custom_values.
|
||||
where("EXISTS(SELECT 1 FROM #{CustomValue.table_name} cve WHERE cve.custom_field_id = #{CustomValue.table_name}.custom_field_id" +
|
||||
" AND cve.customized_type = #{CustomValue.table_name}.customized_type AND cve.customized_id = #{CustomValue.table_name}.customized_id" +
|
||||
" AND cve.id > #{CustomValue.table_name}.id)").
|
||||
pluck(:id)
|
||||
|
||||
if ids.any?
|
||||
custom_values.where(:id => ids).delete_all
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
require_dependency 'redmine/field_format'
|
82
app/models/custom_field_enumeration.rb
Normal file
82
app/models/custom_field_enumeration.rb
Normal file
|
@ -0,0 +1,82 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
class CustomFieldEnumeration < ActiveRecord::Base
|
||||
belongs_to :custom_field
|
||||
|
||||
validates_presence_of :name, :position, :custom_field_id
|
||||
validates_length_of :name, :maximum => 60
|
||||
validates_numericality_of :position, :only_integer => true
|
||||
before_create :set_position
|
||||
|
||||
scope :active, lambda { where(:active => true) }
|
||||
|
||||
def to_s
|
||||
name.to_s
|
||||
end
|
||||
|
||||
def objects_count
|
||||
custom_values.count
|
||||
end
|
||||
|
||||
def in_use?
|
||||
objects_count > 0
|
||||
end
|
||||
|
||||
alias :destroy_without_reassign :destroy
|
||||
def destroy(reassign_to=nil)
|
||||
if reassign_to
|
||||
custom_values.update_all(:value => reassign_to.id.to_s)
|
||||
end
|
||||
destroy_without_reassign
|
||||
end
|
||||
|
||||
def custom_values
|
||||
custom_field.custom_values.where(:value => id.to_s)
|
||||
end
|
||||
|
||||
def self.update_each(custom_field, attributes)
|
||||
transaction do
|
||||
attributes.each do |enumeration_id, enumeration_attributes|
|
||||
enumeration = custom_field.enumerations.find_by_id(enumeration_id)
|
||||
if enumeration
|
||||
if block_given?
|
||||
yield enumeration, enumeration_attributes
|
||||
else
|
||||
enumeration.attributes = enumeration_attributes
|
||||
end
|
||||
unless enumeration.save
|
||||
raise ActiveRecord::Rollback
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def self.fields_for_order_statement(table=nil)
|
||||
table ||= table_name
|
||||
columns = ['position']
|
||||
columns.uniq.map {|field| "#{table}.#{field}"}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_position
|
||||
max = self.class.where(:custom_field_id => custom_field_id).maximum(:position) || 0
|
||||
self.position = max + 1
|
||||
end
|
||||
end
|
68
app/models/custom_field_value.rb
Normal file
68
app/models/custom_field_value.rb
Normal file
|
@ -0,0 +1,68 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
class CustomFieldValue
|
||||
attr_accessor :custom_field, :customized, :value, :value_was
|
||||
|
||||
def initialize(attributes={})
|
||||
attributes.each do |name, v|
|
||||
send "#{name}=", v
|
||||
end
|
||||
end
|
||||
|
||||
def custom_field_id
|
||||
custom_field.id
|
||||
end
|
||||
|
||||
def true?
|
||||
self.value == '1'
|
||||
end
|
||||
|
||||
def editable?
|
||||
custom_field.editable?
|
||||
end
|
||||
|
||||
def visible?
|
||||
custom_field.visible?
|
||||
end
|
||||
|
||||
def required?
|
||||
custom_field.is_required?
|
||||
end
|
||||
|
||||
def to_s
|
||||
value.to_s
|
||||
end
|
||||
|
||||
def value=(v)
|
||||
@value = custom_field.set_custom_field_value(self, v)
|
||||
end
|
||||
|
||||
def value_present?
|
||||
if value.is_a?(Array)
|
||||
value.any?(&:present?)
|
||||
else
|
||||
value.present?
|
||||
end
|
||||
end
|
||||
|
||||
def validate_value
|
||||
custom_field.validate_custom_value(self).each do |message|
|
||||
customized.errors.add(:base, custom_field.name + ' ' + message)
|
||||
end
|
||||
end
|
||||
end
|
68
app/models/custom_value.rb
Normal file
68
app/models/custom_value.rb
Normal file
|
@ -0,0 +1,68 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
class CustomValue < ActiveRecord::Base
|
||||
belongs_to :custom_field
|
||||
belongs_to :customized, :polymorphic => true
|
||||
attr_protected :id
|
||||
|
||||
after_save :custom_field_after_save_custom_value
|
||||
|
||||
def initialize(attributes=nil, *args)
|
||||
super
|
||||
if new_record? && custom_field && !attributes.key?(:value)
|
||||
self.value ||= custom_field.default_value
|
||||
end
|
||||
end
|
||||
|
||||
# Returns true if the boolean custom value is true
|
||||
def true?
|
||||
self.value == '1'
|
||||
end
|
||||
|
||||
def editable?
|
||||
custom_field.editable?
|
||||
end
|
||||
|
||||
def visible?(user=User.current)
|
||||
if custom_field.visible?
|
||||
true
|
||||
elsif customized.respond_to?(:project)
|
||||
custom_field.visible_by?(customized.project, user)
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
def attachments_visible?(user)
|
||||
visible?(user) && customized && customized.visible?(user)
|
||||
end
|
||||
|
||||
def required?
|
||||
custom_field.is_required?
|
||||
end
|
||||
|
||||
def to_s
|
||||
value.to_s
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def custom_field_after_save_custom_value
|
||||
custom_field.after_save_custom_value(self)
|
||||
end
|
||||
end
|
75
app/models/document.rb
Normal file
75
app/models/document.rb
Normal file
|
@ -0,0 +1,75 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
class Document < ActiveRecord::Base
|
||||
include Redmine::SafeAttributes
|
||||
belongs_to :project
|
||||
belongs_to :category, :class_name => "DocumentCategory"
|
||||
acts_as_attachable :delete_permission => :delete_documents
|
||||
acts_as_customizable
|
||||
|
||||
acts_as_searchable :columns => ['title', "#{table_name}.description"],
|
||||
:preload => :project
|
||||
acts_as_event :title => Proc.new {|o| "#{l(:label_document)}: #{o.title}"},
|
||||
:author => Proc.new {|o| o.attachments.reorder("#{Attachment.table_name}.created_on ASC").first.try(:author) },
|
||||
:url => Proc.new {|o| {:controller => 'documents', :action => 'show', :id => o.id}}
|
||||
acts_as_activity_provider :scope => preload(:project)
|
||||
|
||||
validates_presence_of :project, :title, :category
|
||||
validates_length_of :title, :maximum => 255
|
||||
attr_protected :id
|
||||
|
||||
after_create :send_notification
|
||||
|
||||
scope :visible, lambda {|*args|
|
||||
joins(:project).
|
||||
where(Project.allowed_to_condition(args.shift || User.current, :view_documents, *args))
|
||||
}
|
||||
|
||||
safe_attributes 'category_id', 'title', 'description', 'custom_fields', 'custom_field_values'
|
||||
|
||||
def visible?(user=User.current)
|
||||
!user.nil? && user.allowed_to?(:view_documents, project)
|
||||
end
|
||||
|
||||
def initialize(attributes=nil, *args)
|
||||
super
|
||||
if new_record?
|
||||
self.category ||= DocumentCategory.default
|
||||
end
|
||||
end
|
||||
|
||||
def updated_on
|
||||
unless @updated_on
|
||||
a = attachments.last
|
||||
@updated_on = (a && a.created_on) || created_on
|
||||
end
|
||||
@updated_on
|
||||
end
|
||||
|
||||
def notified_users
|
||||
project.notified_users.reject {|user| !visible?(user)}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def send_notification
|
||||
if Setting.notified_events.include?('document_added')
|
||||
Mailer.document_added(self).deliver
|
||||
end
|
||||
end
|
||||
end
|
40
app/models/document_category.rb
Normal file
40
app/models/document_category.rb
Normal file
|
@ -0,0 +1,40 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
class DocumentCategory < Enumeration
|
||||
has_many :documents, :foreign_key => 'category_id'
|
||||
|
||||
OptionName = :enumeration_doc_categories
|
||||
|
||||
def option_name
|
||||
OptionName
|
||||
end
|
||||
|
||||
def objects_count
|
||||
documents.count
|
||||
end
|
||||
|
||||
def transfer_relations(to)
|
||||
documents.update_all(:category_id => to.id)
|
||||
end
|
||||
|
||||
def self.default
|
||||
d = super
|
||||
d = first if d.nil?
|
||||
d
|
||||
end
|
||||
end
|
22
app/models/document_category_custom_field.rb
Normal file
22
app/models/document_category_custom_field.rb
Normal file
|
@ -0,0 +1,22 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
class DocumentCategoryCustomField < CustomField
|
||||
def type_name
|
||||
:enumeration_doc_categories
|
||||
end
|
||||
end
|
22
app/models/document_custom_field.rb
Normal file
22
app/models/document_custom_field.rb
Normal file
|
@ -0,0 +1,22 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
class DocumentCustomField < CustomField
|
||||
def type_name
|
||||
:label_document_plural
|
||||
end
|
||||
end
|
111
app/models/email_address.rb
Normal file
111
app/models/email_address.rb
Normal file
|
@ -0,0 +1,111 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
class EmailAddress < ActiveRecord::Base
|
||||
include Redmine::SafeAttributes
|
||||
|
||||
belongs_to :user
|
||||
attr_protected :id
|
||||
|
||||
after_create :deliver_security_notification_create
|
||||
after_update :destroy_tokens, :deliver_security_notification_update
|
||||
after_destroy :destroy_tokens, :deliver_security_notification_destroy
|
||||
|
||||
validates_presence_of :address
|
||||
validates_format_of :address, :with => /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\z/i, :allow_blank => true
|
||||
validates_length_of :address, :maximum => User::MAIL_LENGTH_LIMIT, :allow_nil => true
|
||||
validates_uniqueness_of :address, :case_sensitive => false,
|
||||
:if => Proc.new {|email| email.address_changed? && email.address.present?}
|
||||
|
||||
safe_attributes 'address'
|
||||
|
||||
def address=(arg)
|
||||
write_attribute(:address, arg.to_s.strip)
|
||||
end
|
||||
|
||||
def destroy
|
||||
if is_default?
|
||||
false
|
||||
else
|
||||
super
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# send a security notification to user that a new email address was added
|
||||
def deliver_security_notification_create
|
||||
# only deliver if this isn't the only address.
|
||||
# in that case, the user is just being created and
|
||||
# should not receive this email.
|
||||
if user.mails != [address]
|
||||
deliver_security_notification(user,
|
||||
message: :mail_body_security_notification_add,
|
||||
field: :field_mail,
|
||||
value: address
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
# send a security notification to user that an email has been changed (notified/not notified)
|
||||
def deliver_security_notification_update
|
||||
if address_changed?
|
||||
recipients = [user, address_was]
|
||||
options = {
|
||||
message: :mail_body_security_notification_change_to,
|
||||
field: :field_mail,
|
||||
value: address
|
||||
}
|
||||
elsif notify_changed?
|
||||
recipients = [user, address]
|
||||
options = {
|
||||
message: notify_was ? :mail_body_security_notification_notify_disabled : :mail_body_security_notification_notify_enabled,
|
||||
value: address
|
||||
}
|
||||
end
|
||||
deliver_security_notification(recipients, options)
|
||||
end
|
||||
|
||||
# send a security notification to user that an email address was deleted
|
||||
def deliver_security_notification_destroy
|
||||
deliver_security_notification([user, address],
|
||||
message: :mail_body_security_notification_remove,
|
||||
field: :field_mail,
|
||||
value: address
|
||||
)
|
||||
end
|
||||
|
||||
# generic method to send security notifications for email addresses
|
||||
def deliver_security_notification(recipients, options={})
|
||||
Mailer.security_notification(recipients,
|
||||
options.merge(
|
||||
title: :label_my_account,
|
||||
url: {controller: 'my', action: 'account'}
|
||||
)
|
||||
).deliver
|
||||
end
|
||||
|
||||
# Delete all outstanding password reset tokens on email change.
|
||||
# This helps to keep the account secure in case the associated email account
|
||||
# was compromised.
|
||||
def destroy_tokens
|
||||
if address_changed? || destroyed?
|
||||
tokens = ['recovery']
|
||||
Token.where(:user_id => user_id, :action => tokens).delete_all
|
||||
end
|
||||
end
|
||||
end
|
40
app/models/enabled_module.rb
Normal file
40
app/models/enabled_module.rb
Normal file
|
@ -0,0 +1,40 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
class EnabledModule < ActiveRecord::Base
|
||||
belongs_to :project
|
||||
acts_as_watchable
|
||||
|
||||
validates_presence_of :name
|
||||
validates_uniqueness_of :name, :scope => :project_id
|
||||
attr_protected :id
|
||||
|
||||
after_create :module_enabled
|
||||
|
||||
private
|
||||
|
||||
# after_create callback used to do things when a module is enabled
|
||||
def module_enabled
|
||||
case name
|
||||
when 'wiki'
|
||||
# Create a wiki with a default start page
|
||||
if project && project.wiki.nil?
|
||||
Wiki.create(:project => project, :start_page => 'Wiki')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
173
app/models/enumeration.rb
Normal file
173
app/models/enumeration.rb
Normal file
|
@ -0,0 +1,173 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
class Enumeration < ActiveRecord::Base
|
||||
include Redmine::SubclassFactory
|
||||
|
||||
default_scope lambda {order(:position)}
|
||||
|
||||
belongs_to :project
|
||||
|
||||
acts_as_positioned :scope => :parent_id
|
||||
acts_as_customizable
|
||||
acts_as_tree
|
||||
|
||||
before_destroy :check_integrity
|
||||
before_save :check_default
|
||||
|
||||
attr_protected :type
|
||||
|
||||
validates_presence_of :name
|
||||
validates_uniqueness_of :name, :scope => [:type, :project_id]
|
||||
validates_length_of :name, :maximum => 30
|
||||
|
||||
scope :shared, lambda { where(:project_id => nil) }
|
||||
scope :sorted, lambda { order(:position) }
|
||||
scope :active, lambda { where(:active => true) }
|
||||
scope :system, lambda { where(:project_id => nil) }
|
||||
scope :named, lambda {|arg| where("LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip)}
|
||||
|
||||
def self.default
|
||||
# Creates a fake default scope so Enumeration.default will check
|
||||
# it's type. STI subclasses will automatically add their own
|
||||
# types to the finder.
|
||||
if self.descends_from_active_record?
|
||||
where(:is_default => true, :type => 'Enumeration').first
|
||||
else
|
||||
# STI classes are
|
||||
where(:is_default => true).first
|
||||
end
|
||||
end
|
||||
|
||||
# Overloaded on concrete classes
|
||||
def option_name
|
||||
nil
|
||||
end
|
||||
|
||||
def check_default
|
||||
if is_default? && is_default_changed?
|
||||
Enumeration.where({:type => type}).update_all({:is_default => false})
|
||||
end
|
||||
end
|
||||
|
||||
# Overloaded on concrete classes
|
||||
def objects_count
|
||||
0
|
||||
end
|
||||
|
||||
def in_use?
|
||||
self.objects_count != 0
|
||||
end
|
||||
|
||||
# Is this enumeration overriding a system level enumeration?
|
||||
def is_override?
|
||||
!self.parent.nil?
|
||||
end
|
||||
|
||||
alias :destroy_without_reassign :destroy
|
||||
|
||||
# Destroy the enumeration
|
||||
# If a enumeration is specified, objects are reassigned
|
||||
def destroy(reassign_to = nil)
|
||||
if reassign_to && reassign_to.is_a?(Enumeration)
|
||||
self.transfer_relations(reassign_to)
|
||||
end
|
||||
destroy_without_reassign
|
||||
end
|
||||
|
||||
def <=>(enumeration)
|
||||
position <=> enumeration.position
|
||||
end
|
||||
|
||||
def to_s; name end
|
||||
|
||||
# Returns the Subclasses of Enumeration. Each Subclass needs to be
|
||||
# required in development mode.
|
||||
#
|
||||
# Note: subclasses is protected in ActiveRecord
|
||||
def self.get_subclasses
|
||||
subclasses
|
||||
end
|
||||
|
||||
# Does the +new+ Hash override the previous Enumeration?
|
||||
def self.overriding_change?(new, previous)
|
||||
if (same_active_state?(new['active'], previous.active)) && same_custom_values?(new,previous)
|
||||
return false
|
||||
else
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
# Does the +new+ Hash have the same custom values as the previous Enumeration?
|
||||
def self.same_custom_values?(new, previous)
|
||||
previous.custom_field_values.each do |custom_value|
|
||||
if custom_value.value != new["custom_field_values"][custom_value.custom_field_id.to_s]
|
||||
return false
|
||||
end
|
||||
end
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
# Are the new and previous fields equal?
|
||||
def self.same_active_state?(new, previous)
|
||||
new = (new == "1" ? true : false)
|
||||
return new == previous
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def check_integrity
|
||||
raise "Cannot delete enumeration" if self.in_use?
|
||||
end
|
||||
|
||||
# Overrides Redmine::Acts::Positioned#set_default_position so that enumeration overrides
|
||||
# get the same position as the overridden enumeration
|
||||
def set_default_position
|
||||
if position.nil? && parent
|
||||
self.position = parent.position
|
||||
end
|
||||
super
|
||||
end
|
||||
|
||||
# Overrides Redmine::Acts::Positioned#update_position so that overrides get the same
|
||||
# position as the overridden enumeration
|
||||
def update_position
|
||||
super
|
||||
if position_changed?
|
||||
self.class.where.not(:parent_id => nil).update_all(
|
||||
"position = coalesce((
|
||||
select position
|
||||
from (select id, position from enumerations) as parent
|
||||
where parent_id = parent.id), 1)"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
# Overrides Redmine::Acts::Positioned#remove_position so that enumeration overrides
|
||||
# get the same position as the overridden enumeration
|
||||
def remove_position
|
||||
if parent_id.blank?
|
||||
super
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Force load the subclasses in development mode
|
||||
require_dependency 'time_entry_activity'
|
||||
require_dependency 'document_category'
|
||||
require_dependency 'issue_priority'
|
119
app/models/group.rb
Normal file
119
app/models/group.rb
Normal file
|
@ -0,0 +1,119 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
class Group < Principal
|
||||
include Redmine::SafeAttributes
|
||||
|
||||
has_and_belongs_to_many :users,
|
||||
:join_table => "#{table_name_prefix}groups_users#{table_name_suffix}",
|
||||
:after_add => :user_added,
|
||||
:after_remove => :user_removed
|
||||
|
||||
acts_as_customizable
|
||||
|
||||
validates_presence_of :lastname
|
||||
validates_uniqueness_of :lastname, :case_sensitive => false
|
||||
validates_length_of :lastname, :maximum => 255
|
||||
attr_protected :id
|
||||
|
||||
self.valid_statuses = [STATUS_ACTIVE]
|
||||
|
||||
before_destroy :remove_references_before_destroy
|
||||
|
||||
scope :sorted, lambda { order(:type => :asc, :lastname => :asc) }
|
||||
scope :named, lambda {|arg| where("LOWER(#{table_name}.lastname) = LOWER(?)", arg.to_s.strip)}
|
||||
scope :givable, lambda {where(:type => 'Group')}
|
||||
|
||||
safe_attributes 'name',
|
||||
'user_ids',
|
||||
'custom_field_values',
|
||||
'custom_fields',
|
||||
:if => lambda {|group, user| user.admin? && !group.builtin?}
|
||||
|
||||
def to_s
|
||||
name.to_s
|
||||
end
|
||||
|
||||
def name
|
||||
lastname
|
||||
end
|
||||
|
||||
def name=(arg)
|
||||
self.lastname = arg
|
||||
end
|
||||
|
||||
def builtin_type
|
||||
nil
|
||||
end
|
||||
|
||||
# Return true if the group is a builtin group
|
||||
def builtin?
|
||||
false
|
||||
end
|
||||
|
||||
# Returns true if the group can be given to a user
|
||||
def givable?
|
||||
!builtin?
|
||||
end
|
||||
|
||||
def user_added(user)
|
||||
members.each do |member|
|
||||
next if member.project.nil?
|
||||
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)
|
||||
member.member_roles.each do |member_role|
|
||||
user_member.member_roles << MemberRole.new(:role => member_role.role, :inherited_from => member_role.id)
|
||||
end
|
||||
user_member.save!
|
||||
end
|
||||
end
|
||||
|
||||
def user_removed(user)
|
||||
members.each do |member|
|
||||
MemberRole.
|
||||
joins(:member).
|
||||
where("#{Member.table_name}.user_id = ? AND #{MemberRole.table_name}.inherited_from IN (?)", user.id, member.member_role_ids).
|
||||
each(&:destroy)
|
||||
end
|
||||
end
|
||||
|
||||
def self.human_attribute_name(attribute_key_name, *args)
|
||||
attr_name = attribute_key_name.to_s
|
||||
if attr_name == 'lastname'
|
||||
attr_name = "name"
|
||||
end
|
||||
super(attr_name, *args)
|
||||
end
|
||||
|
||||
def self.anonymous
|
||||
GroupAnonymous.load_instance
|
||||
end
|
||||
|
||||
def self.non_member
|
||||
GroupNonMember.load_instance
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Removes references that are not handled by associations
|
||||
def remove_references_before_destroy
|
||||
return if self.id.nil?
|
||||
|
||||
Issue.where(['assigned_to_id = ?', id]).update_all('assigned_to_id = NULL')
|
||||
end
|
||||
end
|
||||
|
||||
require_dependency "group_builtin"
|
26
app/models/group_anonymous.rb
Normal file
26
app/models/group_anonymous.rb
Normal file
|
@ -0,0 +1,26 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
class GroupAnonymous < GroupBuiltin
|
||||
def name
|
||||
l(:label_group_anonymous)
|
||||
end
|
||||
|
||||
def builtin_type
|
||||
"anonymous"
|
||||
end
|
||||
end
|
56
app/models/group_builtin.rb
Normal file
56
app/models/group_builtin.rb
Normal file
|
@ -0,0 +1,56 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
class GroupBuiltin < Group
|
||||
validate :validate_uniqueness, :on => :create
|
||||
|
||||
def validate_uniqueness
|
||||
errors.add :base, 'The builtin group already exists.' if self.class.exists?
|
||||
end
|
||||
|
||||
def builtin?
|
||||
true
|
||||
end
|
||||
|
||||
def destroy
|
||||
false
|
||||
end
|
||||
|
||||
def user_added(user)
|
||||
raise 'Cannot add users to a builtin group'
|
||||
end
|
||||
|
||||
class << self
|
||||
def load_instance
|
||||
return nil if self == GroupBuiltin
|
||||
instance = unscoped.order('id').first || create_instance
|
||||
end
|
||||
|
||||
def create_instance
|
||||
raise 'The builtin group already exists.' if exists?
|
||||
instance = unscoped.new
|
||||
instance.lastname = name
|
||||
instance.save :validate => false
|
||||
raise 'Unable to create builtin group.' if instance.new_record?
|
||||
instance
|
||||
end
|
||||
private :create_instance
|
||||
end
|
||||
end
|
||||
|
||||
require_dependency "group_anonymous"
|
||||
require_dependency "group_non_member"
|
22
app/models/group_custom_field.rb
Normal file
22
app/models/group_custom_field.rb
Normal file
|
@ -0,0 +1,22 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
class GroupCustomField < CustomField
|
||||
def type_name
|
||||
:label_group_plural
|
||||
end
|
||||
end
|
26
app/models/group_non_member.rb
Normal file
26
app/models/group_non_member.rb
Normal file
|
@ -0,0 +1,26 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
class GroupNonMember < GroupBuiltin
|
||||
def name
|
||||
l(:label_group_non_member)
|
||||
end
|
||||
|
||||
def builtin_type
|
||||
"non_member"
|
||||
end
|
||||
end
|
269
app/models/import.rb
Normal file
269
app/models/import.rb
Normal file
|
@ -0,0 +1,269 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
require 'csv'
|
||||
|
||||
class Import < ActiveRecord::Base
|
||||
has_many :items, :class_name => 'ImportItem', :dependent => :delete_all
|
||||
belongs_to :user
|
||||
serialize :settings
|
||||
|
||||
before_destroy :remove_file
|
||||
|
||||
validates_presence_of :filename, :user_id
|
||||
validates_length_of :filename, :maximum => 255
|
||||
|
||||
DATE_FORMATS = [
|
||||
'%Y-%m-%d',
|
||||
'%d/%m/%Y',
|
||||
'%m/%d/%Y',
|
||||
'%d.%m.%Y',
|
||||
'%d-%m-%Y'
|
||||
]
|
||||
|
||||
def initialize(*args)
|
||||
super
|
||||
self.settings ||= {}
|
||||
end
|
||||
|
||||
def file=(arg)
|
||||
return unless arg.present? && arg.size > 0
|
||||
|
||||
self.filename = generate_filename
|
||||
Redmine::Utils.save_upload(arg, filepath)
|
||||
end
|
||||
|
||||
def set_default_settings
|
||||
separator = lu(user, :general_csv_separator)
|
||||
if file_exists?
|
||||
begin
|
||||
content = File.read(filepath, 256)
|
||||
separator = [',', ';'].sort_by {|sep| content.count(sep) }.last
|
||||
rescue Exception => e
|
||||
end
|
||||
end
|
||||
wrapper = '"'
|
||||
encoding = lu(user, :general_csv_encoding)
|
||||
|
||||
date_format = lu(user, "date.formats.default", :default => "foo")
|
||||
date_format = DATE_FORMATS.first unless DATE_FORMATS.include?(date_format)
|
||||
|
||||
self.settings.merge!(
|
||||
'separator' => separator,
|
||||
'wrapper' => wrapper,
|
||||
'encoding' => encoding,
|
||||
'date_format' => date_format
|
||||
)
|
||||
end
|
||||
|
||||
def to_param
|
||||
filename
|
||||
end
|
||||
|
||||
# Returns the full path of the file to import
|
||||
# It is stored in tmp/imports with a random hex as filename
|
||||
def filepath
|
||||
if filename.present? && filename =~ /\A[0-9a-f]+\z/
|
||||
File.join(Rails.root, "tmp", "imports", filename)
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
# Returns true if the file to import exists
|
||||
def file_exists?
|
||||
filepath.present? && File.exists?(filepath)
|
||||
end
|
||||
|
||||
# Returns the headers as an array that
|
||||
# can be used for select options
|
||||
def columns_options(default=nil)
|
||||
i = -1
|
||||
headers.map {|h| [h, i+=1]}
|
||||
end
|
||||
|
||||
# Parses the file to import and updates the total number of items
|
||||
def parse_file
|
||||
count = 0
|
||||
read_items {|row, i| count=i}
|
||||
update_attribute :total_items, count
|
||||
count
|
||||
end
|
||||
|
||||
# Reads the items to import and yields the given block for each item
|
||||
def read_items
|
||||
i = 0
|
||||
headers = true
|
||||
read_rows do |row|
|
||||
if i == 0 && headers
|
||||
headers = false
|
||||
next
|
||||
end
|
||||
i+= 1
|
||||
yield row, i if block_given?
|
||||
end
|
||||
end
|
||||
|
||||
# Returns the count first rows of the file (including headers)
|
||||
def first_rows(count=4)
|
||||
rows = []
|
||||
read_rows do |row|
|
||||
rows << row
|
||||
break if rows.size >= count
|
||||
end
|
||||
rows
|
||||
end
|
||||
|
||||
# Returns an array of headers
|
||||
def headers
|
||||
first_rows(1).first || []
|
||||
end
|
||||
|
||||
# Returns the mapping options
|
||||
def mapping
|
||||
settings['mapping'] || {}
|
||||
end
|
||||
|
||||
# Adds a callback that will be called after the item at given position is imported
|
||||
def add_callback(position, name, *args)
|
||||
settings['callbacks'] ||= {}
|
||||
settings['callbacks'][position.to_i] ||= []
|
||||
settings['callbacks'][position.to_i] << [name, args]
|
||||
save!
|
||||
end
|
||||
|
||||
# Executes the callbacks for the given object
|
||||
def do_callbacks(position, object)
|
||||
if callbacks = (settings['callbacks'] || {}).delete(position)
|
||||
callbacks.each do |name, args|
|
||||
send "#{name}_callback", object, *args
|
||||
end
|
||||
save!
|
||||
end
|
||||
end
|
||||
|
||||
# Imports items and returns the position of the last processed item
|
||||
def run(options={})
|
||||
max_items = options[:max_items]
|
||||
max_time = options[:max_time]
|
||||
current = 0
|
||||
imported = 0
|
||||
resume_after = items.maximum(:position) || 0
|
||||
interrupted = false
|
||||
started_on = Time.now
|
||||
|
||||
read_items do |row, position|
|
||||
if (max_items && imported >= max_items) || (max_time && Time.now >= started_on + max_time)
|
||||
interrupted = true
|
||||
break
|
||||
end
|
||||
if position > resume_after
|
||||
item = items.build
|
||||
item.position = position
|
||||
|
||||
if object = build_object(row, item)
|
||||
if object.save
|
||||
item.obj_id = object.id
|
||||
else
|
||||
item.message = object.errors.full_messages.join("\n")
|
||||
end
|
||||
end
|
||||
|
||||
item.save!
|
||||
imported += 1
|
||||
|
||||
do_callbacks(item.position, object)
|
||||
end
|
||||
current = position
|
||||
end
|
||||
|
||||
if imported == 0 || interrupted == false
|
||||
if total_items.nil?
|
||||
update_attribute :total_items, current
|
||||
end
|
||||
update_attribute :finished, true
|
||||
remove_file
|
||||
end
|
||||
|
||||
current
|
||||
end
|
||||
|
||||
def unsaved_items
|
||||
items.where(:obj_id => nil)
|
||||
end
|
||||
|
||||
def saved_items
|
||||
items.where("obj_id IS NOT NULL")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def read_rows
|
||||
return unless file_exists?
|
||||
|
||||
csv_options = {:headers => false}
|
||||
csv_options[:encoding] = settings['encoding'].to_s.presence || 'UTF-8'
|
||||
separator = settings['separator'].to_s
|
||||
csv_options[:col_sep] = separator if separator.size == 1
|
||||
wrapper = settings['wrapper'].to_s
|
||||
csv_options[:quote_char] = wrapper if wrapper.size == 1
|
||||
|
||||
CSV.foreach(filepath, csv_options) do |row|
|
||||
yield row if block_given?
|
||||
end
|
||||
end
|
||||
|
||||
def row_value(row, key)
|
||||
if index = mapping[key].presence
|
||||
row[index.to_i].presence
|
||||
end
|
||||
end
|
||||
|
||||
def row_date(row, key)
|
||||
if s = row_value(row, key)
|
||||
format = settings['date_format']
|
||||
format = DATE_FORMATS.first unless DATE_FORMATS.include?(format)
|
||||
Date.strptime(s, format) rescue s
|
||||
end
|
||||
end
|
||||
|
||||
# Builds a record for the given row and returns it
|
||||
# To be implemented by subclasses
|
||||
def build_object(row)
|
||||
end
|
||||
|
||||
# Generates a filename used to store the import file
|
||||
def generate_filename
|
||||
Redmine::Utils.random_hex(16)
|
||||
end
|
||||
|
||||
# Deletes the import file
|
||||
def remove_file
|
||||
if file_exists?
|
||||
begin
|
||||
File.delete filepath
|
||||
rescue Exception => e
|
||||
logger.error "Unable to delete file #{filepath}: #{e.message}" if logger
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Returns true if value is a string that represents a true value
|
||||
def yes?(value)
|
||||
value == lu(user, :general_text_yes) || value == '1'
|
||||
end
|
||||
end
|
22
app/models/import_item.rb
Normal file
22
app/models/import_item.rb
Normal file
|
@ -0,0 +1,22 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
class ImportItem < ActiveRecord::Base
|
||||
belongs_to :import
|
||||
|
||||
validates_presence_of :import_id, :position
|
||||
end
|
1869
app/models/issue.rb
Normal file
1869
app/models/issue.rb
Normal file
File diff suppressed because it is too large
Load diff
49
app/models/issue_category.rb
Normal file
49
app/models/issue_category.rb
Normal file
|
@ -0,0 +1,49 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
class IssueCategory < ActiveRecord::Base
|
||||
include Redmine::SafeAttributes
|
||||
belongs_to :project
|
||||
belongs_to :assigned_to, :class_name => 'Principal'
|
||||
has_many :issues, :foreign_key => 'category_id', :dependent => :nullify
|
||||
|
||||
validates_presence_of :name
|
||||
validates_uniqueness_of :name, :scope => [:project_id]
|
||||
validates_length_of :name, :maximum => 60
|
||||
attr_protected :id
|
||||
|
||||
safe_attributes 'name', 'assigned_to_id'
|
||||
|
||||
scope :named, lambda {|arg| where("LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip)}
|
||||
|
||||
alias :destroy_without_reassign :destroy
|
||||
|
||||
# Destroy the category
|
||||
# If a category is specified, issues are reassigned to this category
|
||||
def destroy(reassign_to = nil)
|
||||
if reassign_to && reassign_to.is_a?(IssueCategory) && reassign_to.project == self.project
|
||||
Issue.where({:category_id => id}).update_all({:category_id => reassign_to.id})
|
||||
end
|
||||
destroy_without_reassign
|
||||
end
|
||||
|
||||
def <=>(category)
|
||||
name <=> category.name
|
||||
end
|
||||
|
||||
def to_s; name end
|
||||
end
|
48
app/models/issue_custom_field.rb
Normal file
48
app/models/issue_custom_field.rb
Normal file
|
@ -0,0 +1,48 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
class IssueCustomField < CustomField
|
||||
has_and_belongs_to_many :projects, :join_table => "#{table_name_prefix}custom_fields_projects#{table_name_suffix}", :foreign_key => "custom_field_id"
|
||||
has_and_belongs_to_many :trackers, :join_table => "#{table_name_prefix}custom_fields_trackers#{table_name_suffix}", :foreign_key => "custom_field_id"
|
||||
has_many :issues, :through => :issue_custom_values
|
||||
|
||||
safe_attributes 'project_ids',
|
||||
'tracker_ids'
|
||||
|
||||
def type_name
|
||||
:label_issue_plural
|
||||
end
|
||||
|
||||
def visible_by?(project, user=User.current)
|
||||
super || (roles & user.roles_for_project(project)).present?
|
||||
end
|
||||
|
||||
def visibility_by_project_condition(project_key=nil, user=User.current, id_column=nil)
|
||||
sql = super
|
||||
id_column ||= id
|
||||
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})"
|
||||
project_condition = "EXISTS (SELECT 1 FROM #{CustomField.table_name} ifa WHERE ifa.is_for_all = #{self.class.connection.quoted_true} AND ifa.id = #{id_column})" +
|
||||
" 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})"
|
||||
|
||||
"((#{sql}) AND (#{tracker_condition}) AND (#{project_condition}))"
|
||||
end
|
||||
|
||||
def validate_custom_field
|
||||
super
|
||||
errors.add(:base, l(:label_role_plural) + ' ' + l('activerecord.errors.messages.blank')) unless visible? || roles.present?
|
||||
end
|
||||
end
|
203
app/models/issue_import.rb
Normal file
203
app/models/issue_import.rb
Normal file
|
@ -0,0 +1,203 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
class IssueImport < Import
|
||||
|
||||
# Returns the objects that were imported
|
||||
def saved_objects
|
||||
object_ids = saved_items.pluck(:obj_id)
|
||||
objects = Issue.where(:id => object_ids).order(:id).preload(:tracker, :priority, :status)
|
||||
end
|
||||
|
||||
# Returns a scope of projects that user is allowed to
|
||||
# import issue to
|
||||
def allowed_target_projects
|
||||
Project.allowed_to(user, :import_issues)
|
||||
end
|
||||
|
||||
def project
|
||||
project_id = mapping['project_id'].to_i
|
||||
allowed_target_projects.find_by_id(project_id) || allowed_target_projects.first
|
||||
end
|
||||
|
||||
# Returns a scope of trackers that user is allowed to
|
||||
# import issue to
|
||||
def allowed_target_trackers
|
||||
Issue.allowed_target_trackers(project, user)
|
||||
end
|
||||
|
||||
def tracker
|
||||
if mapping['tracker'].to_s =~ /\Avalue:(\d+)\z/
|
||||
tracker_id = $1.to_i
|
||||
allowed_target_trackers.find_by_id(tracker_id)
|
||||
end
|
||||
end
|
||||
|
||||
# Returns true if missing categories should be created during the import
|
||||
def create_categories?
|
||||
user.allowed_to?(:manage_categories, project) &&
|
||||
mapping['create_categories'] == '1'
|
||||
end
|
||||
|
||||
# Returns true if missing versions should be created during the import
|
||||
def create_versions?
|
||||
user.allowed_to?(:manage_versions, project) &&
|
||||
mapping['create_versions'] == '1'
|
||||
end
|
||||
|
||||
def mappable_custom_fields
|
||||
if tracker
|
||||
issue = Issue.new
|
||||
issue.project = project
|
||||
issue.tracker = tracker
|
||||
issue.editable_custom_field_values(user).map(&:custom_field)
|
||||
elsif project
|
||||
project.all_issue_custom_fields
|
||||
else
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_object(row, item)
|
||||
issue = Issue.new
|
||||
issue.author = user
|
||||
issue.notify = false
|
||||
|
||||
tracker_id = nil
|
||||
if tracker
|
||||
tracker_id = tracker.id
|
||||
elsif tracker_name = row_value(row, 'tracker')
|
||||
tracker_id = allowed_target_trackers.named(tracker_name).first.try(:id)
|
||||
end
|
||||
|
||||
attributes = {
|
||||
'project_id' => mapping['project_id'],
|
||||
'tracker_id' => tracker_id,
|
||||
'subject' => row_value(row, 'subject'),
|
||||
'description' => row_value(row, 'description')
|
||||
}
|
||||
if status_name = row_value(row, 'status')
|
||||
if status_id = IssueStatus.named(status_name).first.try(:id)
|
||||
attributes['status_id'] = status_id
|
||||
end
|
||||
end
|
||||
issue.send :safe_attributes=, attributes, user
|
||||
|
||||
attributes = {}
|
||||
if priority_name = row_value(row, 'priority')
|
||||
if priority_id = IssuePriority.active.named(priority_name).first.try(:id)
|
||||
attributes['priority_id'] = priority_id
|
||||
end
|
||||
end
|
||||
if issue.project && category_name = row_value(row, 'category')
|
||||
if category = issue.project.issue_categories.named(category_name).first
|
||||
attributes['category_id'] = category.id
|
||||
elsif create_categories?
|
||||
category = issue.project.issue_categories.build
|
||||
category.name = category_name
|
||||
if category.save
|
||||
attributes['category_id'] = category.id
|
||||
end
|
||||
end
|
||||
end
|
||||
if assignee_name = row_value(row, 'assigned_to')
|
||||
if assignee = Principal.detect_by_keyword(issue.assignable_users, assignee_name)
|
||||
attributes['assigned_to_id'] = assignee.id
|
||||
end
|
||||
end
|
||||
if issue.project && version_name = row_value(row, 'fixed_version')
|
||||
if version = issue.project.versions.named(version_name).first
|
||||
attributes['fixed_version_id'] = version.id
|
||||
elsif create_versions?
|
||||
version = issue.project.versions.build
|
||||
version.name = version_name
|
||||
if version.save
|
||||
attributes['fixed_version_id'] = version.id
|
||||
end
|
||||
end
|
||||
end
|
||||
if is_private = row_value(row, 'is_private')
|
||||
if yes?(is_private)
|
||||
attributes['is_private'] = '1'
|
||||
end
|
||||
end
|
||||
if parent_issue_id = row_value(row, 'parent_issue_id')
|
||||
if parent_issue_id =~ /\A(#)?(\d+)\z/
|
||||
parent_issue_id = $2.to_i
|
||||
if $1
|
||||
attributes['parent_issue_id'] = parent_issue_id
|
||||
else
|
||||
if parent_issue_id > item.position
|
||||
add_callback(parent_issue_id, 'set_as_parent', item.position)
|
||||
elsif issue_id = items.where(:position => parent_issue_id).first.try(:obj_id)
|
||||
attributes['parent_issue_id'] = issue_id
|
||||
end
|
||||
end
|
||||
else
|
||||
attributes['parent_issue_id'] = parent_issue_id
|
||||
end
|
||||
end
|
||||
if start_date = row_date(row, 'start_date')
|
||||
attributes['start_date'] = start_date
|
||||
end
|
||||
if due_date = row_date(row, 'due_date')
|
||||
attributes['due_date'] = due_date
|
||||
end
|
||||
if estimated_hours = row_value(row, 'estimated_hours')
|
||||
attributes['estimated_hours'] = estimated_hours
|
||||
end
|
||||
if done_ratio = row_value(row, 'done_ratio')
|
||||
attributes['done_ratio'] = done_ratio
|
||||
end
|
||||
|
||||
attributes['custom_field_values'] = issue.custom_field_values.inject({}) do |h, v|
|
||||
value = case v.custom_field.field_format
|
||||
when 'date'
|
||||
row_date(row, "cf_#{v.custom_field.id}")
|
||||
else
|
||||
row_value(row, "cf_#{v.custom_field.id}")
|
||||
end
|
||||
if value
|
||||
h[v.custom_field.id.to_s] = v.custom_field.value_from_keyword(value, issue)
|
||||
end
|
||||
h
|
||||
end
|
||||
|
||||
issue.send :safe_attributes=, attributes, user
|
||||
|
||||
if issue.tracker_id != tracker_id
|
||||
issue.tracker_id = nil
|
||||
end
|
||||
|
||||
issue
|
||||
end
|
||||
|
||||
# Callback that sets issue as the parent of a previously imported issue
|
||||
def set_as_parent_callback(issue, child_position)
|
||||
child_id = items.where(:position => child_position).first.try(:obj_id)
|
||||
return unless child_id
|
||||
|
||||
child = Issue.find_by_id(child_id)
|
||||
return unless child
|
||||
|
||||
child.parent_issue_id = issue.id
|
||||
child.save!
|
||||
issue.reload
|
||||
end
|
||||
end
|
68
app/models/issue_priority.rb
Normal file
68
app/models/issue_priority.rb
Normal file
|
@ -0,0 +1,68 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
class IssuePriority < Enumeration
|
||||
has_many :issues, :foreign_key => 'priority_id'
|
||||
|
||||
after_destroy {|priority| priority.class.compute_position_names}
|
||||
after_save {|priority| priority.class.compute_position_names if (priority.position_changed? && priority.position) || priority.active_changed?}
|
||||
|
||||
OptionName = :enumeration_issue_priorities
|
||||
|
||||
def option_name
|
||||
OptionName
|
||||
end
|
||||
|
||||
def objects_count
|
||||
issues.count
|
||||
end
|
||||
|
||||
def transfer_relations(to)
|
||||
issues.update_all(:priority_id => to.id)
|
||||
end
|
||||
|
||||
def css_classes
|
||||
"priority-#{id} priority-#{position_name}"
|
||||
end
|
||||
|
||||
# Clears position_name for all priorities
|
||||
# Called from migration 20121026003537_populate_enumerations_position_name
|
||||
def self.clear_position_names
|
||||
update_all :position_name => nil
|
||||
end
|
||||
|
||||
# Updates position_name for active priorities
|
||||
# Called from migration 20121026003537_populate_enumerations_position_name
|
||||
def self.compute_position_names
|
||||
priorities = where(:active => true).sort_by(&:position)
|
||||
if priorities.any?
|
||||
default = priorities.detect(&:is_default?) || priorities[(priorities.size - 1) / 2]
|
||||
priorities.each_with_index do |priority, index|
|
||||
name = case
|
||||
when priority.position == default.position
|
||||
"default"
|
||||
when priority.position < default.position
|
||||
index == 0 ? "lowest" : "low#{index+1}"
|
||||
else
|
||||
index == (priorities.size - 1) ? "highest" : "high#{priorities.size - index}"
|
||||
end
|
||||
|
||||
where(:id => priority.id).update_all({:position_name => name})
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
23
app/models/issue_priority_custom_field.rb
Normal file
23
app/models/issue_priority_custom_field.rb
Normal file
|
@ -0,0 +1,23 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
class IssuePriorityCustomField < CustomField
|
||||
def type_name
|
||||
:enumeration_issue_priorities
|
||||
end
|
||||
end
|
||||
|
602
app/models/issue_query.rb
Normal file
602
app/models/issue_query.rb
Normal file
|
@ -0,0 +1,602 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
class IssueQuery < Query
|
||||
|
||||
self.queried_class = Issue
|
||||
self.view_permission = :view_issues
|
||||
|
||||
self.available_columns = [
|
||||
QueryColumn.new(:id, :sortable => "#{Issue.table_name}.id", :default_order => 'desc', :caption => '#', :frozen => true),
|
||||
QueryColumn.new(:project, :sortable => "#{Project.table_name}.name", :groupable => true),
|
||||
QueryColumn.new(:tracker, :sortable => "#{Tracker.table_name}.position", :groupable => true),
|
||||
QueryColumn.new(:parent, :sortable => ["#{Issue.table_name}.root_id", "#{Issue.table_name}.lft ASC"], :default_order => 'desc', :caption => :field_parent_issue),
|
||||
QueryColumn.new(:status, :sortable => "#{IssueStatus.table_name}.position", :groupable => true),
|
||||
QueryColumn.new(:priority, :sortable => "#{IssuePriority.table_name}.position", :default_order => 'desc', :groupable => true),
|
||||
QueryColumn.new(:subject, :sortable => "#{Issue.table_name}.subject"),
|
||||
QueryColumn.new(:author, :sortable => lambda {User.fields_for_order_statement("authors")}, :groupable => true),
|
||||
QueryColumn.new(:assigned_to, :sortable => lambda {User.fields_for_order_statement}, :groupable => true),
|
||||
QueryColumn.new(:updated_on, :sortable => "#{Issue.table_name}.updated_on", :default_order => 'desc'),
|
||||
QueryColumn.new(:category, :sortable => "#{IssueCategory.table_name}.name", :groupable => true),
|
||||
QueryColumn.new(:fixed_version, :sortable => lambda {Version.fields_for_order_statement}, :groupable => true),
|
||||
QueryColumn.new(:start_date, :sortable => "#{Issue.table_name}.start_date"),
|
||||
QueryColumn.new(:due_date, :sortable => "#{Issue.table_name}.due_date"),
|
||||
QueryColumn.new(:estimated_hours, :sortable => "#{Issue.table_name}.estimated_hours", :totalable => true),
|
||||
QueryColumn.new(:total_estimated_hours,
|
||||
:sortable => "COALESCE((SELECT SUM(estimated_hours) FROM #{Issue.table_name} subtasks" +
|
||||
" WHERE subtasks.root_id = #{Issue.table_name}.root_id AND subtasks.lft >= #{Issue.table_name}.lft AND subtasks.rgt <= #{Issue.table_name}.rgt), 0)",
|
||||
:default_order => 'desc'),
|
||||
QueryColumn.new(:done_ratio, :sortable => "#{Issue.table_name}.done_ratio", :groupable => true),
|
||||
QueryColumn.new(:created_on, :sortable => "#{Issue.table_name}.created_on", :default_order => 'desc'),
|
||||
QueryColumn.new(:closed_on, :sortable => "#{Issue.table_name}.closed_on", :default_order => 'desc'),
|
||||
QueryColumn.new(:last_updated_by, :sortable => lambda {User.fields_for_order_statement("last_journal_user")}),
|
||||
QueryColumn.new(:relations, :caption => :label_related_issues),
|
||||
QueryColumn.new(:attachments, :caption => :label_attachment_plural),
|
||||
QueryColumn.new(:description, :inline => false),
|
||||
QueryColumn.new(:last_notes, :caption => :label_last_notes, :inline => false)
|
||||
]
|
||||
|
||||
def initialize(attributes=nil, *args)
|
||||
super attributes
|
||||
self.filters ||= { 'status_id' => {:operator => "o", :values => [""]} }
|
||||
end
|
||||
|
||||
def draw_relations
|
||||
r = options[:draw_relations]
|
||||
r.nil? || r == '1'
|
||||
end
|
||||
|
||||
def draw_relations=(arg)
|
||||
options[:draw_relations] = (arg == '0' ? '0' : nil)
|
||||
end
|
||||
|
||||
def draw_progress_line
|
||||
r = options[:draw_progress_line]
|
||||
r == '1'
|
||||
end
|
||||
|
||||
def draw_progress_line=(arg)
|
||||
options[:draw_progress_line] = (arg == '1' ? '1' : nil)
|
||||
end
|
||||
|
||||
def build_from_params(params)
|
||||
super
|
||||
self.draw_relations = params[:draw_relations] || (params[:query] && params[:query][:draw_relations])
|
||||
self.draw_progress_line = params[:draw_progress_line] || (params[:query] && params[:query][:draw_progress_line])
|
||||
self
|
||||
end
|
||||
|
||||
def initialize_available_filters
|
||||
add_available_filter "status_id",
|
||||
:type => :list_status, :values => lambda { issue_statuses_values }
|
||||
|
||||
add_available_filter("project_id",
|
||||
:type => :list, :values => lambda { project_values }
|
||||
) if project.nil?
|
||||
|
||||
add_available_filter "tracker_id",
|
||||
:type => :list, :values => trackers.collect{|s| [s.name, s.id.to_s] }
|
||||
|
||||
add_available_filter "priority_id",
|
||||
:type => :list, :values => IssuePriority.all.collect{|s| [s.name, s.id.to_s] }
|
||||
|
||||
add_available_filter("author_id",
|
||||
:type => :list, :values => lambda { author_values }
|
||||
)
|
||||
|
||||
add_available_filter("assigned_to_id",
|
||||
:type => :list_optional, :values => lambda { assigned_to_values }
|
||||
)
|
||||
|
||||
add_available_filter("member_of_group",
|
||||
:type => :list_optional, :values => lambda { Group.givable.visible.collect {|g| [g.name, g.id.to_s] } }
|
||||
)
|
||||
|
||||
add_available_filter("assigned_to_role",
|
||||
:type => :list_optional, :values => lambda { Role.givable.collect {|r| [r.name, r.id.to_s] } }
|
||||
)
|
||||
|
||||
add_available_filter "fixed_version_id",
|
||||
:type => :list_optional, :values => lambda { fixed_version_values }
|
||||
|
||||
add_available_filter "fixed_version.due_date",
|
||||
:type => :date,
|
||||
:name => l(:label_attribute_of_fixed_version, :name => l(:field_effective_date))
|
||||
|
||||
add_available_filter "fixed_version.status",
|
||||
:type => :list,
|
||||
:name => l(:label_attribute_of_fixed_version, :name => l(:field_status)),
|
||||
:values => Version::VERSION_STATUSES.map{|s| [l("version_status_#{s}"), s] }
|
||||
|
||||
add_available_filter "category_id",
|
||||
:type => :list_optional,
|
||||
:values => lambda { project.issue_categories.collect{|s| [s.name, s.id.to_s] } } if project
|
||||
|
||||
add_available_filter "subject", :type => :text
|
||||
add_available_filter "description", :type => :text
|
||||
add_available_filter "created_on", :type => :date_past
|
||||
add_available_filter "updated_on", :type => :date_past
|
||||
add_available_filter "closed_on", :type => :date_past
|
||||
add_available_filter "start_date", :type => :date
|
||||
add_available_filter "due_date", :type => :date
|
||||
add_available_filter "estimated_hours", :type => :float
|
||||
add_available_filter "done_ratio", :type => :integer
|
||||
|
||||
if User.current.allowed_to?(:set_issues_private, nil, :global => true) ||
|
||||
User.current.allowed_to?(:set_own_issues_private, nil, :global => true)
|
||||
add_available_filter "is_private",
|
||||
:type => :list,
|
||||
:values => [[l(:general_text_yes), "1"], [l(:general_text_no), "0"]]
|
||||
end
|
||||
|
||||
add_available_filter "attachment",
|
||||
:type => :text, :name => l(:label_attachment)
|
||||
|
||||
if User.current.logged?
|
||||
add_available_filter "watcher_id",
|
||||
:type => :list, :values => [["<< #{l(:label_me)} >>", "me"]]
|
||||
end
|
||||
|
||||
add_available_filter("updated_by",
|
||||
:type => :list, :values => lambda { author_values }
|
||||
)
|
||||
|
||||
add_available_filter("last_updated_by",
|
||||
:type => :list, :values => lambda { author_values }
|
||||
)
|
||||
|
||||
if project && !project.leaf?
|
||||
add_available_filter "subproject_id",
|
||||
:type => :list_subprojects,
|
||||
:values => lambda { subproject_values }
|
||||
end
|
||||
|
||||
add_custom_fields_filters(issue_custom_fields)
|
||||
add_associations_custom_fields_filters :project, :author, :assigned_to, :fixed_version
|
||||
|
||||
IssueRelation::TYPES.each do |relation_type, options|
|
||||
add_available_filter relation_type, :type => :relation, :label => options[:name], :values => lambda {all_projects_values}
|
||||
end
|
||||
add_available_filter "parent_id", :type => :tree, :label => :field_parent_issue
|
||||
add_available_filter "child_id", :type => :tree, :label => :label_subtask_plural
|
||||
|
||||
add_available_filter "issue_id", :type => :integer, :label => :label_issue
|
||||
|
||||
Tracker.disabled_core_fields(trackers).each {|field|
|
||||
delete_available_filter field
|
||||
}
|
||||
end
|
||||
|
||||
def available_columns
|
||||
return @available_columns if @available_columns
|
||||
@available_columns = self.class.available_columns.dup
|
||||
@available_columns += issue_custom_fields.visible.collect {|cf| QueryCustomFieldColumn.new(cf) }
|
||||
|
||||
if User.current.allowed_to?(:view_time_entries, project, :global => true)
|
||||
# insert the columns after total_estimated_hours or at the end
|
||||
index = @available_columns.find_index {|column| column.name == :total_estimated_hours}
|
||||
index = (index ? index + 1 : -1)
|
||||
|
||||
subselect = "SELECT SUM(hours) FROM #{TimeEntry.table_name}" +
|
||||
" JOIN #{Project.table_name} ON #{Project.table_name}.id = #{TimeEntry.table_name}.project_id" +
|
||||
" WHERE (#{TimeEntry.visible_condition(User.current)}) AND #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id"
|
||||
|
||||
@available_columns.insert index, QueryColumn.new(:spent_hours,
|
||||
:sortable => "COALESCE((#{subselect}), 0)",
|
||||
:default_order => 'desc',
|
||||
:caption => :label_spent_time,
|
||||
:totalable => true
|
||||
)
|
||||
|
||||
subselect = "SELECT SUM(hours) FROM #{TimeEntry.table_name}" +
|
||||
" JOIN #{Project.table_name} ON #{Project.table_name}.id = #{TimeEntry.table_name}.project_id" +
|
||||
" JOIN #{Issue.table_name} subtasks ON subtasks.id = #{TimeEntry.table_name}.issue_id" +
|
||||
" WHERE (#{TimeEntry.visible_condition(User.current)})" +
|
||||
" AND subtasks.root_id = #{Issue.table_name}.root_id AND subtasks.lft >= #{Issue.table_name}.lft AND subtasks.rgt <= #{Issue.table_name}.rgt"
|
||||
|
||||
@available_columns.insert index+1, QueryColumn.new(:total_spent_hours,
|
||||
:sortable => "COALESCE((#{subselect}), 0)",
|
||||
:default_order => 'desc',
|
||||
:caption => :label_total_spent_time
|
||||
)
|
||||
end
|
||||
|
||||
if User.current.allowed_to?(:set_issues_private, nil, :global => true) ||
|
||||
User.current.allowed_to?(:set_own_issues_private, nil, :global => true)
|
||||
@available_columns << QueryColumn.new(:is_private, :sortable => "#{Issue.table_name}.is_private", :groupable => true)
|
||||
end
|
||||
|
||||
disabled_fields = Tracker.disabled_core_fields(trackers).map {|field| field.sub(/_id$/, '')}
|
||||
@available_columns.reject! {|column|
|
||||
disabled_fields.include?(column.name.to_s)
|
||||
}
|
||||
|
||||
@available_columns
|
||||
end
|
||||
|
||||
def default_columns_names
|
||||
@default_columns_names ||= begin
|
||||
default_columns = Setting.issue_list_default_columns.map(&:to_sym)
|
||||
|
||||
project.present? ? default_columns : [:project] | default_columns
|
||||
end
|
||||
end
|
||||
|
||||
def default_totalable_names
|
||||
Setting.issue_list_default_totals.map(&:to_sym)
|
||||
end
|
||||
|
||||
def default_sort_criteria
|
||||
[['id', 'desc']]
|
||||
end
|
||||
|
||||
def base_scope
|
||||
Issue.visible.joins(:status, :project).where(statement)
|
||||
end
|
||||
|
||||
# Returns the issue count
|
||||
def issue_count
|
||||
base_scope.count
|
||||
rescue ::ActiveRecord::StatementInvalid => e
|
||||
raise StatementInvalid.new(e.message)
|
||||
end
|
||||
|
||||
# Returns sum of all the issue's estimated_hours
|
||||
def total_for_estimated_hours(scope)
|
||||
map_total(scope.sum(:estimated_hours)) {|t| t.to_f.round(2)}
|
||||
end
|
||||
|
||||
# Returns sum of all the issue's time entries hours
|
||||
def total_for_spent_hours(scope)
|
||||
total = scope.joins(:time_entries).
|
||||
where(TimeEntry.visible_condition(User.current)).
|
||||
sum("#{TimeEntry.table_name}.hours")
|
||||
|
||||
map_total(total) {|t| t.to_f.round(2)}
|
||||
end
|
||||
|
||||
# Returns the issues
|
||||
# Valid options are :order, :offset, :limit, :include, :conditions
|
||||
def issues(options={})
|
||||
order_option = [group_by_sort_order, (options[:order] || sort_clause)].flatten.reject(&:blank?)
|
||||
|
||||
scope = Issue.visible.
|
||||
joins(:status, :project).
|
||||
preload(:priority).
|
||||
where(statement).
|
||||
includes(([:status, :project] + (options[:include] || [])).uniq).
|
||||
where(options[:conditions]).
|
||||
order(order_option).
|
||||
joins(joins_for_order_statement(order_option.join(','))).
|
||||
limit(options[:limit]).
|
||||
offset(options[:offset])
|
||||
|
||||
scope = scope.preload([:tracker, :author, :assigned_to, :fixed_version, :category, :attachments] & columns.map(&:name))
|
||||
if has_custom_field_column?
|
||||
scope = scope.preload(:custom_values)
|
||||
end
|
||||
|
||||
issues = scope.to_a
|
||||
|
||||
if has_column?(:spent_hours)
|
||||
Issue.load_visible_spent_hours(issues)
|
||||
end
|
||||
if has_column?(:total_spent_hours)
|
||||
Issue.load_visible_total_spent_hours(issues)
|
||||
end
|
||||
if has_column?(:last_updated_by)
|
||||
Issue.load_visible_last_updated_by(issues)
|
||||
end
|
||||
if has_column?(:relations)
|
||||
Issue.load_visible_relations(issues)
|
||||
end
|
||||
if has_column?(:last_notes)
|
||||
Issue.load_visible_last_notes(issues)
|
||||
end
|
||||
issues
|
||||
rescue ::ActiveRecord::StatementInvalid => e
|
||||
raise StatementInvalid.new(e.message)
|
||||
end
|
||||
|
||||
# Returns the issues ids
|
||||
def issue_ids(options={})
|
||||
order_option = [group_by_sort_order, (options[:order] || sort_clause)].flatten.reject(&:blank?)
|
||||
|
||||
Issue.visible.
|
||||
joins(:status, :project).
|
||||
where(statement).
|
||||
includes(([:status, :project] + (options[:include] || [])).uniq).
|
||||
references(([:status, :project] + (options[:include] || [])).uniq).
|
||||
where(options[:conditions]).
|
||||
order(order_option).
|
||||
joins(joins_for_order_statement(order_option.join(','))).
|
||||
limit(options[:limit]).
|
||||
offset(options[:offset]).
|
||||
pluck(:id)
|
||||
rescue ::ActiveRecord::StatementInvalid => e
|
||||
raise StatementInvalid.new(e.message)
|
||||
end
|
||||
|
||||
# Returns the journals
|
||||
# Valid options are :order, :offset, :limit
|
||||
def journals(options={})
|
||||
Journal.visible.
|
||||
joins(:issue => [:project, :status]).
|
||||
where(statement).
|
||||
order(options[:order]).
|
||||
limit(options[:limit]).
|
||||
offset(options[:offset]).
|
||||
preload(:details, :user, {:issue => [:project, :author, :tracker, :status]}).
|
||||
to_a
|
||||
rescue ::ActiveRecord::StatementInvalid => e
|
||||
raise StatementInvalid.new(e.message)
|
||||
end
|
||||
|
||||
# Returns the versions
|
||||
# Valid options are :conditions
|
||||
def versions(options={})
|
||||
Version.visible.
|
||||
where(project_statement).
|
||||
where(options[:conditions]).
|
||||
includes(:project).
|
||||
references(:project).
|
||||
to_a
|
||||
rescue ::ActiveRecord::StatementInvalid => e
|
||||
raise StatementInvalid.new(e.message)
|
||||
end
|
||||
|
||||
def sql_for_updated_by_field(field, operator, value)
|
||||
neg = (operator == '!' ? 'NOT' : '')
|
||||
subquery = "SELECT 1 FROM #{Journal.table_name}" +
|
||||
" WHERE #{Journal.table_name}.journalized_type='Issue' AND #{Journal.table_name}.journalized_id=#{Issue.table_name}.id" +
|
||||
" AND (#{sql_for_field field, '=', value, Journal.table_name, 'user_id'})" +
|
||||
" AND (#{Journal.visible_notes_condition(User.current, :skip_pre_condition => true)})"
|
||||
|
||||
"#{neg} EXISTS (#{subquery})"
|
||||
end
|
||||
|
||||
def sql_for_last_updated_by_field(field, operator, value)
|
||||
neg = (operator == '!' ? 'NOT' : '')
|
||||
subquery = "SELECT 1 FROM #{Journal.table_name} sj" +
|
||||
" WHERE sj.journalized_type='Issue' AND sj.journalized_id=#{Issue.table_name}.id AND (#{sql_for_field field, '=', value, 'sj', 'user_id'})" +
|
||||
" AND sj.id = (SELECT MAX(#{Journal.table_name}.id) FROM #{Journal.table_name}" +
|
||||
" WHERE #{Journal.table_name}.journalized_type='Issue' AND #{Journal.table_name}.journalized_id=#{Issue.table_name}.id" +
|
||||
" AND (#{Journal.visible_notes_condition(User.current, :skip_pre_condition => true)}))"
|
||||
|
||||
"#{neg} EXISTS (#{subquery})"
|
||||
end
|
||||
|
||||
def sql_for_watcher_id_field(field, operator, value)
|
||||
db_table = Watcher.table_name
|
||||
"#{Issue.table_name}.id #{ operator == '=' ? 'IN' : 'NOT IN' } (SELECT #{db_table}.watchable_id FROM #{db_table} WHERE #{db_table}.watchable_type='Issue' AND " +
|
||||
sql_for_field(field, '=', value, db_table, 'user_id') + ')'
|
||||
end
|
||||
|
||||
def sql_for_member_of_group_field(field, operator, value)
|
||||
if operator == '*' # Any group
|
||||
groups = Group.givable
|
||||
operator = '=' # Override the operator since we want to find by assigned_to
|
||||
elsif operator == "!*"
|
||||
groups = Group.givable
|
||||
operator = '!' # Override the operator since we want to find by assigned_to
|
||||
else
|
||||
groups = Group.where(:id => value).to_a
|
||||
end
|
||||
groups ||= []
|
||||
|
||||
members_of_groups = groups.inject([]) {|user_ids, group|
|
||||
user_ids + group.user_ids + [group.id]
|
||||
}.uniq.compact.sort.collect(&:to_s)
|
||||
|
||||
'(' + sql_for_field("assigned_to_id", operator, members_of_groups, Issue.table_name, "assigned_to_id", false) + ')'
|
||||
end
|
||||
|
||||
def sql_for_assigned_to_role_field(field, operator, value)
|
||||
case operator
|
||||
when "*", "!*" # Member / Not member
|
||||
sw = operator == "!*" ? 'NOT' : ''
|
||||
nl = operator == "!*" ? "#{Issue.table_name}.assigned_to_id IS NULL OR" : ''
|
||||
"(#{nl} #{Issue.table_name}.assigned_to_id #{sw} IN (SELECT DISTINCT #{Member.table_name}.user_id FROM #{Member.table_name}" +
|
||||
" WHERE #{Member.table_name}.project_id = #{Issue.table_name}.project_id))"
|
||||
when "=", "!"
|
||||
role_cond = value.any? ?
|
||||
"#{MemberRole.table_name}.role_id IN (" + value.collect{|val| "'#{self.class.connection.quote_string(val)}'"}.join(",") + ")" :
|
||||
"1=0"
|
||||
|
||||
sw = operator == "!" ? 'NOT' : ''
|
||||
nl = operator == "!" ? "#{Issue.table_name}.assigned_to_id IS NULL OR" : ''
|
||||
"(#{nl} #{Issue.table_name}.assigned_to_id #{sw} IN (SELECT DISTINCT #{Member.table_name}.user_id FROM #{Member.table_name}, #{MemberRole.table_name}" +
|
||||
" WHERE #{Member.table_name}.project_id = #{Issue.table_name}.project_id AND #{Member.table_name}.id = #{MemberRole.table_name}.member_id AND #{role_cond}))"
|
||||
end
|
||||
end
|
||||
|
||||
def sql_for_fixed_version_status_field(field, operator, value)
|
||||
where = sql_for_field(field, operator, value, Version.table_name, "status")
|
||||
version_ids = versions(:conditions => [where]).map(&:id)
|
||||
|
||||
nl = operator == "!" ? "#{Issue.table_name}.fixed_version_id IS NULL OR" : ''
|
||||
"(#{nl} #{sql_for_field("fixed_version_id", "=", version_ids, Issue.table_name, "fixed_version_id")})"
|
||||
end
|
||||
|
||||
def sql_for_fixed_version_due_date_field(field, operator, value)
|
||||
where = sql_for_field(field, operator, value, Version.table_name, "effective_date")
|
||||
version_ids = versions(:conditions => [where]).map(&:id)
|
||||
|
||||
nl = operator == "!*" ? "#{Issue.table_name}.fixed_version_id IS NULL OR" : ''
|
||||
"(#{nl} #{sql_for_field("fixed_version_id", "=", version_ids, Issue.table_name, "fixed_version_id")})"
|
||||
end
|
||||
|
||||
def sql_for_is_private_field(field, operator, value)
|
||||
op = (operator == "=" ? 'IN' : 'NOT IN')
|
||||
va = value.map {|v| v == '0' ? self.class.connection.quoted_false : self.class.connection.quoted_true}.uniq.join(',')
|
||||
|
||||
"#{Issue.table_name}.is_private #{op} (#{va})"
|
||||
end
|
||||
|
||||
def sql_for_attachment_field(field, operator, value)
|
||||
case operator
|
||||
when "*", "!*"
|
||||
e = (operator == "*" ? "EXISTS" : "NOT EXISTS")
|
||||
"#{e} (SELECT 1 FROM #{Attachment.table_name} a WHERE a.container_type = 'Issue' AND a.container_id = #{Issue.table_name}.id)"
|
||||
when "~", "!~"
|
||||
c = sql_contains("a.filename", value.first)
|
||||
e = (operator == "~" ? "EXISTS" : "NOT EXISTS")
|
||||
"#{e} (SELECT 1 FROM #{Attachment.table_name} a WHERE a.container_type = 'Issue' AND a.container_id = #{Issue.table_name}.id AND #{c})"
|
||||
end
|
||||
end
|
||||
|
||||
def sql_for_parent_id_field(field, operator, value)
|
||||
case operator
|
||||
when "="
|
||||
"#{Issue.table_name}.parent_id = #{value.first.to_i}"
|
||||
when "~"
|
||||
root_id, lft, rgt = Issue.where(:id => value.first.to_i).pluck(:root_id, :lft, :rgt).first
|
||||
if root_id && lft && rgt
|
||||
"#{Issue.table_name}.root_id = #{root_id} AND #{Issue.table_name}.lft > #{lft} AND #{Issue.table_name}.rgt < #{rgt}"
|
||||
else
|
||||
"1=0"
|
||||
end
|
||||
when "!*"
|
||||
"#{Issue.table_name}.parent_id IS NULL"
|
||||
when "*"
|
||||
"#{Issue.table_name}.parent_id IS NOT NULL"
|
||||
end
|
||||
end
|
||||
|
||||
def sql_for_child_id_field(field, operator, value)
|
||||
case operator
|
||||
when "="
|
||||
parent_id = Issue.where(:id => value.first.to_i).pluck(:parent_id).first
|
||||
if parent_id
|
||||
"#{Issue.table_name}.id = #{parent_id}"
|
||||
else
|
||||
"1=0"
|
||||
end
|
||||
when "~"
|
||||
root_id, lft, rgt = Issue.where(:id => value.first.to_i).pluck(:root_id, :lft, :rgt).first
|
||||
if root_id && lft && rgt
|
||||
"#{Issue.table_name}.root_id = #{root_id} AND #{Issue.table_name}.lft < #{lft} AND #{Issue.table_name}.rgt > #{rgt}"
|
||||
else
|
||||
"1=0"
|
||||
end
|
||||
when "!*"
|
||||
"#{Issue.table_name}.rgt - #{Issue.table_name}.lft = 1"
|
||||
when "*"
|
||||
"#{Issue.table_name}.rgt - #{Issue.table_name}.lft > 1"
|
||||
end
|
||||
end
|
||||
|
||||
def sql_for_updated_on_field(field, operator, value)
|
||||
case operator
|
||||
when "!*"
|
||||
"#{Issue.table_name}.updated_on = #{Issue.table_name}.created_on"
|
||||
when "*"
|
||||
"#{Issue.table_name}.updated_on > #{Issue.table_name}.created_on"
|
||||
else
|
||||
sql_for_field("updated_on", operator, value, Issue.table_name, "updated_on")
|
||||
end
|
||||
end
|
||||
|
||||
def sql_for_issue_id_field(field, operator, value)
|
||||
if operator == "="
|
||||
# accepts a comma separated list of ids
|
||||
ids = value.first.to_s.scan(/\d+/).map(&:to_i)
|
||||
if ids.present?
|
||||
"#{Issue.table_name}.id IN (#{ids.join(",")})"
|
||||
else
|
||||
"1=0"
|
||||
end
|
||||
else
|
||||
sql_for_field("id", operator, value, Issue.table_name, "id")
|
||||
end
|
||||
end
|
||||
|
||||
def sql_for_relations(field, operator, value, options={})
|
||||
relation_options = IssueRelation::TYPES[field]
|
||||
return relation_options unless relation_options
|
||||
|
||||
relation_type = field
|
||||
join_column, target_join_column = "issue_from_id", "issue_to_id"
|
||||
if relation_options[:reverse] || options[:reverse]
|
||||
relation_type = relation_options[:reverse] || relation_type
|
||||
join_column, target_join_column = target_join_column, join_column
|
||||
end
|
||||
|
||||
sql = case operator
|
||||
when "*", "!*"
|
||||
op = (operator == "*" ? 'IN' : 'NOT IN')
|
||||
"#{Issue.table_name}.id #{op} (SELECT DISTINCT #{IssueRelation.table_name}.#{join_column} FROM #{IssueRelation.table_name} WHERE #{IssueRelation.table_name}.relation_type = '#{self.class.connection.quote_string(relation_type)}')"
|
||||
when "=", "!"
|
||||
op = (operator == "=" ? 'IN' : 'NOT IN')
|
||||
"#{Issue.table_name}.id #{op} (SELECT DISTINCT #{IssueRelation.table_name}.#{join_column} FROM #{IssueRelation.table_name} WHERE #{IssueRelation.table_name}.relation_type = '#{self.class.connection.quote_string(relation_type)}' AND #{IssueRelation.table_name}.#{target_join_column} = #{value.first.to_i})"
|
||||
when "=p", "=!p", "!p"
|
||||
op = (operator == "!p" ? 'NOT IN' : 'IN')
|
||||
comp = (operator == "=!p" ? '<>' : '=')
|
||||
"#{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 = '#{self.class.connection.quote_string(relation_type)}' AND #{IssueRelation.table_name}.#{target_join_column} = relissues.id AND relissues.project_id #{comp} #{value.first.to_i})"
|
||||
when "*o", "!o"
|
||||
op = (operator == "!o" ? 'NOT IN' : 'IN')
|
||||
"#{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 = '#{self.class.connection.quote_string(relation_type)}' AND #{IssueRelation.table_name}.#{target_join_column} = relissues.id AND relissues.status_id IN (SELECT id FROM #{IssueStatus.table_name} WHERE is_closed=#{self.class.connection.quoted_false}))"
|
||||
end
|
||||
|
||||
if relation_options[:sym] == field && !options[:reverse]
|
||||
sqls = [sql, sql_for_relations(field, operator, value, :reverse => true)]
|
||||
sql = sqls.join(["!", "!*", "!p", '!o'].include?(operator) ? " AND " : " OR ")
|
||||
end
|
||||
"(#{sql})"
|
||||
end
|
||||
|
||||
def find_assigned_to_id_filter_values(values)
|
||||
Principal.visible.where(:id => values).map {|p| [p.name, p.id.to_s]}
|
||||
end
|
||||
alias :find_author_id_filter_values :find_assigned_to_id_filter_values
|
||||
|
||||
IssueRelation::TYPES.keys.each do |relation_type|
|
||||
alias_method "sql_for_#{relation_type}_field".to_sym, :sql_for_relations
|
||||
end
|
||||
|
||||
def joins_for_order_statement(order_options)
|
||||
joins = [super]
|
||||
|
||||
if order_options
|
||||
if order_options.include?('authors')
|
||||
joins << "LEFT OUTER JOIN #{User.table_name} authors ON authors.id = #{queried_table_name}.author_id"
|
||||
end
|
||||
if order_options.include?('users')
|
||||
joins << "LEFT OUTER JOIN #{User.table_name} ON #{User.table_name}.id = #{queried_table_name}.assigned_to_id"
|
||||
end
|
||||
if order_options.include?('last_journal_user')
|
||||
joins << "LEFT OUTER JOIN #{Journal.table_name} ON #{Journal.table_name}.id = (SELECT MAX(#{Journal.table_name}.id) FROM #{Journal.table_name}" +
|
||||
" WHERE #{Journal.table_name}.journalized_type='Issue' AND #{Journal.table_name}.journalized_id=#{Issue.table_name}.id AND #{Journal.visible_notes_condition(User.current, :skip_pre_condition => true)})" +
|
||||
" LEFT OUTER JOIN #{User.table_name} last_journal_user ON last_journal_user.id = #{Journal.table_name}.user_id";
|
||||
end
|
||||
if order_options.include?('versions')
|
||||
joins << "LEFT OUTER JOIN #{Version.table_name} ON #{Version.table_name}.id = #{queried_table_name}.fixed_version_id"
|
||||
end
|
||||
if order_options.include?('issue_categories')
|
||||
joins << "LEFT OUTER JOIN #{IssueCategory.table_name} ON #{IssueCategory.table_name}.id = #{queried_table_name}.category_id"
|
||||
end
|
||||
if order_options.include?('trackers')
|
||||
joins << "LEFT OUTER JOIN #{Tracker.table_name} ON #{Tracker.table_name}.id = #{queried_table_name}.tracker_id"
|
||||
end
|
||||
if order_options.include?('enumerations')
|
||||
joins << "LEFT OUTER JOIN #{IssuePriority.table_name} ON #{IssuePriority.table_name}.id = #{queried_table_name}.priority_id"
|
||||
end
|
||||
end
|
||||
|
||||
joins.any? ? joins.join(' ') : nil
|
||||
end
|
||||
end
|
256
app/models/issue_relation.rb
Normal file
256
app/models/issue_relation.rb
Normal file
|
@ -0,0 +1,256 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
class IssueRelation < ActiveRecord::Base
|
||||
# Class used to represent the relations of an issue
|
||||
class Relations < Array
|
||||
include Redmine::I18n
|
||||
|
||||
def initialize(issue, *args)
|
||||
@issue = issue
|
||||
super(*args)
|
||||
end
|
||||
|
||||
def to_s(*args)
|
||||
map {|relation| relation.to_s(@issue)}.join(', ')
|
||||
end
|
||||
end
|
||||
|
||||
include Redmine::SafeAttributes
|
||||
|
||||
belongs_to :issue_from, :class_name => 'Issue'
|
||||
belongs_to :issue_to, :class_name => 'Issue'
|
||||
|
||||
TYPE_RELATES = "relates"
|
||||
TYPE_DUPLICATES = "duplicates"
|
||||
TYPE_DUPLICATED = "duplicated"
|
||||
TYPE_BLOCKS = "blocks"
|
||||
TYPE_BLOCKED = "blocked"
|
||||
TYPE_PRECEDES = "precedes"
|
||||
TYPE_FOLLOWS = "follows"
|
||||
TYPE_COPIED_TO = "copied_to"
|
||||
TYPE_COPIED_FROM = "copied_from"
|
||||
|
||||
TYPES = {
|
||||
TYPE_RELATES => { :name => :label_relates_to, :sym_name => :label_relates_to,
|
||||
:order => 1, :sym => TYPE_RELATES },
|
||||
TYPE_DUPLICATES => { :name => :label_duplicates, :sym_name => :label_duplicated_by,
|
||||
:order => 2, :sym => TYPE_DUPLICATED },
|
||||
TYPE_DUPLICATED => { :name => :label_duplicated_by, :sym_name => :label_duplicates,
|
||||
:order => 3, :sym => TYPE_DUPLICATES, :reverse => TYPE_DUPLICATES },
|
||||
TYPE_BLOCKS => { :name => :label_blocks, :sym_name => :label_blocked_by,
|
||||
:order => 4, :sym => TYPE_BLOCKED },
|
||||
TYPE_BLOCKED => { :name => :label_blocked_by, :sym_name => :label_blocks,
|
||||
:order => 5, :sym => TYPE_BLOCKS, :reverse => TYPE_BLOCKS },
|
||||
TYPE_PRECEDES => { :name => :label_precedes, :sym_name => :label_follows,
|
||||
:order => 6, :sym => TYPE_FOLLOWS },
|
||||
TYPE_FOLLOWS => { :name => :label_follows, :sym_name => :label_precedes,
|
||||
:order => 7, :sym => TYPE_PRECEDES, :reverse => TYPE_PRECEDES },
|
||||
TYPE_COPIED_TO => { :name => :label_copied_to, :sym_name => :label_copied_from,
|
||||
:order => 8, :sym => TYPE_COPIED_FROM },
|
||||
TYPE_COPIED_FROM => { :name => :label_copied_from, :sym_name => :label_copied_to,
|
||||
:order => 9, :sym => TYPE_COPIED_TO, :reverse => TYPE_COPIED_TO }
|
||||
}.freeze
|
||||
|
||||
validates_presence_of :issue_from, :issue_to, :relation_type
|
||||
validates_inclusion_of :relation_type, :in => TYPES.keys
|
||||
validates_numericality_of :delay, :allow_nil => true
|
||||
validates_uniqueness_of :issue_to_id, :scope => :issue_from_id
|
||||
validate :validate_issue_relation
|
||||
|
||||
attr_protected :issue_from_id, :issue_to_id
|
||||
before_save :handle_issue_order
|
||||
after_create :call_issues_relation_added_callback
|
||||
after_destroy :call_issues_relation_removed_callback
|
||||
|
||||
safe_attributes 'relation_type',
|
||||
'delay',
|
||||
'issue_to_id'
|
||||
|
||||
def safe_attributes=(attrs, user=User.current)
|
||||
return unless attrs.is_a?(Hash)
|
||||
attrs = attrs.deep_dup
|
||||
|
||||
if issue_id = attrs.delete('issue_to_id')
|
||||
if issue_id.to_s.strip.match(/\A#?(\d+)\z/)
|
||||
issue_id = $1.to_i
|
||||
self.issue_to = Issue.visible(user).find_by_id(issue_id)
|
||||
end
|
||||
end
|
||||
|
||||
super(attrs)
|
||||
end
|
||||
|
||||
def visible?(user=User.current)
|
||||
(issue_from.nil? || issue_from.visible?(user)) && (issue_to.nil? || issue_to.visible?(user))
|
||||
end
|
||||
|
||||
def deletable?(user=User.current)
|
||||
visible?(user) &&
|
||||
((issue_from.nil? || user.allowed_to?(:manage_issue_relations, issue_from.project)) ||
|
||||
(issue_to.nil? || user.allowed_to?(:manage_issue_relations, issue_to.project)))
|
||||
end
|
||||
|
||||
def initialize(attributes=nil, *args)
|
||||
super
|
||||
if new_record?
|
||||
if relation_type.blank?
|
||||
self.relation_type = IssueRelation::TYPE_RELATES
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def validate_issue_relation
|
||||
if issue_from && issue_to
|
||||
errors.add :issue_to_id, :invalid if issue_from_id == issue_to_id
|
||||
unless issue_from.project_id == issue_to.project_id ||
|
||||
Setting.cross_project_issue_relations?
|
||||
errors.add :issue_to_id, :not_same_project
|
||||
end
|
||||
if circular_dependency?
|
||||
errors.add :base, :circular_dependency
|
||||
end
|
||||
if issue_from.is_descendant_of?(issue_to) || issue_from.is_ancestor_of?(issue_to)
|
||||
errors.add :base, :cant_link_an_issue_with_a_descendant
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def other_issue(issue)
|
||||
(self.issue_from_id == issue.id) ? issue_to : issue_from
|
||||
end
|
||||
|
||||
# Returns the relation type for +issue+
|
||||
def relation_type_for(issue)
|
||||
if TYPES[relation_type]
|
||||
if self.issue_from_id == issue.id
|
||||
relation_type
|
||||
else
|
||||
TYPES[relation_type][:sym]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def label_for(issue)
|
||||
TYPES[relation_type] ?
|
||||
TYPES[relation_type][(self.issue_from_id == issue.id) ? :name : :sym_name] :
|
||||
:unknow
|
||||
end
|
||||
|
||||
def to_s(issue=nil)
|
||||
issue ||= issue_from
|
||||
issue_text = block_given? ? yield(other_issue(issue)) : "##{other_issue(issue).try(:id)}"
|
||||
s = []
|
||||
s << l(label_for(issue))
|
||||
s << "(#{l('datetime.distance_in_words.x_days', :count => delay)})" if delay && delay != 0
|
||||
s << issue_text
|
||||
s.join(' ')
|
||||
end
|
||||
|
||||
def css_classes_for(issue)
|
||||
"rel-#{relation_type_for(issue)}"
|
||||
end
|
||||
|
||||
def handle_issue_order
|
||||
reverse_if_needed
|
||||
|
||||
if TYPE_PRECEDES == relation_type
|
||||
self.delay ||= 0
|
||||
else
|
||||
self.delay = nil
|
||||
end
|
||||
set_issue_to_dates
|
||||
end
|
||||
|
||||
def set_issue_to_dates
|
||||
soonest_start = self.successor_soonest_start
|
||||
if soonest_start && issue_to
|
||||
issue_to.reschedule_on!(soonest_start)
|
||||
end
|
||||
end
|
||||
|
||||
def successor_soonest_start
|
||||
if (TYPE_PRECEDES == self.relation_type) && delay && issue_from &&
|
||||
(issue_from.start_date || issue_from.due_date)
|
||||
(issue_from.due_date || issue_from.start_date) + 1 + delay
|
||||
end
|
||||
end
|
||||
|
||||
def <=>(relation)
|
||||
r = TYPES[self.relation_type][:order] <=> TYPES[relation.relation_type][:order]
|
||||
r == 0 ? id <=> relation.id : r
|
||||
end
|
||||
|
||||
def init_journals(user)
|
||||
issue_from.init_journal(user) if issue_from
|
||||
issue_to.init_journal(user) if issue_to
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Reverses the relation if needed so that it gets stored in the proper way
|
||||
# Should not be reversed before validation so that it can be displayed back
|
||||
# as entered on new relation form.
|
||||
#
|
||||
# Orders relates relations by ID, so that uniqueness index in DB is triggered
|
||||
# on concurrent access.
|
||||
def reverse_if_needed
|
||||
if TYPES.has_key?(relation_type) && TYPES[relation_type][:reverse]
|
||||
issue_tmp = issue_to
|
||||
self.issue_to = issue_from
|
||||
self.issue_from = issue_tmp
|
||||
self.relation_type = TYPES[relation_type][:reverse]
|
||||
|
||||
elsif relation_type == TYPE_RELATES && issue_from_id > issue_to_id
|
||||
self.issue_to, self.issue_from = issue_from, issue_to
|
||||
end
|
||||
end
|
||||
|
||||
# Returns true if the relation would create a circular dependency
|
||||
def circular_dependency?
|
||||
case relation_type
|
||||
when 'follows'
|
||||
issue_from.would_reschedule? issue_to
|
||||
when 'precedes'
|
||||
issue_to.would_reschedule? issue_from
|
||||
when 'blocked'
|
||||
issue_from.blocks? issue_to
|
||||
when 'blocks'
|
||||
issue_to.blocks? issue_from
|
||||
when 'relates'
|
||||
self.class.where(issue_from_id: issue_to, issue_to_id: issue_from).present?
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
def call_issues_relation_added_callback
|
||||
call_issues_callback :relation_added
|
||||
end
|
||||
|
||||
def call_issues_relation_removed_callback
|
||||
call_issues_callback :relation_removed
|
||||
end
|
||||
|
||||
def call_issues_callback(name)
|
||||
[issue_from, issue_to].each do |issue|
|
||||
if issue
|
||||
issue.send name, self
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
122
app/models/issue_status.rb
Normal file
122
app/models/issue_status.rb
Normal file
|
@ -0,0 +1,122 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
class IssueStatus < ActiveRecord::Base
|
||||
include Redmine::SafeAttributes
|
||||
|
||||
before_destroy :check_integrity
|
||||
has_many :workflows, :class_name => 'WorkflowTransition', :foreign_key => "old_status_id"
|
||||
has_many :workflow_transitions_as_new_status, :class_name => 'WorkflowTransition', :foreign_key => "new_status_id"
|
||||
acts_as_positioned
|
||||
|
||||
after_update :handle_is_closed_change
|
||||
before_destroy :delete_workflow_rules
|
||||
|
||||
validates_presence_of :name
|
||||
validates_uniqueness_of :name
|
||||
validates_length_of :name, :maximum => 30
|
||||
validates_inclusion_of :default_done_ratio, :in => 0..100, :allow_nil => true
|
||||
attr_protected :id
|
||||
|
||||
scope :sorted, lambda { order(:position) }
|
||||
scope :named, lambda {|arg| where("LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip)}
|
||||
|
||||
safe_attributes 'name',
|
||||
'is_closed',
|
||||
'position',
|
||||
'default_done_ratio'
|
||||
|
||||
# Update all the +Issues+ setting their done_ratio to the value of their +IssueStatus+
|
||||
def self.update_issue_done_ratios
|
||||
if Issue.use_status_for_done_ratio?
|
||||
IssueStatus.where("default_done_ratio >= 0").each do |status|
|
||||
Issue.where({:status_id => status.id}).update_all({:done_ratio => status.default_done_ratio})
|
||||
end
|
||||
end
|
||||
|
||||
return Issue.use_status_for_done_ratio?
|
||||
end
|
||||
|
||||
# Returns an array of all statuses the given role can switch to
|
||||
def new_statuses_allowed_to(roles, tracker, author=false, assignee=false)
|
||||
self.class.new_statuses_allowed(self, roles, tracker, author, assignee)
|
||||
end
|
||||
alias :find_new_statuses_allowed_to :new_statuses_allowed_to
|
||||
|
||||
def self.new_statuses_allowed(status, roles, tracker, author=false, assignee=false)
|
||||
if roles.present? && tracker
|
||||
status_id = status.try(:id) || 0
|
||||
|
||||
scope = IssueStatus.
|
||||
joins(:workflow_transitions_as_new_status).
|
||||
where(:workflows => {:old_status_id => status_id, :role_id => roles.map(&:id), :tracker_id => tracker.id})
|
||||
|
||||
unless author && assignee
|
||||
if author || assignee
|
||||
scope = scope.where("author = ? OR assignee = ?", author, assignee)
|
||||
else
|
||||
scope = scope.where("author = ? AND assignee = ?", false, false)
|
||||
end
|
||||
end
|
||||
|
||||
scope.distinct.to_a.sort
|
||||
else
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
def <=>(status)
|
||||
position <=> status.position
|
||||
end
|
||||
|
||||
def to_s; name end
|
||||
|
||||
private
|
||||
|
||||
# Updates issues closed_on attribute when an existing status is set as closed.
|
||||
def handle_is_closed_change
|
||||
if is_closed_changed? && is_closed == true
|
||||
# First we update issues that have a journal for when the current status was set,
|
||||
# a subselect is used to update all issues with a single query
|
||||
subquery = Journal.joins(:details).
|
||||
where(:journalized_type => 'Issue').
|
||||
where("journalized_id = #{Issue.table_name}.id").
|
||||
where(:journal_details => {:property => 'attr', :prop_key => 'status_id', :value => id.to_s}).
|
||||
select("MAX(created_on)").
|
||||
to_sql
|
||||
Issue.where(:status_id => id, :closed_on => nil).update_all("closed_on = (#{subquery})")
|
||||
|
||||
# Then we update issues that don't have a journal which means the
|
||||
# current status was set on creation
|
||||
Issue.where(:status_id => id, :closed_on => nil).update_all("closed_on = created_on")
|
||||
end
|
||||
end
|
||||
|
||||
def check_integrity
|
||||
if Issue.where(:status_id => id).any?
|
||||
raise "This status is used by some issues"
|
||||
elsif Tracker.where(:default_status_id => id).any?
|
||||
raise "This status is used as the default status by some trackers"
|
||||
end
|
||||
end
|
||||
|
||||
# Deletes associated workflows
|
||||
def delete_workflow_rules
|
||||
WorkflowRule.where(:old_status_id => id).delete_all
|
||||
WorkflowRule.where(:new_status_id => id).delete_all
|
||||
end
|
||||
end
|
328
app/models/journal.rb
Normal file
328
app/models/journal.rb
Normal file
|
@ -0,0 +1,328 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
class Journal < ActiveRecord::Base
|
||||
include Redmine::SafeAttributes
|
||||
|
||||
belongs_to :journalized, :polymorphic => true
|
||||
# added as a quick fix to allow eager loading of the polymorphic association
|
||||
# since always associated to an issue, for now
|
||||
belongs_to :issue, :foreign_key => :journalized_id
|
||||
|
||||
belongs_to :user
|
||||
has_many :details, :class_name => "JournalDetail", :dependent => :delete_all, :inverse_of => :journal
|
||||
attr_accessor :indice
|
||||
attr_protected :id
|
||||
|
||||
acts_as_event :title => Proc.new {|o| status = ((s = o.new_status) ? " (#{s})" : nil); "#{o.issue.tracker} ##{o.issue.id}#{status}: #{o.issue.subject}" },
|
||||
:description => :notes,
|
||||
:author => :user,
|
||||
:group => :issue,
|
||||
:type => Proc.new {|o| (s = o.new_status) ? (s.is_closed? ? 'issue-closed' : 'issue-edit') : 'issue-note' },
|
||||
:url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.issue.id, :anchor => "change-#{o.id}"}}
|
||||
|
||||
acts_as_activity_provider :type => 'issues',
|
||||
:author_key => :user_id,
|
||||
:scope => preload({:issue => :project}, :user).
|
||||
joins("LEFT OUTER JOIN #{JournalDetail.table_name} ON #{JournalDetail.table_name}.journal_id = #{Journal.table_name}.id").
|
||||
where("#{Journal.table_name}.journalized_type = 'Issue' AND" +
|
||||
" (#{JournalDetail.table_name}.prop_key = 'status_id' OR #{Journal.table_name}.notes <> '')").distinct
|
||||
|
||||
before_create :split_private_notes
|
||||
after_commit :send_notification, :on => :create
|
||||
|
||||
scope :visible, lambda {|*args|
|
||||
user = args.shift || User.current
|
||||
options = args.shift || {}
|
||||
|
||||
joins(:issue => :project).
|
||||
where(Issue.visible_condition(user, options)).
|
||||
where(Journal.visible_notes_condition(user, :skip_pre_condition => true))
|
||||
}
|
||||
|
||||
safe_attributes 'notes',
|
||||
:if => lambda {|journal, user| journal.new_record? || journal.editable_by?(user)}
|
||||
safe_attributes 'private_notes',
|
||||
:if => lambda {|journal, user| user.allowed_to?(:set_notes_private, journal.project)}
|
||||
|
||||
# Returns a SQL condition to filter out journals with notes that are not visible to user
|
||||
def self.visible_notes_condition(user=User.current, options={})
|
||||
private_notes_permission = Project.allowed_to_condition(user, :view_private_notes, options)
|
||||
sanitize_sql_for_conditions(["(#{table_name}.private_notes = ? OR #{table_name}.user_id = ? OR (#{private_notes_permission}))", false, user.id])
|
||||
end
|
||||
|
||||
def initialize(*args)
|
||||
super
|
||||
if journalized
|
||||
if journalized.new_record?
|
||||
self.notify = false
|
||||
else
|
||||
start
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def save(*args)
|
||||
journalize_changes
|
||||
# Do not save an empty journal
|
||||
(details.empty? && notes.blank?) ? false : super
|
||||
end
|
||||
|
||||
# Returns journal details that are visible to user
|
||||
def visible_details(user=User.current)
|
||||
details.select do |detail|
|
||||
if detail.property == 'cf'
|
||||
detail.custom_field && detail.custom_field.visible_by?(project, user)
|
||||
elsif detail.property == 'relation'
|
||||
Issue.find_by_id(detail.value || detail.old_value).try(:visible?, user)
|
||||
else
|
||||
true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def each_notification(users, &block)
|
||||
if users.any?
|
||||
users_by_details_visibility = users.group_by do |user|
|
||||
visible_details(user)
|
||||
end
|
||||
users_by_details_visibility.each do |visible_details, users|
|
||||
if notes? || visible_details.any?
|
||||
yield(users)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Returns the JournalDetail for the given attribute, or nil if the attribute
|
||||
# was not updated
|
||||
def detail_for_attribute(attribute)
|
||||
details.detect {|detail| detail.prop_key == attribute}
|
||||
end
|
||||
|
||||
# Returns the new status if the journal contains a status change, otherwise nil
|
||||
def new_status
|
||||
s = new_value_for('status_id')
|
||||
s ? IssueStatus.find_by_id(s.to_i) : nil
|
||||
end
|
||||
|
||||
def new_value_for(prop)
|
||||
detail_for_attribute(prop).try(:value)
|
||||
end
|
||||
|
||||
def editable_by?(usr)
|
||||
usr && usr.logged? && (usr.allowed_to?(:edit_issue_notes, project) || (self.user == usr && usr.allowed_to?(:edit_own_issue_notes, project)))
|
||||
end
|
||||
|
||||
def project
|
||||
journalized.respond_to?(:project) ? journalized.project : nil
|
||||
end
|
||||
|
||||
def attachments
|
||||
journalized.respond_to?(:attachments) ? journalized.attachments : []
|
||||
end
|
||||
|
||||
# Returns a string of css classes
|
||||
def css_classes
|
||||
s = 'journal'
|
||||
s << ' has-notes' unless notes.blank?
|
||||
s << ' has-details' unless details.blank?
|
||||
s << ' private-notes' if private_notes?
|
||||
s
|
||||
end
|
||||
|
||||
def notify?
|
||||
@notify != false
|
||||
end
|
||||
|
||||
def notify=(arg)
|
||||
@notify = arg
|
||||
end
|
||||
|
||||
def notified_users
|
||||
notified = journalized.notified_users
|
||||
if private_notes?
|
||||
notified = notified.select {|user| user.allowed_to?(:view_private_notes, journalized.project)}
|
||||
end
|
||||
notified
|
||||
end
|
||||
|
||||
def recipients
|
||||
notified_users.map(&:mail)
|
||||
end
|
||||
|
||||
def notified_watchers
|
||||
notified = journalized.notified_watchers
|
||||
if private_notes?
|
||||
notified = notified.select {|user| user.allowed_to?(:view_private_notes, journalized.project)}
|
||||
end
|
||||
notified
|
||||
end
|
||||
|
||||
def watcher_recipients
|
||||
notified_watchers.map(&:mail)
|
||||
end
|
||||
|
||||
# Sets @custom_field instance variable on journals details using a single query
|
||||
def self.preload_journals_details_custom_fields(journals)
|
||||
field_ids = journals.map(&:details).flatten.select {|d| d.property == 'cf'}.map(&:prop_key).uniq
|
||||
if field_ids.any?
|
||||
fields_by_id = CustomField.where(:id => field_ids).inject({}) {|h, f| h[f.id] = f; h}
|
||||
journals.each do |journal|
|
||||
journal.details.each do |detail|
|
||||
if detail.property == 'cf'
|
||||
detail.instance_variable_set "@custom_field", fields_by_id[detail.prop_key.to_i]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
journals
|
||||
end
|
||||
|
||||
# Stores the values of the attributes and custom fields of the journalized object
|
||||
def start
|
||||
if journalized
|
||||
@attributes_before_change = journalized.journalized_attribute_names.inject({}) do |h, attribute|
|
||||
h[attribute] = journalized.send(attribute)
|
||||
h
|
||||
end
|
||||
@custom_values_before_change = journalized.custom_field_values.inject({}) do |h, c|
|
||||
h[c.custom_field_id] = c.value
|
||||
h
|
||||
end
|
||||
end
|
||||
self
|
||||
end
|
||||
|
||||
# Adds a journal detail for an attachment that was added or removed
|
||||
def journalize_attachment(attachment, added_or_removed)
|
||||
key = (added_or_removed == :removed ? :old_value : :value)
|
||||
details << JournalDetail.new(
|
||||
:property => 'attachment',
|
||||
:prop_key => attachment.id,
|
||||
key => attachment.filename
|
||||
)
|
||||
end
|
||||
|
||||
# Adds a journal detail for an issue relation that was added or removed
|
||||
def journalize_relation(relation, added_or_removed)
|
||||
key = (added_or_removed == :removed ? :old_value : :value)
|
||||
details << JournalDetail.new(
|
||||
:property => 'relation',
|
||||
:prop_key => relation.relation_type_for(journalized),
|
||||
key => relation.other_issue(journalized).try(:id)
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Generates journal details for attribute and custom field changes
|
||||
def journalize_changes
|
||||
# attributes changes
|
||||
if @attributes_before_change
|
||||
attrs = (journalized.journalized_attribute_names + @attributes_before_change.keys).uniq
|
||||
attrs.each do |attribute|
|
||||
before = @attributes_before_change[attribute]
|
||||
after = journalized.send(attribute)
|
||||
next if before == after || (before.blank? && after.blank?)
|
||||
add_attribute_detail(attribute, before, after)
|
||||
end
|
||||
end
|
||||
# custom fields changes
|
||||
if @custom_values_before_change
|
||||
values_by_custom_field_id = {}
|
||||
@custom_values_before_change.each do |custom_field_id, value|
|
||||
values_by_custom_field_id[custom_field_id] = nil
|
||||
end
|
||||
journalized.custom_field_values.each do |c|
|
||||
values_by_custom_field_id[c.custom_field_id] = c.value
|
||||
end
|
||||
|
||||
values_by_custom_field_id.each do |custom_field_id, after|
|
||||
before = @custom_values_before_change[custom_field_id]
|
||||
next if before == after || (before.blank? && after.blank?)
|
||||
|
||||
if before.is_a?(Array) || after.is_a?(Array)
|
||||
before = [before] unless before.is_a?(Array)
|
||||
after = [after] unless after.is_a?(Array)
|
||||
|
||||
# values removed
|
||||
(before - after).reject(&:blank?).each do |value|
|
||||
add_custom_field_detail(custom_field_id, value, nil)
|
||||
end
|
||||
# values added
|
||||
(after - before).reject(&:blank?).each do |value|
|
||||
add_custom_field_detail(custom_field_id, nil, value)
|
||||
end
|
||||
else
|
||||
add_custom_field_detail(custom_field_id, before, after)
|
||||
end
|
||||
end
|
||||
end
|
||||
start
|
||||
end
|
||||
|
||||
# Adds a journal detail for an attribute change
|
||||
def add_attribute_detail(attribute, old_value, value)
|
||||
add_detail('attr', attribute, old_value, value)
|
||||
end
|
||||
|
||||
# Adds a journal detail for a custom field value change
|
||||
def add_custom_field_detail(custom_field_id, old_value, value)
|
||||
add_detail('cf', custom_field_id, old_value, value)
|
||||
end
|
||||
|
||||
# Adds a journal detail
|
||||
def add_detail(property, prop_key, old_value, value)
|
||||
details << JournalDetail.new(
|
||||
:property => property,
|
||||
:prop_key => prop_key,
|
||||
:old_value => old_value,
|
||||
:value => value
|
||||
)
|
||||
end
|
||||
|
||||
def split_private_notes
|
||||
if private_notes?
|
||||
if notes.present?
|
||||
if details.any?
|
||||
# Split the journal (notes/changes) so we don't have half-private journals
|
||||
journal = Journal.new(:journalized => journalized, :user => user, :notes => nil, :private_notes => false)
|
||||
journal.details = details
|
||||
journal.save
|
||||
self.details = []
|
||||
self.created_on = journal.created_on
|
||||
end
|
||||
else
|
||||
# Blank notes should not be private
|
||||
self.private_notes = false
|
||||
end
|
||||
end
|
||||
true
|
||||
end
|
||||
|
||||
def send_notification
|
||||
if notify? && (Setting.notified_events.include?('issue_updated') ||
|
||||
(Setting.notified_events.include?('issue_note_added') && notes.present?) ||
|
||||
(Setting.notified_events.include?('issue_status_updated') && new_status.present?) ||
|
||||
(Setting.notified_events.include?('issue_assigned_to_updated') && detail_for_attribute('assigned_to_id').present?) ||
|
||||
(Setting.notified_events.include?('issue_priority_updated') && new_value_for('priority_id').present?)
|
||||
)
|
||||
Mailer.deliver_issue_edit(self)
|
||||
end
|
||||
end
|
||||
end
|
50
app/models/journal_detail.rb
Normal file
50
app/models/journal_detail.rb
Normal file
|
@ -0,0 +1,50 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
class JournalDetail < ActiveRecord::Base
|
||||
belongs_to :journal
|
||||
attr_protected :id
|
||||
|
||||
def custom_field
|
||||
if property == 'cf'
|
||||
@custom_field ||= CustomField.find_by_id(prop_key)
|
||||
end
|
||||
end
|
||||
|
||||
def value=(arg)
|
||||
write_attribute :value, normalize(arg)
|
||||
end
|
||||
|
||||
def old_value=(arg)
|
||||
write_attribute :old_value, normalize(arg)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def normalize(v)
|
||||
case v
|
||||
when true
|
||||
"1"
|
||||
when false
|
||||
"0"
|
||||
when Date
|
||||
v.strftime("%Y-%m-%d")
|
||||
else
|
||||
v
|
||||
end
|
||||
end
|
||||
end
|
591
app/models/mail_handler.rb
Executable file
591
app/models/mail_handler.rb
Executable file
|
@ -0,0 +1,591 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
class MailHandler < ActionMailer::Base
|
||||
include ActionView::Helpers::SanitizeHelper
|
||||
include Redmine::I18n
|
||||
|
||||
class UnauthorizedAction < StandardError; end
|
||||
class MissingInformation < StandardError; end
|
||||
|
||||
attr_reader :email, :user, :handler_options
|
||||
|
||||
def self.receive(raw_mail, options={})
|
||||
options = options.deep_dup
|
||||
|
||||
options[:issue] ||= {}
|
||||
|
||||
options[:allow_override] ||= []
|
||||
if options[:allow_override].is_a?(String)
|
||||
options[:allow_override] = options[:allow_override].split(',')
|
||||
end
|
||||
options[:allow_override].map! {|s| s.strip.downcase.gsub(/\s+/, '_')}
|
||||
# Project needs to be overridable if not specified
|
||||
options[:allow_override] << 'project' unless options[:issue].has_key?(:project)
|
||||
|
||||
options[:no_account_notice] = (options[:no_account_notice].to_s == '1')
|
||||
options[:no_notification] = (options[:no_notification].to_s == '1')
|
||||
options[:no_permission_check] = (options[:no_permission_check].to_s == '1')
|
||||
|
||||
raw_mail.force_encoding('ASCII-8BIT')
|
||||
|
||||
ActiveSupport::Notifications.instrument("receive.action_mailer") do |payload|
|
||||
mail = Mail.new(raw_mail)
|
||||
set_payload_for_mail(payload, mail)
|
||||
new.receive(mail, options)
|
||||
end
|
||||
end
|
||||
|
||||
# Receives an email and rescues any exception
|
||||
def self.safe_receive(*args)
|
||||
receive(*args)
|
||||
rescue Exception => e
|
||||
logger.error "MailHandler: an unexpected error occurred when receiving email: #{e.message}" if logger
|
||||
return false
|
||||
end
|
||||
|
||||
# Extracts MailHandler options from environment variables
|
||||
# Use when receiving emails with rake tasks
|
||||
def self.extract_options_from_env(env)
|
||||
options = {:issue => {}}
|
||||
%w(project status tracker category priority assigned_to fixed_version).each do |option|
|
||||
options[:issue][option.to_sym] = env[option] if env[option]
|
||||
end
|
||||
%w(allow_override unknown_user no_permission_check no_account_notice default_group project_from_subaddress).each do |option|
|
||||
options[option.to_sym] = env[option] if env[option]
|
||||
end
|
||||
if env['private']
|
||||
options[:issue][:is_private] = '1'
|
||||
end
|
||||
options
|
||||
end
|
||||
|
||||
def logger
|
||||
Rails.logger
|
||||
end
|
||||
|
||||
cattr_accessor :ignored_emails_headers
|
||||
self.ignored_emails_headers = {
|
||||
'Auto-Submitted' => /\Aauto-(replied|generated)/,
|
||||
'X-Autoreply' => 'yes'
|
||||
}
|
||||
|
||||
# Processes incoming emails
|
||||
# Returns the created object (eg. an issue, a message) or false
|
||||
def receive(email, options={})
|
||||
@email = email
|
||||
@handler_options = options
|
||||
sender_email = email.from.to_a.first.to_s.strip
|
||||
# Ignore emails received from the application emission address to avoid hell cycles
|
||||
if sender_email.casecmp(Setting.mail_from.to_s.strip) == 0
|
||||
if logger
|
||||
logger.info "MailHandler: ignoring email from Redmine emission address [#{sender_email}]"
|
||||
end
|
||||
return false
|
||||
end
|
||||
# Ignore auto generated emails
|
||||
self.class.ignored_emails_headers.each do |key, ignored_value|
|
||||
value = email.header[key]
|
||||
if value
|
||||
value = value.to_s.downcase
|
||||
if (ignored_value.is_a?(Regexp) && value.match(ignored_value)) || value == ignored_value
|
||||
if logger
|
||||
logger.info "MailHandler: ignoring email with #{key}:#{value} header"
|
||||
end
|
||||
return false
|
||||
end
|
||||
end
|
||||
end
|
||||
@user = User.find_by_mail(sender_email) if sender_email.present?
|
||||
if @user && !@user.active?
|
||||
if logger
|
||||
logger.info "MailHandler: ignoring email from non-active user [#{@user.login}]"
|
||||
end
|
||||
return false
|
||||
end
|
||||
if @user.nil?
|
||||
# Email was submitted by an unknown user
|
||||
case handler_options[:unknown_user]
|
||||
when 'accept'
|
||||
@user = User.anonymous
|
||||
when 'create'
|
||||
@user = create_user_from_email
|
||||
if @user
|
||||
if logger
|
||||
logger.info "MailHandler: [#{@user.login}] account created"
|
||||
end
|
||||
add_user_to_group(handler_options[:default_group])
|
||||
unless handler_options[:no_account_notice]
|
||||
::Mailer.account_information(@user, @user.password).deliver
|
||||
end
|
||||
else
|
||||
if logger
|
||||
logger.error "MailHandler: could not create account for [#{sender_email}]"
|
||||
end
|
||||
return false
|
||||
end
|
||||
else
|
||||
# Default behaviour, emails from unknown users are ignored
|
||||
if logger
|
||||
logger.info "MailHandler: ignoring email from unknown user [#{sender_email}]"
|
||||
end
|
||||
return false
|
||||
end
|
||||
end
|
||||
User.current = @user
|
||||
dispatch
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
MESSAGE_ID_RE = %r{^<?redmine\.([a-z0-9_]+)\-(\d+)\.\d+(\.[a-f0-9]+)?@}
|
||||
ISSUE_REPLY_SUBJECT_RE = %r{\[(?:[^\]]*\s+)?#(\d+)\]}
|
||||
MESSAGE_REPLY_SUBJECT_RE = %r{\[[^\]]*msg(\d+)\]}
|
||||
|
||||
def dispatch
|
||||
headers = [email.in_reply_to, email.references].flatten.compact
|
||||
subject = email.subject.to_s
|
||||
if headers.detect {|h| h.to_s =~ MESSAGE_ID_RE}
|
||||
klass, object_id = $1, $2.to_i
|
||||
method_name = "receive_#{klass}_reply"
|
||||
if self.class.private_instance_methods.collect(&:to_s).include?(method_name)
|
||||
send method_name, object_id
|
||||
else
|
||||
# ignoring it
|
||||
end
|
||||
elsif m = subject.match(ISSUE_REPLY_SUBJECT_RE)
|
||||
receive_issue_reply(m[1].to_i)
|
||||
elsif m = subject.match(MESSAGE_REPLY_SUBJECT_RE)
|
||||
receive_message_reply(m[1].to_i)
|
||||
else
|
||||
dispatch_to_default
|
||||
end
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
# TODO: send a email to the user
|
||||
logger.error "MailHandler: #{e.message}" if logger
|
||||
false
|
||||
rescue MissingInformation => e
|
||||
logger.error "MailHandler: missing information from #{user}: #{e.message}" if logger
|
||||
false
|
||||
rescue UnauthorizedAction => e
|
||||
logger.error "MailHandler: unauthorized attempt from #{user}" if logger
|
||||
false
|
||||
end
|
||||
|
||||
def dispatch_to_default
|
||||
receive_issue
|
||||
end
|
||||
|
||||
# Creates a new issue
|
||||
def receive_issue
|
||||
project = target_project
|
||||
# check permission
|
||||
unless handler_options[:no_permission_check]
|
||||
raise UnauthorizedAction unless user.allowed_to?(:add_issues, project)
|
||||
end
|
||||
|
||||
issue = Issue.new(:author => user, :project => project)
|
||||
attributes = issue_attributes_from_keywords(issue)
|
||||
if handler_options[:no_permission_check]
|
||||
issue.tracker_id = attributes['tracker_id']
|
||||
if project
|
||||
issue.tracker_id ||= project.trackers.first.try(:id)
|
||||
end
|
||||
end
|
||||
issue.safe_attributes = attributes
|
||||
issue.safe_attributes = {'custom_field_values' => custom_field_values_from_keywords(issue)}
|
||||
issue.subject = cleaned_up_subject
|
||||
if issue.subject.blank?
|
||||
issue.subject = '(no subject)'
|
||||
end
|
||||
issue.description = cleaned_up_text_body
|
||||
issue.start_date ||= User.current.today if Setting.default_issue_start_date_to_creation_date?
|
||||
issue.is_private = (handler_options[:issue][:is_private] == '1')
|
||||
|
||||
# add To and Cc as watchers before saving so the watchers can reply to Redmine
|
||||
add_watchers(issue)
|
||||
issue.save!
|
||||
add_attachments(issue)
|
||||
logger.info "MailHandler: issue ##{issue.id} created by #{user}" if logger
|
||||
issue
|
||||
end
|
||||
|
||||
# Adds a note to an existing issue
|
||||
def receive_issue_reply(issue_id, from_journal=nil)
|
||||
issue = Issue.find_by_id(issue_id)
|
||||
return unless issue
|
||||
# check permission
|
||||
unless handler_options[:no_permission_check]
|
||||
unless user.allowed_to?(:add_issue_notes, issue.project) ||
|
||||
user.allowed_to?(:edit_issues, issue.project)
|
||||
raise UnauthorizedAction
|
||||
end
|
||||
end
|
||||
|
||||
# ignore CLI-supplied defaults for new issues
|
||||
handler_options[:issue].clear
|
||||
|
||||
journal = issue.init_journal(user)
|
||||
if from_journal && from_journal.private_notes?
|
||||
# If the received email was a reply to a private note, make the added note private
|
||||
issue.private_notes = true
|
||||
end
|
||||
issue.safe_attributes = issue_attributes_from_keywords(issue)
|
||||
issue.safe_attributes = {'custom_field_values' => custom_field_values_from_keywords(issue)}
|
||||
journal.notes = cleaned_up_text_body
|
||||
|
||||
# add To and Cc as watchers before saving so the watchers can reply to Redmine
|
||||
add_watchers(issue)
|
||||
add_attachments(issue)
|
||||
issue.save!
|
||||
if logger
|
||||
logger.info "MailHandler: issue ##{issue.id} updated by #{user}"
|
||||
end
|
||||
journal
|
||||
end
|
||||
|
||||
# Reply will be added to the issue
|
||||
def receive_journal_reply(journal_id)
|
||||
journal = Journal.find_by_id(journal_id)
|
||||
if journal && journal.journalized_type == 'Issue'
|
||||
receive_issue_reply(journal.journalized_id, journal)
|
||||
end
|
||||
end
|
||||
|
||||
# Receives a reply to a forum message
|
||||
def receive_message_reply(message_id)
|
||||
message = Message.find_by_id(message_id)
|
||||
if message
|
||||
message = message.root
|
||||
|
||||
unless handler_options[:no_permission_check]
|
||||
raise UnauthorizedAction unless user.allowed_to?(:add_messages, message.project)
|
||||
end
|
||||
|
||||
if !message.locked?
|
||||
reply = Message.new(:subject => cleaned_up_subject.gsub(%r{^.*msg\d+\]}, '').strip,
|
||||
:content => cleaned_up_text_body)
|
||||
reply.author = user
|
||||
reply.board = message.board
|
||||
message.children << reply
|
||||
add_attachments(reply)
|
||||
reply
|
||||
else
|
||||
if logger
|
||||
logger.info "MailHandler: ignoring reply from [#{sender_email}] to a locked topic"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def add_attachments(obj)
|
||||
if email.attachments && email.attachments.any?
|
||||
email.attachments.each do |attachment|
|
||||
next unless accept_attachment?(attachment)
|
||||
next unless attachment.body.decoded.size > 0
|
||||
obj.attachments << Attachment.create(:container => obj,
|
||||
:file => attachment.body.decoded,
|
||||
:filename => attachment.filename,
|
||||
:author => user,
|
||||
:content_type => attachment.mime_type)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Returns false if the +attachment+ of the incoming email should be ignored
|
||||
def accept_attachment?(attachment)
|
||||
@excluded ||= Setting.mail_handler_excluded_filenames.to_s.split(',').map(&:strip).reject(&:blank?)
|
||||
@excluded.each do |pattern|
|
||||
regexp = %r{\A#{Regexp.escape(pattern).gsub("\\*", ".*")}\z}i
|
||||
if attachment.filename.to_s =~ regexp
|
||||
logger.info "MailHandler: ignoring attachment #{attachment.filename} matching #{pattern}"
|
||||
return false
|
||||
end
|
||||
end
|
||||
true
|
||||
end
|
||||
|
||||
# Adds To and Cc as watchers of the given object if the sender has the
|
||||
# appropriate permission
|
||||
def add_watchers(obj)
|
||||
if handler_options[:no_permission_check] || user.allowed_to?("add_#{obj.class.name.underscore}_watchers".to_sym, obj.project)
|
||||
addresses = [email.to, email.cc].flatten.compact.uniq.collect {|a| a.strip.downcase}
|
||||
unless addresses.empty?
|
||||
users = User.active.having_mail(addresses).to_a
|
||||
users -= obj.watcher_users
|
||||
users.each do |u|
|
||||
obj.add_watcher(u)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def get_keyword(attr, options={})
|
||||
@keywords ||= {}
|
||||
if @keywords.has_key?(attr)
|
||||
@keywords[attr]
|
||||
else
|
||||
@keywords[attr] = begin
|
||||
override = options.key?(:override) ?
|
||||
options[:override] :
|
||||
(handler_options[:allow_override] & [attr.to_s.downcase.gsub(/\s+/, '_'), 'all']).present?
|
||||
|
||||
if override && (v = extract_keyword!(cleaned_up_text_body, attr, options[:format]))
|
||||
v
|
||||
elsif !handler_options[:issue][attr].blank?
|
||||
handler_options[:issue][attr]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Destructively extracts the value for +attr+ in +text+
|
||||
# Returns nil if no matching keyword found
|
||||
def extract_keyword!(text, attr, format=nil)
|
||||
keys = [attr.to_s.humanize]
|
||||
if attr.is_a?(Symbol)
|
||||
if user && user.language.present?
|
||||
keys << l("field_#{attr}", :default => '', :locale => user.language)
|
||||
end
|
||||
if Setting.default_language.present?
|
||||
keys << l("field_#{attr}", :default => '', :locale => Setting.default_language)
|
||||
end
|
||||
end
|
||||
keys.reject! {|k| k.blank?}
|
||||
keys.collect! {|k| Regexp.escape(k)}
|
||||
format ||= '.+'
|
||||
keyword = nil
|
||||
regexp = /^(#{keys.join('|')})[ \t]*:[ \t]*(#{format})\s*$/i
|
||||
if m = text.match(regexp)
|
||||
keyword = m[2].strip
|
||||
text.sub!(regexp, '')
|
||||
end
|
||||
keyword
|
||||
end
|
||||
|
||||
def get_project_from_receiver_addresses
|
||||
local, domain = handler_options[:project_from_subaddress].to_s.split("@")
|
||||
return nil unless local && domain
|
||||
local = Regexp.escape(local)
|
||||
|
||||
[:to, :cc, :bcc].each do |field|
|
||||
header = @email[field]
|
||||
next if header.blank? || header.field.blank? || !header.field.respond_to?(:addrs)
|
||||
header.field.addrs.each do |addr|
|
||||
if addr.domain.to_s.casecmp(domain)==0 && addr.local.to_s =~ /\A#{local}\+([^+]+)\z/
|
||||
if project = Project.find_by_identifier($1)
|
||||
return project
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
nil
|
||||
end
|
||||
|
||||
def target_project
|
||||
# TODO: other ways to specify project:
|
||||
# * parse the email To field
|
||||
# * specific project (eg. Setting.mail_handler_target_project)
|
||||
target = get_project_from_receiver_addresses
|
||||
target ||= Project.find_by_identifier(get_keyword(:project))
|
||||
if target.nil?
|
||||
# Invalid project keyword, use the project specified as the default one
|
||||
default_project = handler_options[:issue][:project]
|
||||
if default_project.present?
|
||||
target = Project.find_by_identifier(default_project)
|
||||
end
|
||||
end
|
||||
raise MissingInformation.new('Unable to determine target project') if target.nil?
|
||||
target
|
||||
end
|
||||
|
||||
# Returns a Hash of issue attributes extracted from keywords in the email body
|
||||
def issue_attributes_from_keywords(issue)
|
||||
attrs = {
|
||||
'tracker_id' => (k = get_keyword(:tracker)) && issue.project.trackers.named(k).first.try(:id),
|
||||
'status_id' => (k = get_keyword(:status)) && IssueStatus.named(k).first.try(:id),
|
||||
'priority_id' => (k = get_keyword(:priority)) && IssuePriority.named(k).first.try(:id),
|
||||
'category_id' => (k = get_keyword(:category)) && issue.project.issue_categories.named(k).first.try(:id),
|
||||
'assigned_to_id' => (k = get_keyword(:assigned_to)) && find_assignee_from_keyword(k, issue).try(:id),
|
||||
'fixed_version_id' => (k = get_keyword(:fixed_version)) && issue.project.shared_versions.named(k).first.try(:id),
|
||||
'start_date' => get_keyword(:start_date, :format => '\d{4}-\d{2}-\d{2}'),
|
||||
'due_date' => get_keyword(:due_date, :format => '\d{4}-\d{2}-\d{2}'),
|
||||
'estimated_hours' => get_keyword(:estimated_hours),
|
||||
'done_ratio' => get_keyword(:done_ratio, :format => '(\d|10)?0')
|
||||
}.delete_if {|k, v| v.blank? }
|
||||
|
||||
attrs
|
||||
end
|
||||
|
||||
# Returns a Hash of issue custom field values extracted from keywords in the email body
|
||||
def custom_field_values_from_keywords(customized)
|
||||
customized.custom_field_values.inject({}) do |h, v|
|
||||
if keyword = get_keyword(v.custom_field.name)
|
||||
h[v.custom_field.id.to_s] = v.custom_field.value_from_keyword(keyword, customized)
|
||||
end
|
||||
h
|
||||
end
|
||||
end
|
||||
|
||||
# Returns the text/plain part of the email
|
||||
# If not found (eg. HTML-only email), returns the body with tags removed
|
||||
def plain_text_body
|
||||
return @plain_text_body unless @plain_text_body.nil?
|
||||
|
||||
# check if we have any plain-text parts with content
|
||||
@plain_text_body = email_parts_to_text(email.all_parts.select {|p| p.mime_type == 'text/plain'}).presence
|
||||
|
||||
# if not, we try to parse the body from the HTML-parts
|
||||
@plain_text_body ||= email_parts_to_text(email.all_parts.select {|p| p.mime_type == 'text/html'}).presence
|
||||
|
||||
# If there is still no body found, and there are no mime-parts defined,
|
||||
# we use the whole raw mail body
|
||||
@plain_text_body ||= email_parts_to_text([email]).presence if email.all_parts.empty?
|
||||
|
||||
# As a fallback we return an empty plain text body (e.g. if we have only
|
||||
# empty text parts but a non-text attachment)
|
||||
@plain_text_body ||= ""
|
||||
end
|
||||
|
||||
def email_parts_to_text(parts)
|
||||
parts.reject! do |part|
|
||||
part.attachment?
|
||||
end
|
||||
|
||||
parts.map do |p|
|
||||
body_charset = Mail::RubyVer.respond_to?(:pick_encoding) ?
|
||||
Mail::RubyVer.pick_encoding(p.charset).to_s : p.charset
|
||||
|
||||
body = Redmine::CodesetUtil.to_utf8(p.body.decoded, body_charset)
|
||||
# convert html parts to text
|
||||
p.mime_type == 'text/html' ? self.class.html_body_to_text(body) : self.class.plain_text_body_to_text(body)
|
||||
end.join("\r\n")
|
||||
end
|
||||
|
||||
def cleaned_up_text_body
|
||||
@cleaned_up_text_body ||= cleanup_body(plain_text_body)
|
||||
end
|
||||
|
||||
def cleaned_up_subject
|
||||
subject = email.subject.to_s
|
||||
subject.strip[0,255]
|
||||
end
|
||||
|
||||
# Converts a HTML email body to text
|
||||
def self.html_body_to_text(html)
|
||||
Redmine::WikiFormatting.html_parser.to_text(html)
|
||||
end
|
||||
|
||||
# Converts a plain/text email body to text
|
||||
def self.plain_text_body_to_text(text)
|
||||
# Removes leading spaces that would cause the line to be rendered as
|
||||
# preformatted text with textile
|
||||
text.gsub(/^ +(?![*#])/, '')
|
||||
end
|
||||
|
||||
def self.assign_string_attribute_with_limit(object, attribute, value, limit=nil)
|
||||
limit ||= object.class.columns_hash[attribute.to_s].limit || 255
|
||||
value = value.to_s.slice(0, limit)
|
||||
object.send("#{attribute}=", value)
|
||||
end
|
||||
|
||||
# Returns a User from an email address and a full name
|
||||
def self.new_user_from_attributes(email_address, fullname=nil)
|
||||
user = User.new
|
||||
|
||||
# Truncating the email address would result in an invalid format
|
||||
user.mail = email_address
|
||||
assign_string_attribute_with_limit(user, 'login', email_address, User::LOGIN_LENGTH_LIMIT)
|
||||
|
||||
names = fullname.blank? ? email_address.gsub(/@.*$/, '').split('.') : fullname.split
|
||||
assign_string_attribute_with_limit(user, 'firstname', names.shift, 30)
|
||||
assign_string_attribute_with_limit(user, 'lastname', names.join(' '), 30)
|
||||
user.lastname = '-' if user.lastname.blank?
|
||||
user.language = Setting.default_language
|
||||
user.generate_password = true
|
||||
user.mail_notification = 'only_my_events'
|
||||
|
||||
unless user.valid?
|
||||
user.login = "user#{Redmine::Utils.random_hex(6)}" unless user.errors[:login].blank?
|
||||
user.firstname = "-" unless user.errors[:firstname].blank?
|
||||
(puts user.errors[:lastname];user.lastname = "-") unless user.errors[:lastname].blank?
|
||||
end
|
||||
|
||||
user
|
||||
end
|
||||
|
||||
# Creates a User for the +email+ sender
|
||||
# Returns the user or nil if it could not be created
|
||||
def create_user_from_email
|
||||
from = email.header['from'].to_s
|
||||
addr, name = from, nil
|
||||
if m = from.match(/^"?(.+?)"?\s+<(.+@.+)>$/)
|
||||
addr, name = m[2], m[1]
|
||||
end
|
||||
if addr.present?
|
||||
user = self.class.new_user_from_attributes(addr, name)
|
||||
if handler_options[:no_notification]
|
||||
user.mail_notification = 'none'
|
||||
end
|
||||
if user.save
|
||||
user
|
||||
else
|
||||
logger.error "MailHandler: failed to create User: #{user.errors.full_messages}" if logger
|
||||
nil
|
||||
end
|
||||
else
|
||||
logger.error "MailHandler: failed to create User: no FROM address found" if logger
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
# Adds the newly created user to default group
|
||||
def add_user_to_group(default_group)
|
||||
if default_group.present?
|
||||
default_group.split(',').each do |group_name|
|
||||
if group = Group.named(group_name).first
|
||||
group.users << @user
|
||||
elsif logger
|
||||
logger.warn "MailHandler: could not add user to [#{group_name}], group not found"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Removes the email body of text after the truncation configurations.
|
||||
def cleanup_body(body)
|
||||
delimiters = Setting.mail_handler_body_delimiters.to_s.split(/[\r\n]+/).reject(&:blank?)
|
||||
|
||||
if Setting.mail_handler_enable_regex_delimiters?
|
||||
begin
|
||||
delimiters = delimiters.map {|s| Regexp.new(s)}
|
||||
rescue RegexpError => e
|
||||
logger.error "MailHandler: invalid regexp delimiter found in mail_handler_body_delimiters setting (#{e.message})" if logger
|
||||
end
|
||||
end
|
||||
|
||||
unless delimiters.empty?
|
||||
regex = Regexp.new("^[> ]*(#{ Regexp.union(delimiters) })[[:blank:]]*[\r\n].*", Regexp::MULTILINE)
|
||||
body = body.gsub(regex, '')
|
||||
end
|
||||
body.strip
|
||||
end
|
||||
|
||||
def find_assignee_from_keyword(keyword, issue)
|
||||
Principal.detect_by_keyword(issue.assignable_users, keyword)
|
||||
end
|
||||
end
|
588
app/models/mailer.rb
Normal file
588
app/models/mailer.rb
Normal file
|
@ -0,0 +1,588 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
require 'roadie'
|
||||
|
||||
class Mailer < ActionMailer::Base
|
||||
layout 'mailer'
|
||||
helper :application
|
||||
helper :issues
|
||||
helper :custom_fields
|
||||
|
||||
include Redmine::I18n
|
||||
include Roadie::Rails::Automatic
|
||||
|
||||
def self.default_url_options
|
||||
options = {:protocol => Setting.protocol}
|
||||
if Setting.host_name.to_s =~ /\A(https?\:\/\/)?(.+?)(\:(\d+))?(\/.+)?\z/i
|
||||
host, port, prefix = $2, $4, $5
|
||||
options.merge!({
|
||||
:host => host, :port => port, :script_name => prefix
|
||||
})
|
||||
else
|
||||
options[:host] = Setting.host_name
|
||||
end
|
||||
options
|
||||
end
|
||||
|
||||
# Builds a mail for notifying to_users and cc_users about a new issue
|
||||
def issue_add(issue, to_users, cc_users)
|
||||
redmine_headers 'Project' => issue.project.identifier,
|
||||
'Issue-Id' => issue.id,
|
||||
'Issue-Author' => issue.author.login
|
||||
redmine_headers 'Issue-Assignee' => issue.assigned_to.login if issue.assigned_to
|
||||
message_id issue
|
||||
references issue
|
||||
@author = issue.author
|
||||
@issue = issue
|
||||
@users = to_users + cc_users
|
||||
@issue_url = url_for(:controller => 'issues', :action => 'show', :id => issue)
|
||||
mail :to => to_users,
|
||||
:cc => cc_users,
|
||||
:subject => "[#{issue.project.name} - #{issue.tracker.name} ##{issue.id}] (#{issue.status.name}) #{issue.subject}"
|
||||
end
|
||||
|
||||
# Notifies users about a new issue
|
||||
def self.deliver_issue_add(issue)
|
||||
to = issue.notified_users
|
||||
cc = issue.notified_watchers - to
|
||||
issue.each_notification(to + cc) do |users|
|
||||
issue_add(issue, to & users, cc & users).deliver
|
||||
end
|
||||
end
|
||||
|
||||
# Builds a mail for notifying to_users and cc_users about an issue update
|
||||
def issue_edit(journal, to_users, cc_users)
|
||||
issue = journal.journalized
|
||||
redmine_headers 'Project' => issue.project.identifier,
|
||||
'Issue-Id' => issue.id,
|
||||
'Issue-Author' => issue.author.login
|
||||
redmine_headers 'Issue-Assignee' => issue.assigned_to.login if issue.assigned_to
|
||||
message_id journal
|
||||
references issue
|
||||
@author = journal.user
|
||||
s = "[#{issue.project.name} - #{issue.tracker.name} ##{issue.id}] "
|
||||
s << "(#{issue.status.name}) " if journal.new_value_for('status_id')
|
||||
s << issue.subject
|
||||
@issue = issue
|
||||
@users = to_users + cc_users
|
||||
@journal = journal
|
||||
@journal_details = journal.visible_details(@users.first)
|
||||
@issue_url = url_for(:controller => 'issues', :action => 'show', :id => issue, :anchor => "change-#{journal.id}")
|
||||
mail :to => to_users,
|
||||
:cc => cc_users,
|
||||
:subject => s
|
||||
end
|
||||
|
||||
# Notifies users about an issue update
|
||||
def self.deliver_issue_edit(journal)
|
||||
issue = journal.journalized.reload
|
||||
to = journal.notified_users
|
||||
cc = journal.notified_watchers - to
|
||||
journal.each_notification(to + cc) do |users|
|
||||
issue.each_notification(users) do |users2|
|
||||
issue_edit(journal, to & users2, cc & users2).deliver
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def reminder(user, issues, days)
|
||||
set_language_if_valid user.language
|
||||
@issues = issues
|
||||
@days = days
|
||||
@issues_url = url_for(:controller => 'issues', :action => 'index',
|
||||
:set_filter => 1, :assigned_to_id => user.id,
|
||||
:sort => 'due_date:asc')
|
||||
mail :to => user,
|
||||
:subject => l(:mail_subject_reminder, :count => issues.size, :days => days)
|
||||
end
|
||||
|
||||
# Builds a Mail::Message object used to email users belonging to the added document's project.
|
||||
#
|
||||
# Example:
|
||||
# document_added(document) => Mail::Message object
|
||||
# Mailer.document_added(document).deliver => sends an email to the document's project recipients
|
||||
def document_added(document)
|
||||
redmine_headers 'Project' => document.project.identifier
|
||||
@author = User.current
|
||||
@document = document
|
||||
@document_url = url_for(:controller => 'documents', :action => 'show', :id => document)
|
||||
mail :to => document.notified_users,
|
||||
:subject => "[#{document.project.name}] #{l(:label_document_new)}: #{document.title}"
|
||||
end
|
||||
|
||||
# Builds a Mail::Message object used to email recipients of a project when an attachements are added.
|
||||
#
|
||||
# Example:
|
||||
# attachments_added(attachments) => Mail::Message object
|
||||
# Mailer.attachments_added(attachments).deliver => sends an email to the project's recipients
|
||||
def attachments_added(attachments)
|
||||
container = attachments.first.container
|
||||
added_to = ''
|
||||
added_to_url = ''
|
||||
@author = attachments.first.author
|
||||
case container.class.name
|
||||
when 'Project'
|
||||
added_to_url = url_for(:controller => 'files', :action => 'index', :project_id => container)
|
||||
added_to = "#{l(:label_project)}: #{container}"
|
||||
recipients = container.project.notified_users.select {|user| user.allowed_to?(:view_files, container.project)}
|
||||
when 'Version'
|
||||
added_to_url = url_for(:controller => 'files', :action => 'index', :project_id => container.project)
|
||||
added_to = "#{l(:label_version)}: #{container.name}"
|
||||
recipients = container.project.notified_users.select {|user| user.allowed_to?(:view_files, container.project)}
|
||||
when 'Document'
|
||||
added_to_url = url_for(:controller => 'documents', :action => 'show', :id => container.id)
|
||||
added_to = "#{l(:label_document)}: #{container.title}"
|
||||
recipients = container.notified_users
|
||||
end
|
||||
redmine_headers 'Project' => container.project.identifier
|
||||
@attachments = attachments
|
||||
@added_to = added_to
|
||||
@added_to_url = added_to_url
|
||||
mail :to => recipients,
|
||||
:subject => "[#{container.project.name}] #{l(:label_attachment_new)}"
|
||||
end
|
||||
|
||||
# Builds a Mail::Message object used to email recipients of a news' project when a news item is added.
|
||||
#
|
||||
# Example:
|
||||
# news_added(news) => Mail::Message object
|
||||
# Mailer.news_added(news).deliver => sends an email to the news' project recipients
|
||||
def news_added(news)
|
||||
redmine_headers 'Project' => news.project.identifier
|
||||
@author = news.author
|
||||
message_id news
|
||||
references news
|
||||
@news = news
|
||||
@news_url = url_for(:controller => 'news', :action => 'show', :id => news)
|
||||
mail :to => news.notified_users,
|
||||
:cc => news.notified_watchers_for_added_news,
|
||||
:subject => "[#{news.project.name}] #{l(:label_news)}: #{news.title}"
|
||||
end
|
||||
|
||||
# Builds a Mail::Message object used to email recipients of a news' project when a news comment is added.
|
||||
#
|
||||
# Example:
|
||||
# news_comment_added(comment) => Mail::Message object
|
||||
# Mailer.news_comment_added(comment) => sends an email to the news' project recipients
|
||||
def news_comment_added(comment)
|
||||
news = comment.commented
|
||||
redmine_headers 'Project' => news.project.identifier
|
||||
@author = comment.author
|
||||
message_id comment
|
||||
references news
|
||||
@news = news
|
||||
@comment = comment
|
||||
@news_url = url_for(:controller => 'news', :action => 'show', :id => news)
|
||||
mail :to => news.notified_users,
|
||||
:cc => news.notified_watchers,
|
||||
:subject => "Re: [#{news.project.name}] #{l(:label_news)}: #{news.title}"
|
||||
end
|
||||
|
||||
# Builds a Mail::Message object used to email the recipients of the specified message that was posted.
|
||||
#
|
||||
# Example:
|
||||
# message_posted(message) => Mail::Message object
|
||||
# Mailer.message_posted(message).deliver => sends an email to the recipients
|
||||
def message_posted(message)
|
||||
redmine_headers 'Project' => message.project.identifier,
|
||||
'Topic-Id' => (message.parent_id || message.id)
|
||||
@author = message.author
|
||||
message_id message
|
||||
references message.root
|
||||
recipients = message.notified_users
|
||||
cc = ((message.root.notified_watchers + message.board.notified_watchers).uniq - recipients)
|
||||
@message = message
|
||||
@message_url = url_for(message.event_url)
|
||||
mail :to => recipients,
|
||||
:cc => cc,
|
||||
:subject => "[#{message.board.project.name} - #{message.board.name} - msg#{message.root.id}] #{message.subject}"
|
||||
end
|
||||
|
||||
# Builds a Mail::Message object used to email the recipients of a project of the specified wiki content was added.
|
||||
#
|
||||
# Example:
|
||||
# wiki_content_added(wiki_content) => Mail::Message object
|
||||
# Mailer.wiki_content_added(wiki_content).deliver => sends an email to the project's recipients
|
||||
def wiki_content_added(wiki_content)
|
||||
redmine_headers 'Project' => wiki_content.project.identifier,
|
||||
'Wiki-Page-Id' => wiki_content.page.id
|
||||
@author = wiki_content.author
|
||||
message_id wiki_content
|
||||
recipients = wiki_content.notified_users
|
||||
cc = wiki_content.page.wiki.notified_watchers - recipients
|
||||
@wiki_content = wiki_content
|
||||
@wiki_content_url = url_for(:controller => 'wiki', :action => 'show',
|
||||
:project_id => wiki_content.project,
|
||||
:id => wiki_content.page.title)
|
||||
mail :to => recipients,
|
||||
:cc => cc,
|
||||
:subject => "[#{wiki_content.project.name}] #{l(:mail_subject_wiki_content_added, :id => wiki_content.page.pretty_title)}"
|
||||
end
|
||||
|
||||
# Builds a Mail::Message object used to email the recipients of a project of the specified wiki content was updated.
|
||||
#
|
||||
# Example:
|
||||
# wiki_content_updated(wiki_content) => Mail::Message object
|
||||
# Mailer.wiki_content_updated(wiki_content).deliver => sends an email to the project's recipients
|
||||
def wiki_content_updated(wiki_content)
|
||||
redmine_headers 'Project' => wiki_content.project.identifier,
|
||||
'Wiki-Page-Id' => wiki_content.page.id
|
||||
@author = wiki_content.author
|
||||
message_id wiki_content
|
||||
recipients = wiki_content.notified_users
|
||||
cc = wiki_content.page.wiki.notified_watchers + wiki_content.page.notified_watchers - recipients
|
||||
@wiki_content = wiki_content
|
||||
@wiki_content_url = url_for(:controller => 'wiki', :action => 'show',
|
||||
:project_id => wiki_content.project,
|
||||
:id => wiki_content.page.title)
|
||||
@wiki_diff_url = url_for(:controller => 'wiki', :action => 'diff',
|
||||
:project_id => wiki_content.project, :id => wiki_content.page.title,
|
||||
:version => wiki_content.version)
|
||||
mail :to => recipients,
|
||||
:cc => cc,
|
||||
:subject => "[#{wiki_content.project.name}] #{l(:mail_subject_wiki_content_updated, :id => wiki_content.page.pretty_title)}"
|
||||
end
|
||||
|
||||
# Builds a Mail::Message object used to email the specified user their account information.
|
||||
#
|
||||
# Example:
|
||||
# account_information(user, password) => Mail::Message object
|
||||
# Mailer.account_information(user, password).deliver => sends account information to the user
|
||||
def account_information(user, password)
|
||||
set_language_if_valid user.language
|
||||
@user = user
|
||||
@password = password
|
||||
@login_url = url_for(:controller => 'account', :action => 'login')
|
||||
mail :to => user.mail,
|
||||
:subject => l(:mail_subject_register, Setting.app_title)
|
||||
end
|
||||
|
||||
# Builds a Mail::Message object used to email all active administrators of an account activation request.
|
||||
#
|
||||
# Example:
|
||||
# account_activation_request(user) => Mail::Message object
|
||||
# Mailer.account_activation_request(user).deliver => sends an email to all active administrators
|
||||
def account_activation_request(user)
|
||||
# Send the email to all active administrators
|
||||
recipients = User.active.where(:admin => true)
|
||||
@user = user
|
||||
@url = url_for(:controller => 'users', :action => 'index',
|
||||
:status => User::STATUS_REGISTERED,
|
||||
:sort_key => 'created_on', :sort_order => 'desc')
|
||||
mail :to => recipients,
|
||||
:subject => l(:mail_subject_account_activation_request, Setting.app_title)
|
||||
end
|
||||
|
||||
# Builds a Mail::Message object used to email the specified user that their account was activated by an administrator.
|
||||
#
|
||||
# Example:
|
||||
# account_activated(user) => Mail::Message object
|
||||
# Mailer.account_activated(user).deliver => sends an email to the registered user
|
||||
def account_activated(user)
|
||||
set_language_if_valid user.language
|
||||
@user = user
|
||||
@login_url = url_for(:controller => 'account', :action => 'login')
|
||||
mail :to => user.mail,
|
||||
:subject => l(:mail_subject_register, Setting.app_title)
|
||||
end
|
||||
|
||||
def lost_password(token, recipient=nil)
|
||||
set_language_if_valid(token.user.language)
|
||||
recipient ||= token.user.mail
|
||||
@token = token
|
||||
@url = url_for(:controller => 'account', :action => 'lost_password', :token => token.value)
|
||||
mail :to => recipient,
|
||||
:subject => l(:mail_subject_lost_password, Setting.app_title)
|
||||
end
|
||||
|
||||
# Notifies user that his password was updated
|
||||
def self.password_updated(user)
|
||||
# Don't send a notification to the dummy email address when changing the password
|
||||
# of the default admin account which is required after the first login
|
||||
# TODO: maybe not the best way to handle this
|
||||
return if user.admin? && user.login == 'admin' && user.mail == 'admin@example.net'
|
||||
|
||||
security_notification(user,
|
||||
message: :mail_body_password_updated,
|
||||
title: :button_change_password,
|
||||
url: {controller: 'my', action: 'password'}
|
||||
).deliver
|
||||
end
|
||||
|
||||
def register(token)
|
||||
set_language_if_valid(token.user.language)
|
||||
@token = token
|
||||
@url = url_for(:controller => 'account', :action => 'activate', :token => token.value)
|
||||
mail :to => token.user.mail,
|
||||
:subject => l(:mail_subject_register, Setting.app_title)
|
||||
end
|
||||
|
||||
def security_notification(recipients, options={})
|
||||
redmine_headers 'Sender' => User.current.login
|
||||
@user = Array(recipients).detect{|r| r.is_a? User }
|
||||
set_language_if_valid(@user.try :language)
|
||||
@message = l(options[:message],
|
||||
field: (options[:field] && l(options[:field])),
|
||||
value: options[:value]
|
||||
)
|
||||
@title = options[:title] && l(options[:title])
|
||||
@url = options[:url] && (options[:url].is_a?(Hash) ? url_for(options[:url]) : options[:url])
|
||||
mail :to => recipients,
|
||||
:subject => "[#{Setting.app_title}] #{l(:mail_subject_security_notification)}"
|
||||
end
|
||||
|
||||
def settings_updated(recipients, changes)
|
||||
redmine_headers 'Sender' => User.current.login
|
||||
@changes = changes
|
||||
@url = url_for(controller: 'settings', action: 'index')
|
||||
mail :to => recipients,
|
||||
:subject => "[#{Setting.app_title}] #{l(:mail_subject_security_notification)}"
|
||||
end
|
||||
|
||||
# Notifies admins about settings changes
|
||||
def self.security_settings_updated(changes)
|
||||
return unless changes.present?
|
||||
|
||||
users = User.active.where(admin: true).to_a
|
||||
settings_updated(users, changes).deliver
|
||||
end
|
||||
|
||||
def test_email(user)
|
||||
set_language_if_valid(user.language)
|
||||
@url = url_for(:controller => 'welcome')
|
||||
mail :to => user.mail,
|
||||
:subject => 'Redmine test'
|
||||
end
|
||||
|
||||
# Sends reminders to issue assignees
|
||||
# Available options:
|
||||
# * :days => how many days in the future to remind about (defaults to 7)
|
||||
# * :tracker => id of tracker for filtering issues (defaults to all trackers)
|
||||
# * :project => id or identifier of project to process (defaults to all projects)
|
||||
# * :users => array of user/group ids who should be reminded
|
||||
# * :version => name of target version for filtering issues (defaults to none)
|
||||
def self.reminders(options={})
|
||||
days = options[:days] || 7
|
||||
project = options[:project] ? Project.find(options[:project]) : nil
|
||||
tracker = options[:tracker] ? Tracker.find(options[:tracker]) : nil
|
||||
target_version_id = options[:version] ? Version.named(options[:version]).pluck(:id) : nil
|
||||
if options[:version] && target_version_id.blank?
|
||||
raise ActiveRecord::RecordNotFound.new("Couldn't find Version with named #{options[:version]}")
|
||||
end
|
||||
user_ids = options[:users]
|
||||
|
||||
scope = Issue.open.where("#{Issue.table_name}.assigned_to_id IS NOT NULL" +
|
||||
" AND #{Project.table_name}.status = #{Project::STATUS_ACTIVE}" +
|
||||
" AND #{Issue.table_name}.due_date <= ?", days.day.from_now.to_date
|
||||
)
|
||||
scope = scope.where(:assigned_to_id => user_ids) if user_ids.present?
|
||||
scope = scope.where(:project_id => project.id) if project
|
||||
scope = scope.where(:fixed_version_id => target_version_id) if target_version_id.present?
|
||||
scope = scope.where(:tracker_id => tracker.id) if tracker
|
||||
issues_by_assignee = scope.includes(:status, :assigned_to, :project, :tracker).
|
||||
group_by(&:assigned_to)
|
||||
issues_by_assignee.keys.each do |assignee|
|
||||
if assignee.is_a?(Group)
|
||||
assignee.users.each do |user|
|
||||
issues_by_assignee[user] ||= []
|
||||
issues_by_assignee[user] += issues_by_assignee[assignee]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
issues_by_assignee.each do |assignee, issues|
|
||||
if assignee.is_a?(User) && assignee.active? && issues.present?
|
||||
visible_issues = issues.select {|i| i.visible?(assignee)}
|
||||
reminder(assignee, visible_issues, days).deliver if visible_issues.present?
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Activates/desactivates email deliveries during +block+
|
||||
def self.with_deliveries(enabled = true, &block)
|
||||
was_enabled = ActionMailer::Base.perform_deliveries
|
||||
ActionMailer::Base.perform_deliveries = !!enabled
|
||||
yield
|
||||
ensure
|
||||
ActionMailer::Base.perform_deliveries = was_enabled
|
||||
end
|
||||
|
||||
# Sends emails synchronously in the given block
|
||||
def self.with_synched_deliveries(&block)
|
||||
saved_method = ActionMailer::Base.delivery_method
|
||||
if m = saved_method.to_s.match(%r{^async_(.+)$})
|
||||
synched_method = m[1]
|
||||
ActionMailer::Base.delivery_method = synched_method.to_sym
|
||||
ActionMailer::Base.send "#{synched_method}_settings=", ActionMailer::Base.send("async_#{synched_method}_settings")
|
||||
end
|
||||
yield
|
||||
ensure
|
||||
ActionMailer::Base.delivery_method = saved_method
|
||||
end
|
||||
|
||||
def mail(headers={}, &block)
|
||||
headers.reverse_merge! 'X-Mailer' => 'Redmine',
|
||||
'X-Redmine-Host' => Setting.host_name,
|
||||
'X-Redmine-Site' => Setting.app_title,
|
||||
'X-Auto-Response-Suppress' => 'All',
|
||||
'Auto-Submitted' => 'auto-generated',
|
||||
'From' => Setting.mail_from,
|
||||
'List-Id' => "<#{Setting.mail_from.to_s.gsub('@', '.')}>"
|
||||
|
||||
# Replaces users with their email addresses
|
||||
[:to, :cc, :bcc].each do |key|
|
||||
if headers[key].present?
|
||||
headers[key] = self.class.email_addresses(headers[key])
|
||||
end
|
||||
end
|
||||
|
||||
# Removes the author from the recipients and cc
|
||||
# if the author does not want to receive notifications
|
||||
# about what the author do
|
||||
if @author && @author.logged? && @author.pref.no_self_notified
|
||||
addresses = @author.mails
|
||||
headers[:to] -= addresses if headers[:to].is_a?(Array)
|
||||
headers[:cc] -= addresses if headers[:cc].is_a?(Array)
|
||||
end
|
||||
|
||||
if @author && @author.logged?
|
||||
redmine_headers 'Sender' => @author.login
|
||||
end
|
||||
|
||||
# Blind carbon copy recipients
|
||||
if Setting.bcc_recipients?
|
||||
headers[:bcc] = [headers[:to], headers[:cc]].flatten.uniq.reject(&:blank?)
|
||||
headers[:to] = nil
|
||||
headers[:cc] = nil
|
||||
end
|
||||
|
||||
if @message_id_object
|
||||
headers[:message_id] = "<#{self.class.message_id_for(@message_id_object)}>"
|
||||
end
|
||||
if @references_objects
|
||||
headers[:references] = @references_objects.collect {|o| "<#{self.class.references_for(o)}>"}.join(' ')
|
||||
end
|
||||
|
||||
m = if block_given?
|
||||
super headers, &block
|
||||
else
|
||||
super headers do |format|
|
||||
format.text
|
||||
format.html unless Setting.plain_text_mail?
|
||||
end
|
||||
end
|
||||
set_language_if_valid @initial_language
|
||||
|
||||
m
|
||||
end
|
||||
|
||||
def initialize(*args)
|
||||
@initial_language = current_language
|
||||
set_language_if_valid Setting.default_language
|
||||
super
|
||||
end
|
||||
|
||||
def self.deliver_mail(mail)
|
||||
return false if mail.to.blank? && mail.cc.blank? && mail.bcc.blank?
|
||||
begin
|
||||
# Log errors when raise_delivery_errors is set to false, Rails does not
|
||||
mail.raise_delivery_errors = true
|
||||
super
|
||||
rescue Exception => e
|
||||
if ActionMailer::Base.raise_delivery_errors
|
||||
raise e
|
||||
else
|
||||
Rails.logger.error "Email delivery error: #{e.message}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def self.method_missing(method, *args, &block)
|
||||
if m = method.to_s.match(%r{^deliver_(.+)$})
|
||||
ActiveSupport::Deprecation.warn "Mailer.deliver_#{m[1]}(*args) is deprecated. Use Mailer.#{m[1]}(*args).deliver instead."
|
||||
send(m[1], *args).deliver
|
||||
else
|
||||
super
|
||||
end
|
||||
end
|
||||
|
||||
# Returns an array of email addresses to notify by
|
||||
# replacing users in arg with their notified email addresses
|
||||
#
|
||||
# Example:
|
||||
# Mailer.email_addresses(users)
|
||||
# => ["foo@example.net", "bar@example.net"]
|
||||
def self.email_addresses(arg)
|
||||
arr = Array.wrap(arg)
|
||||
mails = arr.reject {|a| a.is_a? Principal}
|
||||
users = arr - mails
|
||||
if users.any?
|
||||
mails += EmailAddress.
|
||||
where(:user_id => users.map(&:id)).
|
||||
where("is_default = ? OR notify = ?", true, true).
|
||||
pluck(:address)
|
||||
end
|
||||
mails
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Appends a Redmine header field (name is prepended with 'X-Redmine-')
|
||||
def redmine_headers(h)
|
||||
h.each { |k,v| headers["X-Redmine-#{k}"] = v.to_s }
|
||||
end
|
||||
|
||||
def self.token_for(object, rand=true)
|
||||
timestamp = object.send(object.respond_to?(:created_on) ? :created_on : :updated_on)
|
||||
hash = [
|
||||
"redmine",
|
||||
"#{object.class.name.demodulize.underscore}-#{object.id}",
|
||||
timestamp.strftime("%Y%m%d%H%M%S")
|
||||
]
|
||||
if rand
|
||||
hash << Redmine::Utils.random_hex(8)
|
||||
end
|
||||
host = Setting.mail_from.to_s.strip.gsub(%r{^.*@|>}, '')
|
||||
host = "#{::Socket.gethostname}.redmine" if host.empty?
|
||||
"#{hash.join('.')}@#{host}"
|
||||
end
|
||||
|
||||
# Returns a Message-Id for the given object
|
||||
def self.message_id_for(object)
|
||||
token_for(object, true)
|
||||
end
|
||||
|
||||
# Returns a uniq token for a given object referenced by all notifications
|
||||
# related to this object
|
||||
def self.references_for(object)
|
||||
token_for(object, false)
|
||||
end
|
||||
|
||||
def message_id(object)
|
||||
@message_id_object = object
|
||||
end
|
||||
|
||||
def references(object)
|
||||
@references_objects ||= []
|
||||
@references_objects << object
|
||||
end
|
||||
|
||||
def mylogger
|
||||
Rails.logger
|
||||
end
|
||||
end
|
219
app/models/member.rb
Normal file
219
app/models/member.rb
Normal file
|
@ -0,0 +1,219 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
class Member < ActiveRecord::Base
|
||||
belongs_to :user
|
||||
belongs_to :principal, :foreign_key => 'user_id'
|
||||
has_many :member_roles, :dependent => :destroy
|
||||
has_many :roles, lambda { distinct }, :through => :member_roles
|
||||
belongs_to :project
|
||||
|
||||
validates_presence_of :principal, :project
|
||||
validates_uniqueness_of :user_id, :scope => :project_id
|
||||
validate :validate_role
|
||||
attr_protected :id
|
||||
|
||||
before_destroy :set_issue_category_nil, :remove_from_project_default_assigned_to
|
||||
|
||||
scope :active, lambda { joins(:principal).where(:users => {:status => Principal::STATUS_ACTIVE})}
|
||||
|
||||
# Sort by first role and principal
|
||||
scope :sorted, lambda {
|
||||
includes(:member_roles, :roles, :principal).
|
||||
reorder("#{Role.table_name}.position").
|
||||
order(Principal.fields_for_order_statement)
|
||||
}
|
||||
scope :sorted_by_project, lambda {
|
||||
includes(:project).
|
||||
reorder("#{Project.table_name}.lft")
|
||||
}
|
||||
|
||||
alias :base_reload :reload
|
||||
def reload(*args)
|
||||
@managed_roles = nil
|
||||
base_reload(*args)
|
||||
end
|
||||
|
||||
def role
|
||||
end
|
||||
|
||||
def role=
|
||||
end
|
||||
|
||||
def name
|
||||
self.user.name
|
||||
end
|
||||
|
||||
alias :base_role_ids= :role_ids=
|
||||
def role_ids=(arg)
|
||||
ids = (arg || []).collect(&:to_i) - [0]
|
||||
# Keep inherited roles
|
||||
ids += member_roles.select {|mr| !mr.inherited_from.nil?}.collect(&:role_id)
|
||||
|
||||
new_role_ids = ids - role_ids
|
||||
# Add new roles
|
||||
new_role_ids.each {|id| member_roles << MemberRole.new(:role_id => id, :member => self) }
|
||||
# Remove roles (Rails' #role_ids= will not trigger MemberRole#on_destroy)
|
||||
member_roles_to_destroy = member_roles.select {|mr| !ids.include?(mr.role_id)}
|
||||
if member_roles_to_destroy.any?
|
||||
member_roles_to_destroy.each(&:destroy)
|
||||
end
|
||||
end
|
||||
|
||||
def <=>(member)
|
||||
a, b = roles.sort, member.roles.sort
|
||||
if a == b
|
||||
if principal
|
||||
principal <=> member.principal
|
||||
else
|
||||
1
|
||||
end
|
||||
elsif a.any?
|
||||
b.any? ? a <=> b : -1
|
||||
else
|
||||
1
|
||||
end
|
||||
end
|
||||
|
||||
# Set member role ids ignoring any change to roles that
|
||||
# user is not allowed to manage
|
||||
def set_editable_role_ids(ids, user=User.current)
|
||||
ids = (ids || []).collect(&:to_i) - [0]
|
||||
editable_role_ids = user.managed_roles(project).map(&:id)
|
||||
untouched_role_ids = self.role_ids - editable_role_ids
|
||||
touched_role_ids = ids & editable_role_ids
|
||||
self.role_ids = untouched_role_ids + touched_role_ids
|
||||
end
|
||||
|
||||
# Returns true if one of the member roles is inherited
|
||||
def any_inherited_role?
|
||||
member_roles.any? {|mr| mr.inherited_from}
|
||||
end
|
||||
|
||||
# Returns true if the member has the role and if it's inherited
|
||||
def has_inherited_role?(role)
|
||||
member_roles.any? {|mr| mr.role_id == role.id && mr.inherited_from.present?}
|
||||
end
|
||||
|
||||
# Returns true if the member's role is editable by user
|
||||
def role_editable?(role, user=User.current)
|
||||
if has_inherited_role?(role)
|
||||
false
|
||||
else
|
||||
user.managed_roles(project).include?(role)
|
||||
end
|
||||
end
|
||||
|
||||
# Returns true if the member is deletable by user
|
||||
def deletable?(user=User.current)
|
||||
if any_inherited_role?
|
||||
false
|
||||
else
|
||||
roles & user.managed_roles(project) == roles
|
||||
end
|
||||
end
|
||||
|
||||
# Destroys the member
|
||||
def destroy
|
||||
member_roles.reload.each(&:destroy_without_member_removal)
|
||||
super
|
||||
end
|
||||
|
||||
# Returns true if the member is user or is a group
|
||||
# that includes user
|
||||
def include?(user)
|
||||
if principal.is_a?(Group)
|
||||
!user.nil? && user.groups.include?(principal)
|
||||
else
|
||||
self.principal == user
|
||||
end
|
||||
end
|
||||
|
||||
def set_issue_category_nil
|
||||
if user_id && project_id
|
||||
# remove category based auto assignments for this member
|
||||
IssueCategory.where(["project_id = ? AND assigned_to_id = ?", project_id, user_id]).
|
||||
update_all("assigned_to_id = NULL")
|
||||
end
|
||||
end
|
||||
|
||||
def remove_from_project_default_assigned_to
|
||||
if user_id && project && project.default_assigned_to_id == user_id
|
||||
# remove project based auto assignments for this member
|
||||
project.update_column(:default_assigned_to_id, nil)
|
||||
end
|
||||
end
|
||||
|
||||
# Returns the roles that the member is allowed to manage
|
||||
# in the project the member belongs to
|
||||
def managed_roles
|
||||
@managed_roles ||= begin
|
||||
if principal.try(:admin?)
|
||||
Role.givable.to_a
|
||||
else
|
||||
members_management_roles = roles.select do |role|
|
||||
role.has_permission?(:manage_members)
|
||||
end
|
||||
if members_management_roles.empty?
|
||||
[]
|
||||
elsif members_management_roles.any?(&:all_roles_managed?)
|
||||
Role.givable.to_a
|
||||
else
|
||||
members_management_roles.map(&:managed_roles).reduce(&:|)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Creates memberships for principal with the attributes, or add the roles
|
||||
# if the membership already exists.
|
||||
# * project_ids : one or more project ids
|
||||
# * role_ids : ids of the roles to give to each membership
|
||||
#
|
||||
# Example:
|
||||
# Member.create_principal_memberships(user, :project_ids => [2, 5], :role_ids => [1, 3]
|
||||
def self.create_principal_memberships(principal, attributes)
|
||||
members = []
|
||||
if attributes
|
||||
project_ids = Array.wrap(attributes[:project_ids] || attributes[:project_id])
|
||||
role_ids = Array.wrap(attributes[:role_ids])
|
||||
project_ids.each do |project_id|
|
||||
member = Member.find_or_new(project_id, principal)
|
||||
member.role_ids |= role_ids
|
||||
member.save
|
||||
members << member
|
||||
end
|
||||
end
|
||||
members
|
||||
end
|
||||
|
||||
# Finds or initializes a Member for the given project and principal
|
||||
def self.find_or_new(project, principal)
|
||||
project_id = project.is_a?(Project) ? project.id : project
|
||||
principal_id = principal.is_a?(Principal) ? principal.id : principal
|
||||
|
||||
member = Member.find_by_project_id_and_user_id(project_id, principal_id)
|
||||
member ||= Member.new(:project_id => project_id, :user_id => principal_id)
|
||||
member
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def validate_role
|
||||
errors.add(:role, :empty) if member_roles.empty? && roles.empty?
|
||||
end
|
||||
end
|
77
app/models/member_role.rb
Normal file
77
app/models/member_role.rb
Normal file
|
@ -0,0 +1,77 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
class MemberRole < ActiveRecord::Base
|
||||
belongs_to :member
|
||||
belongs_to :role
|
||||
|
||||
after_destroy :remove_member_if_empty
|
||||
|
||||
after_create :add_role_to_group_users, :add_role_to_subprojects
|
||||
after_destroy :remove_inherited_roles
|
||||
|
||||
validates_presence_of :role
|
||||
validate :validate_role_member
|
||||
attr_protected :id
|
||||
|
||||
def validate_role_member
|
||||
errors.add :role_id, :invalid if role && !role.member?
|
||||
end
|
||||
|
||||
def inherited?
|
||||
!inherited_from.nil?
|
||||
end
|
||||
|
||||
# Destroys the MemberRole without destroying its Member if it doesn't have
|
||||
# any other roles
|
||||
def destroy_without_member_removal
|
||||
@member_removal = false
|
||||
destroy
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def remove_member_if_empty
|
||||
if @member_removal != false && member.roles.empty?
|
||||
member.destroy
|
||||
end
|
||||
end
|
||||
|
||||
def add_role_to_group_users
|
||||
if member.principal.is_a?(Group) && !inherited?
|
||||
member.principal.users.each do |user|
|
||||
user_member = Member.find_or_new(member.project_id, user.id)
|
||||
user_member.member_roles << MemberRole.new(:role => role, :inherited_from => id)
|
||||
user_member.save!
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def add_role_to_subprojects
|
||||
member.project.children.each do |subproject|
|
||||
if subproject.inherit_members?
|
||||
child_member = Member.find_or_new(subproject.id, member.user_id)
|
||||
child_member.member_roles << MemberRole.new(:role => role, :inherited_from => id)
|
||||
child_member.save!
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def remove_inherited_roles
|
||||
MemberRole.where(:inherited_from => id).destroy_all
|
||||
end
|
||||
end
|
121
app/models/message.rb
Normal file
121
app/models/message.rb
Normal file
|
@ -0,0 +1,121 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
class Message < ActiveRecord::Base
|
||||
include Redmine::SafeAttributes
|
||||
belongs_to :board
|
||||
belongs_to :author, :class_name => 'User'
|
||||
acts_as_tree :counter_cache => :replies_count, :order => "#{Message.table_name}.created_on ASC"
|
||||
acts_as_attachable
|
||||
belongs_to :last_reply, :class_name => 'Message'
|
||||
attr_protected :id
|
||||
|
||||
acts_as_searchable :columns => ['subject', 'content'],
|
||||
:preload => {:board => :project},
|
||||
:project_key => "#{Board.table_name}.project_id"
|
||||
|
||||
acts_as_event :title => Proc.new {|o| "#{o.board.name}: #{o.subject}"},
|
||||
:description => :content,
|
||||
:group => :parent,
|
||||
:type => Proc.new {|o| o.parent_id.nil? ? 'message' : 'reply'},
|
||||
:url => Proc.new {|o| {:controller => 'messages', :action => 'show', :board_id => o.board_id}.merge(o.parent_id.nil? ? {:id => o.id} :
|
||||
{:id => o.parent_id, :r => o.id, :anchor => "message-#{o.id}"})}
|
||||
|
||||
acts_as_activity_provider :scope => preload({:board => :project}, :author),
|
||||
:author_key => :author_id
|
||||
acts_as_watchable
|
||||
|
||||
validates_presence_of :board, :subject, :content
|
||||
validates_length_of :subject, :maximum => 255
|
||||
validate :cannot_reply_to_locked_topic, :on => :create
|
||||
|
||||
after_create :add_author_as_watcher, :reset_counters!
|
||||
after_update :update_messages_board
|
||||
after_destroy :reset_counters!
|
||||
after_create :send_notification
|
||||
|
||||
scope :visible, lambda {|*args|
|
||||
joins(:board => :project).
|
||||
where(Project.allowed_to_condition(args.shift || User.current, :view_messages, *args))
|
||||
}
|
||||
|
||||
safe_attributes 'subject', 'content'
|
||||
safe_attributes 'locked', 'sticky', 'board_id',
|
||||
:if => lambda {|message, user|
|
||||
user.allowed_to?(:edit_messages, message.project)
|
||||
}
|
||||
|
||||
def visible?(user=User.current)
|
||||
!user.nil? && user.allowed_to?(:view_messages, project)
|
||||
end
|
||||
|
||||
def cannot_reply_to_locked_topic
|
||||
# Can not reply to a locked topic
|
||||
errors.add :base, 'Topic is locked' if root.locked? && self != root
|
||||
end
|
||||
|
||||
def update_messages_board
|
||||
if board_id_changed?
|
||||
Message.where(["id = ? OR parent_id = ?", root.id, root.id]).update_all({:board_id => board_id})
|
||||
Board.reset_counters!(board_id_was)
|
||||
Board.reset_counters!(board_id)
|
||||
end
|
||||
end
|
||||
|
||||
def reset_counters!
|
||||
if parent && parent.id
|
||||
Message.where({:id => parent.id}).update_all({:last_reply_id => parent.children.maximum(:id)})
|
||||
end
|
||||
board.reset_counters!
|
||||
end
|
||||
|
||||
def sticky=(arg)
|
||||
write_attribute :sticky, (arg == true || arg.to_s == '1' ? 1 : 0)
|
||||
end
|
||||
|
||||
def sticky?
|
||||
sticky == 1
|
||||
end
|
||||
|
||||
def project
|
||||
board.project
|
||||
end
|
||||
|
||||
def editable_by?(usr)
|
||||
usr && usr.logged? && (usr.allowed_to?(:edit_messages, project) || (self.author == usr && usr.allowed_to?(:edit_own_messages, project)))
|
||||
end
|
||||
|
||||
def destroyable_by?(usr)
|
||||
usr && usr.logged? && (usr.allowed_to?(:delete_messages, project) || (self.author == usr && usr.allowed_to?(:delete_own_messages, project)))
|
||||
end
|
||||
|
||||
def notified_users
|
||||
project.notified_users.reject {|user| !visible?(user)}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def add_author_as_watcher
|
||||
Watcher.create(:watchable => self.root, :user => author)
|
||||
end
|
||||
|
||||
def send_notification
|
||||
if Setting.notified_events.include?('message_posted')
|
||||
Mailer.message_posted(self).deliver
|
||||
end
|
||||
end
|
||||
end
|
98
app/models/news.rb
Normal file
98
app/models/news.rb
Normal file
|
@ -0,0 +1,98 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
class News < ActiveRecord::Base
|
||||
include Redmine::SafeAttributes
|
||||
belongs_to :project
|
||||
belongs_to :author, :class_name => 'User'
|
||||
has_many :comments, lambda {order("created_on")}, :as => :commented, :dependent => :delete_all
|
||||
|
||||
validates_presence_of :title, :description
|
||||
validates_length_of :title, :maximum => 60
|
||||
validates_length_of :summary, :maximum => 255
|
||||
attr_protected :id
|
||||
|
||||
acts_as_attachable :edit_permission => :manage_news,
|
||||
:delete_permission => :manage_news
|
||||
acts_as_searchable :columns => ['title', 'summary', "#{table_name}.description"],
|
||||
:preload => :project
|
||||
acts_as_event :url => Proc.new {|o| {:controller => 'news', :action => 'show', :id => o.id}}
|
||||
acts_as_activity_provider :scope => preload(:project, :author),
|
||||
:author_key => :author_id
|
||||
acts_as_watchable
|
||||
|
||||
after_create :add_author_as_watcher
|
||||
after_create :send_notification
|
||||
|
||||
scope :visible, lambda {|*args|
|
||||
joins(:project).
|
||||
where(Project.allowed_to_condition(args.shift || User.current, :view_news, *args))
|
||||
}
|
||||
|
||||
safe_attributes 'title', 'summary', 'description'
|
||||
|
||||
def visible?(user=User.current)
|
||||
!user.nil? && user.allowed_to?(:view_news, project)
|
||||
end
|
||||
|
||||
# Returns true if the news can be commented by user
|
||||
def commentable?(user=User.current)
|
||||
user.allowed_to?(:comment_news, project)
|
||||
end
|
||||
|
||||
def notified_users
|
||||
project.users.select {|user| user.notify_about?(self) && user.allowed_to?(:view_news, project)}
|
||||
end
|
||||
|
||||
def recipients
|
||||
notified_users.map(&:mail)
|
||||
end
|
||||
|
||||
# Returns the users that should be cc'd when a new news is added
|
||||
def notified_watchers_for_added_news
|
||||
watchers = []
|
||||
if m = project.enabled_module('news')
|
||||
watchers = m.notified_watchers
|
||||
unless project.is_public?
|
||||
watchers = watchers.select {|user| project.users.include?(user)}
|
||||
end
|
||||
end
|
||||
watchers
|
||||
end
|
||||
|
||||
# Returns the email addresses that should be cc'd when a new news is added
|
||||
def cc_for_added_news
|
||||
notified_watchers_for_added_news.map(&:mail)
|
||||
end
|
||||
|
||||
# returns latest news for projects visible by user
|
||||
def self.latest(user = User.current, count = 5)
|
||||
visible(user).preload(:author, :project).order("#{News.table_name}.created_on DESC").limit(count).to_a
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def add_author_as_watcher
|
||||
Watcher.create(:watchable => self, :user => author)
|
||||
end
|
||||
|
||||
def send_notification
|
||||
if Setting.notified_events.include?('news_added')
|
||||
Mailer.news_added(self).deliver
|
||||
end
|
||||
end
|
||||
end
|
209
app/models/principal.rb
Normal file
209
app/models/principal.rb
Normal file
|
@ -0,0 +1,209 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
class Principal < ActiveRecord::Base
|
||||
self.table_name = "#{table_name_prefix}users#{table_name_suffix}"
|
||||
|
||||
# Account statuses
|
||||
STATUS_ANONYMOUS = 0
|
||||
STATUS_ACTIVE = 1
|
||||
STATUS_REGISTERED = 2
|
||||
STATUS_LOCKED = 3
|
||||
|
||||
class_attribute :valid_statuses
|
||||
|
||||
has_many :members, :foreign_key => 'user_id', :dependent => :destroy
|
||||
has_many :memberships,
|
||||
lambda {joins(:project).where.not(:projects => {:status => Project::STATUS_ARCHIVED})},
|
||||
:class_name => 'Member',
|
||||
:foreign_key => 'user_id'
|
||||
has_many :projects, :through => :memberships
|
||||
has_many :issue_categories, :foreign_key => 'assigned_to_id', :dependent => :nullify
|
||||
|
||||
validate :validate_status
|
||||
|
||||
# Groups and active users
|
||||
scope :active, lambda { where(:status => STATUS_ACTIVE) }
|
||||
|
||||
scope :visible, lambda {|*args|
|
||||
user = args.first || User.current
|
||||
|
||||
if user.admin?
|
||||
all
|
||||
else
|
||||
view_all_active = false
|
||||
if user.memberships.to_a.any?
|
||||
view_all_active = user.memberships.any? {|m| m.roles.any? {|r| r.users_visibility == 'all'}}
|
||||
else
|
||||
view_all_active = user.builtin_role.users_visibility == 'all'
|
||||
end
|
||||
|
||||
if view_all_active
|
||||
active
|
||||
else
|
||||
# self and members of visible projects
|
||||
active.where("#{table_name}.id = ? OR #{table_name}.id IN (SELECT user_id FROM #{Member.table_name} WHERE project_id IN (?))",
|
||||
user.id, user.visible_project_ids
|
||||
)
|
||||
end
|
||||
end
|
||||
}
|
||||
|
||||
scope :like, lambda {|q|
|
||||
q = q.to_s
|
||||
if q.blank?
|
||||
where({})
|
||||
else
|
||||
pattern = "%#{q}%"
|
||||
sql = %w(login firstname lastname).map {|column| "LOWER(#{table_name}.#{column}) LIKE LOWER(:p)"}.join(" OR ")
|
||||
sql << " OR #{table_name}.id IN (SELECT user_id FROM #{EmailAddress.table_name} WHERE LOWER(address) LIKE LOWER(:p))"
|
||||
params = {:p => pattern}
|
||||
if q =~ /^(.+)\s+(.+)$/
|
||||
a, b = "#{$1}%", "#{$2}%"
|
||||
sql << " OR (LOWER(#{table_name}.firstname) LIKE LOWER(:a) AND LOWER(#{table_name}.lastname) LIKE LOWER(:b))"
|
||||
sql << " OR (LOWER(#{table_name}.firstname) LIKE LOWER(:b) AND LOWER(#{table_name}.lastname) LIKE LOWER(:a))"
|
||||
params.merge!(:a => a, :b => b)
|
||||
end
|
||||
where(sql, params)
|
||||
end
|
||||
}
|
||||
|
||||
# Principals that are members of a collection of projects
|
||||
scope :member_of, lambda {|projects|
|
||||
projects = [projects] if projects.is_a?(Project)
|
||||
if projects.blank?
|
||||
where("1=0")
|
||||
else
|
||||
ids = projects.map(&:id)
|
||||
active.where("#{Principal.table_name}.id IN (SELECT DISTINCT user_id FROM #{Member.table_name} WHERE project_id IN (?))", ids)
|
||||
end
|
||||
}
|
||||
# Principals that are not members of projects
|
||||
scope :not_member_of, lambda {|projects|
|
||||
projects = [projects] unless projects.is_a?(Array)
|
||||
if projects.empty?
|
||||
where("1=0")
|
||||
else
|
||||
ids = projects.map(&:id)
|
||||
where("#{Principal.table_name}.id NOT IN (SELECT DISTINCT user_id FROM #{Member.table_name} WHERE project_id IN (?))", ids)
|
||||
end
|
||||
}
|
||||
scope :sorted, lambda { order(*Principal.fields_for_order_statement)}
|
||||
|
||||
before_create :set_default_empty_values
|
||||
before_destroy :nullify_projects_default_assigned_to
|
||||
|
||||
def reload(*args)
|
||||
@project_ids = nil
|
||||
super
|
||||
end
|
||||
|
||||
def name(formatter = nil)
|
||||
to_s
|
||||
end
|
||||
|
||||
def mail=(*args)
|
||||
nil
|
||||
end
|
||||
|
||||
def mail
|
||||
nil
|
||||
end
|
||||
|
||||
def visible?(user=User.current)
|
||||
Principal.visible(user).where(:id => id).first == self
|
||||
end
|
||||
|
||||
# Returns true if the principal is a member of project
|
||||
def member_of?(project)
|
||||
project.is_a?(Project) && project_ids.include?(project.id)
|
||||
end
|
||||
|
||||
# Returns an array of the project ids that the principal is a member of
|
||||
def project_ids
|
||||
@project_ids ||= super.freeze
|
||||
end
|
||||
|
||||
def <=>(principal)
|
||||
if principal.nil?
|
||||
-1
|
||||
elsif self.class.name == principal.class.name
|
||||
self.to_s.casecmp(principal.to_s)
|
||||
else
|
||||
# groups after users
|
||||
principal.class.name <=> self.class.name
|
||||
end
|
||||
end
|
||||
|
||||
# Returns an array of fields names than can be used to make an order statement for principals.
|
||||
# Users are sorted before Groups.
|
||||
# Examples:
|
||||
def self.fields_for_order_statement(table=nil)
|
||||
table ||= table_name
|
||||
columns = ['type DESC'] + (User.name_formatter[:order] - ['id']) + ['lastname', 'id']
|
||||
columns.uniq.map {|field| "#{table}.#{field}"}
|
||||
end
|
||||
|
||||
# Returns the principal that matches the keyword among principals
|
||||
def self.detect_by_keyword(principals, keyword)
|
||||
keyword = keyword.to_s
|
||||
return nil if keyword.blank?
|
||||
|
||||
principal = nil
|
||||
principal ||= principals.detect {|a| keyword.casecmp(a.login.to_s) == 0}
|
||||
principal ||= principals.detect {|a| keyword.casecmp(a.mail.to_s) == 0}
|
||||
|
||||
if principal.nil? && keyword.match(/ /)
|
||||
firstname, lastname = *(keyword.split) # "First Last Throwaway"
|
||||
principal ||= principals.detect {|a|
|
||||
a.is_a?(User) &&
|
||||
firstname.casecmp(a.firstname.to_s) == 0 &&
|
||||
lastname.casecmp(a.lastname.to_s) == 0
|
||||
}
|
||||
end
|
||||
if principal.nil?
|
||||
principal ||= principals.detect {|a| keyword.casecmp(a.name) == 0}
|
||||
end
|
||||
principal
|
||||
end
|
||||
|
||||
def nullify_projects_default_assigned_to
|
||||
Project.where(default_assigned_to: self).update_all(default_assigned_to_id: nil)
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
# Make sure we don't try to insert NULL values (see #4632)
|
||||
def set_default_empty_values
|
||||
self.login ||= ''
|
||||
self.hashed_password ||= ''
|
||||
self.firstname ||= ''
|
||||
self.lastname ||= ''
|
||||
true
|
||||
end
|
||||
|
||||
def validate_status
|
||||
if status_changed? && self.class.valid_statuses.present?
|
||||
unless self.class.valid_statuses.include?(status)
|
||||
errors.add :status, :invalid
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
require_dependency "user"
|
||||
require_dependency "group"
|
1125
app/models/project.rb
Normal file
1125
app/models/project.rb
Normal file
File diff suppressed because it is too large
Load diff
22
app/models/project_custom_field.rb
Normal file
22
app/models/project_custom_field.rb
Normal file
|
@ -0,0 +1,22 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
class ProjectCustomField < CustomField
|
||||
def type_name
|
||||
:label_project_plural
|
||||
end
|
||||
end
|
1373
app/models/query.rb
Normal file
1373
app/models/query.rb
Normal file
File diff suppressed because it is too large
Load diff
516
app/models/repository.rb
Normal file
516
app/models/repository.rb
Normal file
|
@ -0,0 +1,516 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
class ScmFetchError < Exception; end
|
||||
|
||||
class Repository < ActiveRecord::Base
|
||||
include Redmine::Ciphering
|
||||
include Redmine::SafeAttributes
|
||||
|
||||
# Maximum length for repository identifiers
|
||||
IDENTIFIER_MAX_LENGTH = 255
|
||||
|
||||
belongs_to :project
|
||||
has_many :changesets, lambda{order("#{Changeset.table_name}.committed_on DESC, #{Changeset.table_name}.id DESC")}
|
||||
has_many :filechanges, :class_name => 'Change', :through => :changesets
|
||||
|
||||
serialize :extra_info
|
||||
|
||||
before_validation :normalize_identifier
|
||||
before_save :check_default
|
||||
|
||||
# Raw SQL to delete changesets and changes in the database
|
||||
# has_many :changesets, :dependent => :destroy is too slow for big repositories
|
||||
before_destroy :clear_changesets
|
||||
|
||||
validates_length_of :login, maximum: 60, allow_nil: true
|
||||
validates_length_of :password, :maximum => 255, :allow_nil => true
|
||||
validates_length_of :root_url, :url, maximum: 255
|
||||
validates_length_of :identifier, :maximum => IDENTIFIER_MAX_LENGTH, :allow_blank => true
|
||||
validates_uniqueness_of :identifier, :scope => :project_id
|
||||
validates_exclusion_of :identifier, :in => %w(browse show entry raw changes annotate diff statistics graph revisions revision)
|
||||
# donwcase letters, digits, dashes, underscores but not digits only
|
||||
validates_format_of :identifier, :with => /\A(?!\d+$)[a-z0-9\-_]*\z/, :allow_blank => true
|
||||
# Checks if the SCM is enabled when creating a repository
|
||||
validate :repo_create_validation, :on => :create
|
||||
validate :validate_repository_path
|
||||
attr_protected :id
|
||||
|
||||
safe_attributes 'identifier',
|
||||
'login',
|
||||
'password',
|
||||
'path_encoding',
|
||||
'log_encoding',
|
||||
'is_default'
|
||||
|
||||
safe_attributes 'url',
|
||||
:if => lambda {|repository, user| repository.new_record?}
|
||||
|
||||
def repo_create_validation
|
||||
unless Setting.enabled_scm.include?(self.class.name.demodulize)
|
||||
errors.add(:type, :invalid)
|
||||
end
|
||||
end
|
||||
|
||||
def self.human_attribute_name(attribute_key_name, *args)
|
||||
attr_name = attribute_key_name.to_s
|
||||
if attr_name == "log_encoding"
|
||||
attr_name = "commit_logs_encoding"
|
||||
end
|
||||
super(attr_name, *args)
|
||||
end
|
||||
|
||||
# Removes leading and trailing whitespace
|
||||
def url=(arg)
|
||||
write_attribute(:url, arg ? arg.to_s.strip : nil)
|
||||
end
|
||||
|
||||
# Removes leading and trailing whitespace
|
||||
def root_url=(arg)
|
||||
write_attribute(:root_url, arg ? arg.to_s.strip : nil)
|
||||
end
|
||||
|
||||
def password
|
||||
read_ciphered_attribute(:password)
|
||||
end
|
||||
|
||||
def password=(arg)
|
||||
write_ciphered_attribute(:password, arg)
|
||||
end
|
||||
|
||||
def scm_adapter
|
||||
self.class.scm_adapter_class
|
||||
end
|
||||
|
||||
def scm
|
||||
unless @scm
|
||||
@scm = self.scm_adapter.new(url, root_url,
|
||||
login, password, path_encoding)
|
||||
if root_url.blank? && @scm.root_url.present?
|
||||
update_attribute(:root_url, @scm.root_url)
|
||||
end
|
||||
end
|
||||
@scm
|
||||
end
|
||||
|
||||
def scm_name
|
||||
self.class.scm_name
|
||||
end
|
||||
|
||||
def name
|
||||
if identifier.present?
|
||||
identifier
|
||||
elsif is_default?
|
||||
l(:field_repository_is_default)
|
||||
else
|
||||
scm_name
|
||||
end
|
||||
end
|
||||
|
||||
def identifier=(identifier)
|
||||
super unless identifier_frozen?
|
||||
end
|
||||
|
||||
def identifier_frozen?
|
||||
errors[:identifier].blank? && !(new_record? || identifier.blank?)
|
||||
end
|
||||
|
||||
def identifier_param
|
||||
if is_default?
|
||||
nil
|
||||
elsif identifier.present?
|
||||
identifier
|
||||
else
|
||||
id.to_s
|
||||
end
|
||||
end
|
||||
|
||||
def <=>(repository)
|
||||
if is_default?
|
||||
-1
|
||||
elsif repository.is_default?
|
||||
1
|
||||
else
|
||||
identifier.to_s <=> repository.identifier.to_s
|
||||
end
|
||||
end
|
||||
|
||||
def self.find_by_identifier_param(param)
|
||||
if param.to_s =~ /^\d+$/
|
||||
find_by_id(param)
|
||||
else
|
||||
find_by_identifier(param)
|
||||
end
|
||||
end
|
||||
|
||||
# TODO: should return an empty hash instead of nil to avoid many ||{}
|
||||
def extra_info
|
||||
h = read_attribute(:extra_info)
|
||||
h.is_a?(Hash) ? h : nil
|
||||
end
|
||||
|
||||
def merge_extra_info(arg)
|
||||
h = extra_info || {}
|
||||
return h if arg.nil?
|
||||
h.merge!(arg)
|
||||
write_attribute(:extra_info, h)
|
||||
end
|
||||
|
||||
def report_last_commit
|
||||
true
|
||||
end
|
||||
|
||||
def supports_cat?
|
||||
scm.supports_cat?
|
||||
end
|
||||
|
||||
def supports_annotate?
|
||||
scm.supports_annotate?
|
||||
end
|
||||
|
||||
def supports_all_revisions?
|
||||
true
|
||||
end
|
||||
|
||||
def supports_directory_revisions?
|
||||
false
|
||||
end
|
||||
|
||||
def supports_revision_graph?
|
||||
false
|
||||
end
|
||||
|
||||
def entry(path=nil, identifier=nil)
|
||||
scm.entry(path, identifier)
|
||||
end
|
||||
|
||||
def scm_entries(path=nil, identifier=nil)
|
||||
scm.entries(path, identifier)
|
||||
end
|
||||
protected :scm_entries
|
||||
|
||||
def entries(path=nil, identifier=nil)
|
||||
entries = scm_entries(path, identifier)
|
||||
load_entries_changesets(entries)
|
||||
entries
|
||||
end
|
||||
|
||||
def branches
|
||||
scm.branches
|
||||
end
|
||||
|
||||
def tags
|
||||
scm.tags
|
||||
end
|
||||
|
||||
def default_branch
|
||||
nil
|
||||
end
|
||||
|
||||
def properties(path, identifier=nil)
|
||||
scm.properties(path, identifier)
|
||||
end
|
||||
|
||||
def cat(path, identifier=nil)
|
||||
scm.cat(path, identifier)
|
||||
end
|
||||
|
||||
def diff(path, rev, rev_to)
|
||||
scm.diff(path, rev, rev_to)
|
||||
end
|
||||
|
||||
def diff_format_revisions(cs, cs_to, sep=':')
|
||||
text = ""
|
||||
text << cs_to.format_identifier + sep if cs_to
|
||||
text << cs.format_identifier if cs
|
||||
text
|
||||
end
|
||||
|
||||
# Returns a path relative to the url of the repository
|
||||
def relative_path(path)
|
||||
path
|
||||
end
|
||||
|
||||
# Finds and returns a revision with a number or the beginning of a hash
|
||||
def find_changeset_by_name(name)
|
||||
return nil if name.blank?
|
||||
s = name.to_s
|
||||
if s.match(/^\d*$/)
|
||||
changesets.where("revision = ?", s).first
|
||||
else
|
||||
changesets.where("revision LIKE ?", s + '%').first
|
||||
end
|
||||
end
|
||||
|
||||
def latest_changeset
|
||||
@latest_changeset ||= changesets.first
|
||||
end
|
||||
|
||||
# Returns the latest changesets for +path+
|
||||
# Default behaviour is to search in cached changesets
|
||||
def latest_changesets(path, rev, limit=10)
|
||||
if path.blank?
|
||||
changesets.
|
||||
reorder("#{Changeset.table_name}.committed_on DESC, #{Changeset.table_name}.id DESC").
|
||||
limit(limit).
|
||||
preload(:user).
|
||||
to_a
|
||||
else
|
||||
filechanges.
|
||||
where("path = ?", path.with_leading_slash).
|
||||
reorder("#{Changeset.table_name}.committed_on DESC, #{Changeset.table_name}.id DESC").
|
||||
limit(limit).
|
||||
preload(:changeset => :user).
|
||||
collect(&:changeset)
|
||||
end
|
||||
end
|
||||
|
||||
def scan_changesets_for_issue_ids
|
||||
self.changesets.each(&:scan_comment_for_issue_ids)
|
||||
end
|
||||
|
||||
# Returns an array of committers usernames and associated user_id
|
||||
def committers
|
||||
@committers ||= Changeset.where(:repository_id => id).distinct.pluck(:committer, :user_id)
|
||||
end
|
||||
|
||||
# Maps committers username to a user ids
|
||||
def committer_ids=(h)
|
||||
if h.is_a?(Hash)
|
||||
committers.each do |committer, user_id|
|
||||
new_user_id = h[committer]
|
||||
if new_user_id && (new_user_id.to_i != user_id.to_i)
|
||||
new_user_id = (new_user_id.to_i > 0 ? new_user_id.to_i : nil)
|
||||
Changeset.where(["repository_id = ? AND committer = ?", id, committer]).
|
||||
update_all("user_id = #{new_user_id.nil? ? 'NULL' : new_user_id}")
|
||||
end
|
||||
end
|
||||
@committers = nil
|
||||
@found_committer_users = nil
|
||||
true
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
# Returns the Redmine User corresponding to the given +committer+
|
||||
# It will return nil if the committer is not yet mapped and if no User
|
||||
# with the same username or email was found
|
||||
def find_committer_user(committer)
|
||||
unless committer.blank?
|
||||
@found_committer_users ||= {}
|
||||
return @found_committer_users[committer] if @found_committer_users.has_key?(committer)
|
||||
|
||||
user = nil
|
||||
c = changesets.where(:committer => committer).
|
||||
includes(:user).references(:user).first
|
||||
if c && c.user
|
||||
user = c.user
|
||||
elsif committer.strip =~ /^([^<]+)(<(.*)>)?$/
|
||||
username, email = $1.strip, $3
|
||||
u = User.find_by_login(username)
|
||||
u ||= User.find_by_mail(email) unless email.blank?
|
||||
user = u
|
||||
end
|
||||
@found_committer_users[committer] = user
|
||||
user
|
||||
end
|
||||
end
|
||||
|
||||
def repo_log_encoding
|
||||
encoding = log_encoding.to_s.strip
|
||||
encoding.blank? ? 'UTF-8' : encoding
|
||||
end
|
||||
|
||||
# Fetches new changesets for all repositories of active projects
|
||||
# Can be called periodically by an external script
|
||||
# eg. ruby script/runner "Repository.fetch_changesets"
|
||||
def self.fetch_changesets
|
||||
Project.active.has_module(:repository).all.each do |project|
|
||||
project.repositories.each do |repository|
|
||||
begin
|
||||
repository.fetch_changesets
|
||||
rescue Redmine::Scm::Adapters::CommandFailed => e
|
||||
logger.error "scm: error during fetching changesets: #{e.message}"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# scan changeset comments to find related and fixed issues for all repositories
|
||||
def self.scan_changesets_for_issue_ids
|
||||
all.each(&:scan_changesets_for_issue_ids)
|
||||
end
|
||||
|
||||
def self.scm_name
|
||||
'Abstract'
|
||||
end
|
||||
|
||||
def self.available_scm
|
||||
subclasses.collect {|klass| [klass.scm_name, klass.name]}
|
||||
end
|
||||
|
||||
def self.factory(klass_name, *args)
|
||||
repository_class(klass_name).new(*args) rescue nil
|
||||
end
|
||||
|
||||
def self.repository_class(class_name)
|
||||
class_name = class_name.to_s.camelize
|
||||
if Redmine::Scm::Base.all.include?(class_name)
|
||||
"Repository::#{class_name}".constantize
|
||||
end
|
||||
end
|
||||
|
||||
def self.scm_adapter_class
|
||||
nil
|
||||
end
|
||||
|
||||
def self.scm_command
|
||||
ret = ""
|
||||
begin
|
||||
ret = self.scm_adapter_class.client_command if self.scm_adapter_class
|
||||
rescue Exception => e
|
||||
logger.error "scm: error during get command: #{e.message}"
|
||||
end
|
||||
ret
|
||||
end
|
||||
|
||||
def self.scm_version_string
|
||||
ret = ""
|
||||
begin
|
||||
ret = self.scm_adapter_class.client_version_string if self.scm_adapter_class
|
||||
rescue Exception => e
|
||||
logger.error "scm: error during get version string: #{e.message}"
|
||||
end
|
||||
ret
|
||||
end
|
||||
|
||||
def self.scm_available
|
||||
ret = false
|
||||
begin
|
||||
ret = self.scm_adapter_class.client_available if self.scm_adapter_class
|
||||
rescue Exception => e
|
||||
logger.error "scm: error during get scm available: #{e.message}"
|
||||
end
|
||||
ret
|
||||
end
|
||||
|
||||
def set_as_default?
|
||||
new_record? && project && Repository.where(:project_id => project.id).empty?
|
||||
end
|
||||
|
||||
# Returns a hash with statistics by author in the following form:
|
||||
# {
|
||||
# "John Smith" => { :commits => 45, :changes => 324 },
|
||||
# "Bob" => { ... }
|
||||
# }
|
||||
#
|
||||
# Notes:
|
||||
# - this hash honnors the users mapping defined for the repository
|
||||
def stats_by_author
|
||||
commits = Changeset.where("repository_id = ?", id).select("committer, user_id, count(*) as count").group("committer, user_id")
|
||||
|
||||
#TODO: restore ordering ; this line probably never worked
|
||||
#commits.to_a.sort! {|x, y| x.last <=> y.last}
|
||||
|
||||
changes = Change.joins(:changeset).where("#{Changeset.table_name}.repository_id = ?", id).select("committer, user_id, count(*) as count").group("committer, user_id")
|
||||
|
||||
user_ids = changesets.map(&:user_id).compact.uniq
|
||||
authors_names = User.where(:id => user_ids).inject({}) do |memo, user|
|
||||
memo[user.id] = user.to_s
|
||||
memo
|
||||
end
|
||||
|
||||
(commits + changes).inject({}) do |hash, element|
|
||||
mapped_name = element.committer
|
||||
if username = authors_names[element.user_id.to_i]
|
||||
mapped_name = username
|
||||
end
|
||||
hash[mapped_name] ||= { :commits_count => 0, :changes_count => 0 }
|
||||
if element.is_a?(Changeset)
|
||||
hash[mapped_name][:commits_count] += element.count.to_i
|
||||
else
|
||||
hash[mapped_name][:changes_count] += element.count.to_i
|
||||
end
|
||||
hash
|
||||
end
|
||||
end
|
||||
|
||||
# Returns a scope of changesets that come from the same commit as the given changeset
|
||||
# in different repositories that point to the same backend
|
||||
def same_commits_in_scope(scope, changeset)
|
||||
scope = scope.joins(:repository).where(:repositories => {:url => url, :root_url => root_url, :type => type})
|
||||
if changeset.scmid.present?
|
||||
scope = scope.where(:scmid => changeset.scmid)
|
||||
else
|
||||
scope = scope.where(:revision => changeset.revision)
|
||||
end
|
||||
scope
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
# Validates repository url based against an optional regular expression
|
||||
# that can be set in the Redmine configuration file.
|
||||
def validate_repository_path(attribute=:url)
|
||||
regexp = Redmine::Configuration["scm_#{scm_name.to_s.downcase}_path_regexp"]
|
||||
if changes[attribute] && regexp.present?
|
||||
regexp = regexp.to_s.strip.gsub('%project%') {Regexp.escape(project.try(:identifier).to_s)}
|
||||
unless send(attribute).to_s.match(Regexp.new("\\A#{regexp}\\z"))
|
||||
errors.add(attribute, :invalid)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def normalize_identifier
|
||||
self.identifier = identifier.to_s.strip
|
||||
end
|
||||
|
||||
def check_default
|
||||
if !is_default? && set_as_default?
|
||||
self.is_default = true
|
||||
end
|
||||
if is_default? && is_default_changed?
|
||||
Repository.where(["project_id = ?", project_id]).update_all(["is_default = ?", false])
|
||||
end
|
||||
end
|
||||
|
||||
def load_entries_changesets(entries)
|
||||
if entries
|
||||
entries.each do |entry|
|
||||
if entry.lastrev && entry.lastrev.identifier
|
||||
entry.changeset = find_changeset_by_name(entry.lastrev.identifier)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Deletes repository data
|
||||
def clear_changesets
|
||||
cs = Changeset.table_name
|
||||
ch = Change.table_name
|
||||
ci = "#{table_name_prefix}changesets_issues#{table_name_suffix}"
|
||||
cp = "#{table_name_prefix}changeset_parents#{table_name_suffix}"
|
||||
|
||||
self.class.connection.delete("DELETE FROM #{ch} WHERE #{ch}.changeset_id IN (SELECT #{cs}.id FROM #{cs} WHERE #{cs}.repository_id = #{id})")
|
||||
self.class.connection.delete("DELETE FROM #{ci} WHERE #{ci}.changeset_id IN (SELECT #{cs}.id FROM #{cs} WHERE #{cs}.repository_id = #{id})")
|
||||
self.class.connection.delete("DELETE FROM #{cp} WHERE #{cp}.changeset_id IN (SELECT #{cs}.id FROM #{cs} WHERE #{cs}.repository_id = #{id})")
|
||||
self.class.connection.delete("DELETE FROM #{cs} WHERE #{cs}.repository_id = #{id}")
|
||||
end
|
||||
end
|
124
app/models/repository/bazaar.rb
Normal file
124
app/models/repository/bazaar.rb
Normal file
|
@ -0,0 +1,124 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
require 'redmine/scm/adapters/bazaar_adapter'
|
||||
|
||||
class Repository::Bazaar < Repository
|
||||
attr_protected :root_url
|
||||
validates_presence_of :url, :log_encoding
|
||||
|
||||
def self.human_attribute_name(attribute_key_name, *args)
|
||||
attr_name = attribute_key_name.to_s
|
||||
if attr_name == "url"
|
||||
attr_name = "path_to_repository"
|
||||
end
|
||||
super(attr_name, *args)
|
||||
end
|
||||
|
||||
def self.scm_adapter_class
|
||||
Redmine::Scm::Adapters::BazaarAdapter
|
||||
end
|
||||
|
||||
def self.scm_name
|
||||
'Bazaar'
|
||||
end
|
||||
|
||||
def entry(path=nil, identifier=nil)
|
||||
scm.bzr_path_encodig = log_encoding
|
||||
scm.entry(path, identifier)
|
||||
end
|
||||
|
||||
def cat(path, identifier=nil)
|
||||
scm.bzr_path_encodig = log_encoding
|
||||
scm.cat(path, identifier)
|
||||
end
|
||||
|
||||
def annotate(path, identifier=nil)
|
||||
scm.bzr_path_encodig = log_encoding
|
||||
scm.annotate(path, identifier)
|
||||
end
|
||||
|
||||
def diff(path, rev, rev_to)
|
||||
scm.bzr_path_encodig = log_encoding
|
||||
scm.diff(path, rev, rev_to)
|
||||
end
|
||||
|
||||
def scm_entries(path=nil, identifier=nil)
|
||||
scm.bzr_path_encodig = log_encoding
|
||||
entries = scm.entries(path, identifier)
|
||||
if entries
|
||||
entries.each do |e|
|
||||
next if e.lastrev.revision.blank?
|
||||
# Set the filesize unless browsing a specific revision
|
||||
if identifier.nil? && e.is_file?
|
||||
full_path = File.join(root_url, e.path)
|
||||
e.size = File.stat(full_path).size if File.file?(full_path)
|
||||
end
|
||||
c = Change.
|
||||
includes(:changeset).
|
||||
where("#{Change.table_name}.revision = ? and #{Changeset.table_name}.repository_id = ?", e.lastrev.revision, id).
|
||||
order("#{Changeset.table_name}.revision DESC").
|
||||
first
|
||||
if c
|
||||
e.lastrev.identifier = c.changeset.revision
|
||||
e.lastrev.name = c.changeset.revision
|
||||
e.lastrev.author = c.changeset.committer
|
||||
end
|
||||
end
|
||||
end
|
||||
entries
|
||||
end
|
||||
protected :scm_entries
|
||||
|
||||
def fetch_changesets
|
||||
scm.bzr_path_encodig = log_encoding
|
||||
scm_info = scm.info
|
||||
if scm_info
|
||||
# latest revision found in database
|
||||
db_revision = latest_changeset ? latest_changeset.revision.to_i : 0
|
||||
# latest revision in the repository
|
||||
scm_revision = scm_info.lastrev.identifier.to_i
|
||||
if db_revision < scm_revision
|
||||
logger.debug "Fetching changesets for repository #{url}" if logger && logger.debug?
|
||||
identifier_from = db_revision + 1
|
||||
while (identifier_from <= scm_revision)
|
||||
# loads changesets by batches of 200
|
||||
identifier_to = [identifier_from + 199, scm_revision].min
|
||||
revisions = scm.revisions('', identifier_to, identifier_from)
|
||||
transaction do
|
||||
revisions.reverse_each do |revision|
|
||||
changeset = Changeset.create(:repository => self,
|
||||
:revision => revision.identifier,
|
||||
:committer => revision.author,
|
||||
:committed_on => revision.time,
|
||||
:scmid => revision.scmid,
|
||||
:comments => revision.message)
|
||||
|
||||
revision.paths.each do |change|
|
||||
Change.create(:changeset => changeset,
|
||||
:action => change[:action],
|
||||
:path => change[:path],
|
||||
:revision => change[:revision])
|
||||
end
|
||||
end
|
||||
end unless revisions.nil?
|
||||
identifier_from = identifier_to + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
213
app/models/repository/cvs.rb
Normal file
213
app/models/repository/cvs.rb
Normal file
|
@ -0,0 +1,213 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
require 'redmine/scm/adapters/cvs_adapter'
|
||||
require 'digest/sha1'
|
||||
|
||||
class Repository::Cvs < Repository
|
||||
validates_presence_of :url, :root_url, :log_encoding
|
||||
|
||||
safe_attributes 'root_url',
|
||||
:if => lambda {|repository, user| repository.new_record?}
|
||||
|
||||
def self.human_attribute_name(attribute_key_name, *args)
|
||||
attr_name = attribute_key_name.to_s
|
||||
if attr_name == "root_url"
|
||||
attr_name = "cvsroot"
|
||||
elsif attr_name == "url"
|
||||
attr_name = "cvs_module"
|
||||
end
|
||||
super(attr_name, *args)
|
||||
end
|
||||
|
||||
def self.scm_adapter_class
|
||||
Redmine::Scm::Adapters::CvsAdapter
|
||||
end
|
||||
|
||||
def self.scm_name
|
||||
'CVS'
|
||||
end
|
||||
|
||||
def entry(path=nil, identifier=nil)
|
||||
rev = identifier.nil? ? nil : changesets.find_by_revision(identifier)
|
||||
scm.entry(path, rev.nil? ? nil : rev.committed_on)
|
||||
end
|
||||
|
||||
def scm_entries(path=nil, identifier=nil)
|
||||
rev = nil
|
||||
if ! identifier.nil?
|
||||
rev = changesets.find_by_revision(identifier)
|
||||
return nil if rev.nil?
|
||||
end
|
||||
entries = scm.entries(path, rev.nil? ? nil : rev.committed_on)
|
||||
if entries
|
||||
entries.each() do |entry|
|
||||
if ( ! entry.lastrev.nil? ) && ( ! entry.lastrev.revision.nil? )
|
||||
change = filechanges.where(
|
||||
:revision => entry.lastrev.revision,
|
||||
:path => scm.with_leading_slash(entry.path)).first
|
||||
if change
|
||||
entry.lastrev.identifier = change.changeset.revision
|
||||
entry.lastrev.revision = change.changeset.revision
|
||||
entry.lastrev.author = change.changeset.committer
|
||||
# entry.lastrev.branch = change.branch
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
entries
|
||||
end
|
||||
protected :scm_entries
|
||||
|
||||
def cat(path, identifier=nil)
|
||||
rev = nil
|
||||
if ! identifier.nil?
|
||||
rev = changesets.find_by_revision(identifier)
|
||||
return nil if rev.nil?
|
||||
end
|
||||
scm.cat(path, rev.nil? ? nil : rev.committed_on)
|
||||
end
|
||||
|
||||
def annotate(path, identifier=nil)
|
||||
rev = nil
|
||||
if ! identifier.nil?
|
||||
rev = changesets.find_by_revision(identifier)
|
||||
return nil if rev.nil?
|
||||
end
|
||||
scm.annotate(path, rev.nil? ? nil : rev.committed_on)
|
||||
end
|
||||
|
||||
def diff(path, rev, rev_to)
|
||||
# convert rev to revision. CVS can't handle changesets here
|
||||
diff=[]
|
||||
changeset_from = changesets.find_by_revision(rev)
|
||||
if rev_to.to_i > 0
|
||||
changeset_to = changesets.find_by_revision(rev_to)
|
||||
end
|
||||
changeset_from.filechanges.each() do |change_from|
|
||||
revision_from = nil
|
||||
revision_to = nil
|
||||
if path.nil? || (change_from.path.starts_with? scm.with_leading_slash(path))
|
||||
revision_from = change_from.revision
|
||||
end
|
||||
if revision_from
|
||||
if changeset_to
|
||||
changeset_to.filechanges.each() do |change_to|
|
||||
revision_to = change_to.revision if change_to.path == change_from.path
|
||||
end
|
||||
end
|
||||
unless revision_to
|
||||
revision_to = scm.get_previous_revision(revision_from)
|
||||
end
|
||||
file_diff = scm.diff(change_from.path, revision_from, revision_to)
|
||||
diff = diff + file_diff unless file_diff.nil?
|
||||
end
|
||||
end
|
||||
return diff
|
||||
end
|
||||
|
||||
def fetch_changesets
|
||||
# some nifty bits to introduce a commit-id with cvs
|
||||
# natively cvs doesn't provide any kind of changesets,
|
||||
# there is only a revision per file.
|
||||
# we now take a guess using the author, the commitlog and the commit-date.
|
||||
|
||||
# last one is the next step to take. the commit-date is not equal for all
|
||||
# commits in one changeset. cvs update the commit-date when the *,v file was touched. so
|
||||
# we use a small delta here, to merge all changes belonging to _one_ changeset
|
||||
time_delta = 10.seconds
|
||||
fetch_since = latest_changeset ? latest_changeset.committed_on : nil
|
||||
transaction do
|
||||
tmp_rev_num = 1
|
||||
scm.revisions('', fetch_since, nil, :log_encoding => repo_log_encoding) do |revision|
|
||||
# only add the change to the database, if it doen't exists. the cvs log
|
||||
# is not exclusive at all.
|
||||
tmp_time = revision.time.clone
|
||||
unless filechanges.find_by_path_and_revision(
|
||||
scm.with_leading_slash(revision.paths[0][:path]),
|
||||
revision.paths[0][:revision]
|
||||
)
|
||||
cmt = Changeset.normalize_comments(revision.message, repo_log_encoding)
|
||||
author_utf8 = Changeset.to_utf8(revision.author, repo_log_encoding)
|
||||
cs = changesets.where(
|
||||
:committed_on => tmp_time - time_delta .. tmp_time + time_delta,
|
||||
:committer => author_utf8,
|
||||
:comments => cmt
|
||||
).first
|
||||
# create a new changeset....
|
||||
unless cs
|
||||
# we use a temporary revision number here (just for inserting)
|
||||
# later on, we calculate a continuous positive number
|
||||
tmp_time2 = tmp_time.clone.gmtime
|
||||
branch = revision.paths[0][:branch]
|
||||
scmid = branch + "-" + tmp_time2.strftime("%Y%m%d-%H%M%S")
|
||||
cs = Changeset.create(:repository => self,
|
||||
:revision => "tmp#{tmp_rev_num}",
|
||||
:scmid => scmid,
|
||||
:committer => revision.author,
|
||||
:committed_on => tmp_time,
|
||||
:comments => revision.message)
|
||||
tmp_rev_num += 1
|
||||
end
|
||||
# convert CVS-File-States to internal Action-abbreviations
|
||||
# default action is (M)odified
|
||||
action = "M"
|
||||
if revision.paths[0][:action] == "Exp" && revision.paths[0][:revision] == "1.1"
|
||||
action = "A" # add-action always at first revision (= 1.1)
|
||||
elsif revision.paths[0][:action] == "dead"
|
||||
action = "D" # dead-state is similar to Delete
|
||||
end
|
||||
Change.create(
|
||||
:changeset => cs,
|
||||
:action => action,
|
||||
:path => scm.with_leading_slash(revision.paths[0][:path]),
|
||||
:revision => revision.paths[0][:revision],
|
||||
:branch => revision.paths[0][:branch]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
# Renumber new changesets in chronological order
|
||||
Changeset.
|
||||
order('committed_on ASC, id ASC').
|
||||
where("repository_id = ? AND revision LIKE 'tmp%'", id).
|
||||
each do |changeset|
|
||||
changeset.update_attribute :revision, next_revision_number
|
||||
end
|
||||
end # transaction
|
||||
@current_revision_number = nil
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
# Overrides Repository#validate_repository_path to validate
|
||||
# against root_url attribute.
|
||||
def validate_repository_path(attribute=:root_url)
|
||||
super(attribute)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Returns the next revision number to assign to a CVS changeset
|
||||
def next_revision_number
|
||||
# Need to retrieve existing revision numbers to sort them as integers
|
||||
sql = "SELECT revision FROM #{Changeset.table_name} "
|
||||
sql << "WHERE repository_id = #{id} AND revision NOT LIKE 'tmp%'"
|
||||
@current_revision_number ||= (self.class.connection.select_values(sql).collect(&:to_i).max || 0)
|
||||
@current_revision_number += 1
|
||||
end
|
||||
end
|
114
app/models/repository/darcs.rb
Normal file
114
app/models/repository/darcs.rb
Normal file
|
@ -0,0 +1,114 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
require 'redmine/scm/adapters/darcs_adapter'
|
||||
|
||||
class Repository::Darcs < Repository
|
||||
validates_presence_of :url, :log_encoding
|
||||
|
||||
def self.human_attribute_name(attribute_key_name, *args)
|
||||
attr_name = attribute_key_name.to_s
|
||||
if attr_name == "url"
|
||||
attr_name = "path_to_repository"
|
||||
end
|
||||
super(attr_name, *args)
|
||||
end
|
||||
|
||||
def self.scm_adapter_class
|
||||
Redmine::Scm::Adapters::DarcsAdapter
|
||||
end
|
||||
|
||||
def self.scm_name
|
||||
'Darcs'
|
||||
end
|
||||
|
||||
def supports_directory_revisions?
|
||||
true
|
||||
end
|
||||
|
||||
def entry(path=nil, identifier=nil)
|
||||
patch = identifier.nil? ? nil : changesets.find_by_revision(identifier)
|
||||
scm.entry(path, patch.nil? ? nil : patch.scmid)
|
||||
end
|
||||
|
||||
def scm_entries(path=nil, identifier=nil)
|
||||
patch = nil
|
||||
if ! identifier.nil?
|
||||
patch = changesets.find_by_revision(identifier)
|
||||
return nil if patch.nil?
|
||||
end
|
||||
entries = scm.entries(path, patch.nil? ? nil : patch.scmid)
|
||||
if entries
|
||||
entries.each do |entry|
|
||||
# Search the DB for the entry's last change
|
||||
if entry.lastrev && !entry.lastrev.scmid.blank?
|
||||
changeset = changesets.find_by_scmid(entry.lastrev.scmid)
|
||||
end
|
||||
if changeset
|
||||
entry.lastrev.identifier = changeset.revision
|
||||
entry.lastrev.name = changeset.revision
|
||||
entry.lastrev.time = changeset.committed_on
|
||||
entry.lastrev.author = changeset.committer
|
||||
end
|
||||
end
|
||||
end
|
||||
entries
|
||||
end
|
||||
protected :scm_entries
|
||||
|
||||
def cat(path, identifier=nil)
|
||||
patch = identifier.nil? ? nil : changesets.find_by_revision(identifier.to_s)
|
||||
scm.cat(path, patch.nil? ? nil : patch.scmid)
|
||||
end
|
||||
|
||||
def diff(path, rev, rev_to)
|
||||
patch_from = changesets.find_by_revision(rev)
|
||||
return nil if patch_from.nil?
|
||||
patch_to = changesets.find_by_revision(rev_to) if rev_to
|
||||
if path.blank?
|
||||
path = patch_from.filechanges.collect{|change| change.path}.join(' ')
|
||||
end
|
||||
patch_from ? scm.diff(path, patch_from.scmid, patch_to ? patch_to.scmid : nil) : nil
|
||||
end
|
||||
|
||||
def fetch_changesets
|
||||
scm_info = scm.info
|
||||
if scm_info
|
||||
db_last_id = latest_changeset ? latest_changeset.scmid : nil
|
||||
next_rev = latest_changeset ? latest_changeset.revision.to_i + 1 : 1
|
||||
# latest revision in the repository
|
||||
scm_revision = scm_info.lastrev.scmid
|
||||
unless changesets.find_by_scmid(scm_revision)
|
||||
revisions = scm.revisions('', db_last_id, nil, :with_path => true)
|
||||
transaction do
|
||||
revisions.reverse_each do |revision|
|
||||
changeset = Changeset.create(:repository => self,
|
||||
:revision => next_rev,
|
||||
:scmid => revision.scmid,
|
||||
:committer => revision.author,
|
||||
:committed_on => revision.time,
|
||||
:comments => revision.message)
|
||||
revision.paths.each do |change|
|
||||
changeset.create_change(change)
|
||||
end
|
||||
next_rev += 1
|
||||
end if revisions
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
50
app/models/repository/filesystem.rb
Normal file
50
app/models/repository/filesystem.rb
Normal file
|
@ -0,0 +1,50 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# FileSystem adapter
|
||||
# File written by Paul Rivier, at Demotera.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
require 'redmine/scm/adapters/filesystem_adapter'
|
||||
|
||||
class Repository::Filesystem < Repository
|
||||
attr_protected :root_url
|
||||
validates_presence_of :url
|
||||
|
||||
def self.human_attribute_name(attribute_key_name, *args)
|
||||
attr_name = attribute_key_name.to_s
|
||||
if attr_name == "url"
|
||||
attr_name = "root_directory"
|
||||
end
|
||||
super(attr_name, *args)
|
||||
end
|
||||
|
||||
def self.scm_adapter_class
|
||||
Redmine::Scm::Adapters::FilesystemAdapter
|
||||
end
|
||||
|
||||
def self.scm_name
|
||||
'Filesystem'
|
||||
end
|
||||
|
||||
def supports_all_revisions?
|
||||
false
|
||||
end
|
||||
|
||||
def fetch_changesets
|
||||
nil
|
||||
end
|
||||
end
|
265
app/models/repository/git.rb
Normal file
265
app/models/repository/git.rb
Normal file
|
@ -0,0 +1,265 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
# Copyright (C) 2007 Patrick Aljord patcito@ŋmail.com
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
require 'redmine/scm/adapters/git_adapter'
|
||||
|
||||
class Repository::Git < Repository
|
||||
attr_protected :root_url
|
||||
validates_presence_of :url
|
||||
|
||||
safe_attributes 'report_last_commit'
|
||||
|
||||
def self.human_attribute_name(attribute_key_name, *args)
|
||||
attr_name = attribute_key_name.to_s
|
||||
if attr_name == "url"
|
||||
attr_name = "path_to_repository"
|
||||
end
|
||||
super(attr_name, *args)
|
||||
end
|
||||
|
||||
def self.scm_adapter_class
|
||||
Redmine::Scm::Adapters::GitAdapter
|
||||
end
|
||||
|
||||
def self.scm_name
|
||||
'Git'
|
||||
end
|
||||
|
||||
def report_last_commit
|
||||
return false if extra_info.nil?
|
||||
v = extra_info["extra_report_last_commit"]
|
||||
return false if v.nil?
|
||||
v.to_s != '0'
|
||||
end
|
||||
|
||||
def report_last_commit=(arg)
|
||||
merge_extra_info "extra_report_last_commit" => arg
|
||||
end
|
||||
|
||||
def supports_directory_revisions?
|
||||
true
|
||||
end
|
||||
|
||||
def supports_revision_graph?
|
||||
true
|
||||
end
|
||||
|
||||
def repo_log_encoding
|
||||
'UTF-8'
|
||||
end
|
||||
|
||||
# Returns the identifier for the given git changeset
|
||||
def self.changeset_identifier(changeset)
|
||||
changeset.scmid
|
||||
end
|
||||
|
||||
# Returns the readable identifier for the given git changeset
|
||||
def self.format_changeset_identifier(changeset)
|
||||
changeset.revision[0, 8]
|
||||
end
|
||||
|
||||
def branches
|
||||
scm.branches
|
||||
end
|
||||
|
||||
def tags
|
||||
scm.tags
|
||||
end
|
||||
|
||||
def default_branch
|
||||
scm.default_branch
|
||||
rescue Exception => e
|
||||
logger.error "git: error during get default branch: #{e.message}"
|
||||
nil
|
||||
end
|
||||
|
||||
def find_changeset_by_name(name)
|
||||
if name.present?
|
||||
changesets.where(:revision => name.to_s).first ||
|
||||
changesets.where('scmid LIKE ?', "#{name}%").first
|
||||
end
|
||||
end
|
||||
|
||||
def scm_entries(path=nil, identifier=nil)
|
||||
scm.entries(path, identifier, :report_last_commit => report_last_commit)
|
||||
end
|
||||
protected :scm_entries
|
||||
|
||||
# With SCMs that have a sequential commit numbering,
|
||||
# such as Subversion and Mercurial,
|
||||
# Redmine is able to be clever and only fetch changesets
|
||||
# going forward from the most recent one it knows about.
|
||||
#
|
||||
# However, Git does not have a sequential commit numbering.
|
||||
#
|
||||
# In order to fetch only new adding revisions,
|
||||
# Redmine needs to save "heads".
|
||||
#
|
||||
# In Git and Mercurial, revisions are not in date order.
|
||||
# Redmine Mercurial fixed issues.
|
||||
# * Redmine Takes Too Long On Large Mercurial Repository
|
||||
# http://www.redmine.org/issues/3449
|
||||
# * Sorting for changesets might go wrong on Mercurial repos
|
||||
# http://www.redmine.org/issues/3567
|
||||
#
|
||||
# Database revision column is text, so Redmine can not sort by revision.
|
||||
# Mercurial has revision number, and revision number guarantees revision order.
|
||||
# Redmine Mercurial model stored revisions ordered by database id to database.
|
||||
# So, Redmine Mercurial model can use correct ordering revisions.
|
||||
#
|
||||
# Redmine Mercurial adapter uses "hg log -r 0:tip --limit 10"
|
||||
# to get limited revisions from old to new.
|
||||
# But, Git 1.7.3.4 does not support --reverse with -n or --skip.
|
||||
#
|
||||
# The repository can still be fully reloaded by calling #clear_changesets
|
||||
# before fetching changesets (eg. for offline resync)
|
||||
def fetch_changesets
|
||||
scm_brs = branches
|
||||
return if scm_brs.nil? || scm_brs.empty?
|
||||
|
||||
h1 = extra_info || {}
|
||||
h = h1.dup
|
||||
repo_heads = scm_brs.map{ |br| br.scmid }
|
||||
h["heads"] ||= []
|
||||
prev_db_heads = h["heads"].dup
|
||||
if prev_db_heads.empty?
|
||||
prev_db_heads += heads_from_branches_hash
|
||||
end
|
||||
return if prev_db_heads.sort == repo_heads.sort
|
||||
|
||||
h["db_consistent"] ||= {}
|
||||
if ! changesets.exists?
|
||||
h["db_consistent"]["ordering"] = 1
|
||||
merge_extra_info(h)
|
||||
self.save
|
||||
elsif ! h["db_consistent"].has_key?("ordering")
|
||||
h["db_consistent"]["ordering"] = 0
|
||||
merge_extra_info(h)
|
||||
self.save
|
||||
end
|
||||
save_revisions(prev_db_heads, repo_heads)
|
||||
end
|
||||
|
||||
def save_revisions(prev_db_heads, repo_heads)
|
||||
h = {}
|
||||
opts = {}
|
||||
opts[:reverse] = true
|
||||
opts[:excludes] = prev_db_heads
|
||||
opts[:includes] = repo_heads
|
||||
|
||||
revisions = scm.revisions('', nil, nil, opts)
|
||||
return if revisions.blank?
|
||||
|
||||
# Make the search for existing revisions in the database in a more sufficient manner
|
||||
#
|
||||
# Git branch is the reference to the specific revision.
|
||||
# Git can *delete* remote branch and *re-push* branch.
|
||||
#
|
||||
# $ git push remote :branch
|
||||
# $ git push remote branch
|
||||
#
|
||||
# After deleting branch, revisions remain in repository until "git gc".
|
||||
# On git 1.7.2.3, default pruning date is 2 weeks.
|
||||
# So, "git log --not deleted_branch_head_revision" return code is 0.
|
||||
#
|
||||
# After re-pushing branch, "git log" returns revisions which are saved in database.
|
||||
# So, Redmine needs to scan revisions and database every time.
|
||||
#
|
||||
# This is replacing the one-after-one queries.
|
||||
# Find all revisions, that are in the database, and then remove them
|
||||
# from the revision array.
|
||||
# Then later we won't need any conditions for db existence.
|
||||
# Query for several revisions at once, and remove them
|
||||
# from the revisions array, if they are there.
|
||||
# Do this in chunks, to avoid eventual memory problems
|
||||
# (in case of tens of thousands of commits).
|
||||
# If there are no revisions (because the original code's algorithm filtered them),
|
||||
# then this part will be stepped over.
|
||||
# We make queries, just if there is any revision.
|
||||
limit = 100
|
||||
offset = 0
|
||||
revisions_copy = revisions.clone # revisions will change
|
||||
while offset < revisions_copy.size
|
||||
scmids = revisions_copy.slice(offset, limit).map{|x| x.scmid}
|
||||
recent_changesets_slice = changesets.where(:scmid => scmids)
|
||||
# Subtract revisions that redmine already knows about
|
||||
recent_revisions = recent_changesets_slice.map{|c| c.scmid}
|
||||
revisions.reject!{|r| recent_revisions.include?(r.scmid)}
|
||||
offset += limit
|
||||
end
|
||||
revisions.each do |rev|
|
||||
transaction do
|
||||
# There is no search in the db for this revision, because above we ensured,
|
||||
# that it's not in the db.
|
||||
save_revision(rev)
|
||||
end
|
||||
end
|
||||
h["heads"] = repo_heads.dup
|
||||
merge_extra_info(h)
|
||||
save(:validate => false)
|
||||
end
|
||||
private :save_revisions
|
||||
|
||||
def save_revision(rev)
|
||||
parents = (rev.parents || []).collect{|rp| find_changeset_by_name(rp)}.compact
|
||||
changeset = Changeset.create(
|
||||
:repository => self,
|
||||
:revision => rev.identifier,
|
||||
:scmid => rev.scmid,
|
||||
:committer => rev.author,
|
||||
:committed_on => rev.time,
|
||||
:comments => rev.message,
|
||||
:parents => parents
|
||||
)
|
||||
unless changeset.new_record?
|
||||
rev.paths.each { |change| changeset.create_change(change) }
|
||||
end
|
||||
changeset
|
||||
end
|
||||
private :save_revision
|
||||
|
||||
def heads_from_branches_hash
|
||||
h1 = extra_info || {}
|
||||
h = h1.dup
|
||||
h["branches"] ||= {}
|
||||
h['branches'].map{|br, hs| hs['last_scmid']}
|
||||
end
|
||||
|
||||
def latest_changesets(path,rev,limit=10)
|
||||
revisions = scm.revisions(path, nil, rev, :limit => limit, :all => false)
|
||||
return [] if revisions.nil? || revisions.empty?
|
||||
changesets.where(:scmid => revisions.map {|c| c.scmid}).to_a
|
||||
end
|
||||
|
||||
def clear_extra_info_of_changesets
|
||||
return if extra_info.nil?
|
||||
v = extra_info["extra_report_last_commit"]
|
||||
write_attribute(:extra_info, nil)
|
||||
h = {}
|
||||
h["extra_report_last_commit"] = v
|
||||
merge_extra_info(h)
|
||||
save(:validate => false)
|
||||
end
|
||||
private :clear_extra_info_of_changesets
|
||||
|
||||
def clear_changesets
|
||||
super
|
||||
clear_extra_info_of_changesets
|
||||
end
|
||||
private :clear_changesets
|
||||
end
|
211
app/models/repository/mercurial.rb
Normal file
211
app/models/repository/mercurial.rb
Normal file
|
@ -0,0 +1,211 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
require 'redmine/scm/adapters/mercurial_adapter'
|
||||
|
||||
class Repository::Mercurial < Repository
|
||||
# sort changesets by revision number
|
||||
has_many :changesets,
|
||||
lambda {order("#{Changeset.table_name}.id DESC")},
|
||||
:foreign_key => 'repository_id'
|
||||
|
||||
attr_protected :root_url
|
||||
validates_presence_of :url
|
||||
|
||||
# number of changesets to fetch at once
|
||||
FETCH_AT_ONCE = 100
|
||||
|
||||
def self.human_attribute_name(attribute_key_name, *args)
|
||||
attr_name = attribute_key_name.to_s
|
||||
if attr_name == "url"
|
||||
attr_name = "path_to_repository"
|
||||
end
|
||||
super(attr_name, *args)
|
||||
end
|
||||
|
||||
def self.scm_adapter_class
|
||||
Redmine::Scm::Adapters::MercurialAdapter
|
||||
end
|
||||
|
||||
def self.scm_name
|
||||
'Mercurial'
|
||||
end
|
||||
|
||||
def supports_directory_revisions?
|
||||
true
|
||||
end
|
||||
|
||||
def supports_revision_graph?
|
||||
true
|
||||
end
|
||||
|
||||
def repo_log_encoding
|
||||
'UTF-8'
|
||||
end
|
||||
|
||||
# Returns the readable identifier for the given mercurial changeset
|
||||
def self.format_changeset_identifier(changeset)
|
||||
"#{changeset.revision}:#{changeset.scmid[0, 12]}"
|
||||
end
|
||||
|
||||
# Returns the identifier for the given Mercurial changeset
|
||||
def self.changeset_identifier(changeset)
|
||||
changeset.scmid
|
||||
end
|
||||
|
||||
def diff_format_revisions(cs, cs_to, sep=':')
|
||||
super(cs, cs_to, ' ')
|
||||
end
|
||||
|
||||
def modify_entry_lastrev_identifier(entry)
|
||||
if entry.lastrev && entry.lastrev.identifier
|
||||
entry.lastrev.identifier = scmid_for_inserting_db(entry.lastrev.identifier)
|
||||
end
|
||||
end
|
||||
private :modify_entry_lastrev_identifier
|
||||
|
||||
def entry(path=nil, identifier=nil)
|
||||
entry = scm.entry(path, identifier)
|
||||
return nil if entry.nil?
|
||||
modify_entry_lastrev_identifier(entry)
|
||||
entry
|
||||
end
|
||||
|
||||
def scm_entries(path=nil, identifier=nil)
|
||||
entries = scm.entries(path, identifier)
|
||||
return nil if entries.nil?
|
||||
entries.each {|entry| modify_entry_lastrev_identifier(entry)}
|
||||
entries
|
||||
end
|
||||
protected :scm_entries
|
||||
|
||||
# Finds and returns a revision with a number or the beginning of a hash
|
||||
def find_changeset_by_name(name)
|
||||
return nil if name.blank?
|
||||
s = name.to_s
|
||||
if /[^\d]/ =~ s or s.size > 8
|
||||
cs = changesets.where(:scmid => s).first
|
||||
else
|
||||
cs = changesets.where(:revision => s).first
|
||||
end
|
||||
return cs if cs
|
||||
changesets.where('scmid LIKE ?', "#{s}%").first
|
||||
end
|
||||
|
||||
# Returns the latest changesets for +path+; sorted by revision number
|
||||
#
|
||||
# Because :order => 'id DESC' is defined at 'has_many',
|
||||
# there is no need to set 'order'.
|
||||
# But, MySQL test fails.
|
||||
# Sqlite3 and PostgreSQL pass.
|
||||
# Is this MySQL bug?
|
||||
def latest_changesets(path, rev, limit=10)
|
||||
changesets.
|
||||
includes(:user).
|
||||
where(latest_changesets_cond(path, rev, limit)).
|
||||
references(:user).
|
||||
limit(limit).
|
||||
order("#{Changeset.table_name}.id DESC").
|
||||
to_a
|
||||
end
|
||||
|
||||
def is_short_id_in_db?
|
||||
return @is_short_id_in_db unless @is_short_id_in_db.nil?
|
||||
cs = changesets.first
|
||||
@is_short_id_in_db = (!cs.nil? && cs.scmid.length != 40)
|
||||
end
|
||||
private :is_short_id_in_db?
|
||||
|
||||
def scmid_for_inserting_db(scmid)
|
||||
is_short_id_in_db? ? scmid[0, 12] : scmid
|
||||
end
|
||||
|
||||
def nodes_in_branch(rev, branch_limit)
|
||||
scm.nodes_in_branch(rev, :limit => branch_limit).collect do |b|
|
||||
scmid_for_inserting_db(b)
|
||||
end
|
||||
end
|
||||
|
||||
def tag_scmid(rev)
|
||||
scmid = scm.tagmap[rev]
|
||||
scmid.nil? ? nil : scmid_for_inserting_db(scmid)
|
||||
end
|
||||
|
||||
def latest_changesets_cond(path, rev, limit)
|
||||
cond, args = [], []
|
||||
if scm.branchmap.member? rev
|
||||
# Mercurial named branch is *stable* in each revision.
|
||||
# So, named branch can be stored in database.
|
||||
# Mercurial provides *bookmark* which is equivalent with git branch.
|
||||
# But, bookmark is not implemented.
|
||||
cond << "#{Changeset.table_name}.scmid IN (?)"
|
||||
# Revisions in root directory and sub directory are not equal.
|
||||
# So, in order to get correct limit, we need to get all revisions.
|
||||
# But, it is very heavy.
|
||||
# Mercurial does not treat directory.
|
||||
# So, "hg log DIR" is very heavy.
|
||||
branch_limit = path.blank? ? limit : ( limit * 5 )
|
||||
args << nodes_in_branch(rev, branch_limit)
|
||||
elsif last = rev ? find_changeset_by_name(tag_scmid(rev) || rev) : nil
|
||||
cond << "#{Changeset.table_name}.id <= ?"
|
||||
args << last.id
|
||||
end
|
||||
unless path.blank?
|
||||
cond << "EXISTS (SELECT * FROM #{Change.table_name}
|
||||
WHERE #{Change.table_name}.changeset_id = #{Changeset.table_name}.id
|
||||
AND (#{Change.table_name}.path = ?
|
||||
OR #{Change.table_name}.path LIKE ? ESCAPE ?))"
|
||||
args << path.with_leading_slash
|
||||
args << "#{path.with_leading_slash.gsub(%r{[%_\\]}) { |s| "\\#{s}" }}/%" << '\\'
|
||||
end
|
||||
[cond.join(' AND '), *args] unless cond.empty?
|
||||
end
|
||||
private :latest_changesets_cond
|
||||
|
||||
def fetch_changesets
|
||||
return if scm.info.nil?
|
||||
scm_rev = scm.info.lastrev.revision.to_i
|
||||
db_rev = latest_changeset ? latest_changeset.revision.to_i : -1
|
||||
return unless db_rev < scm_rev # already up-to-date
|
||||
|
||||
logger.debug "Fetching changesets for repository #{url}" if logger
|
||||
(db_rev + 1).step(scm_rev, FETCH_AT_ONCE) do |i|
|
||||
scm.each_revision('', i, [i + FETCH_AT_ONCE - 1, scm_rev].min) do |re|
|
||||
transaction do
|
||||
parents = (re.parents || []).collect do |rp|
|
||||
find_changeset_by_name(scmid_for_inserting_db(rp))
|
||||
end.compact
|
||||
cs = Changeset.create(:repository => self,
|
||||
:revision => re.revision,
|
||||
:scmid => scmid_for_inserting_db(re.scmid),
|
||||
:committer => re.author,
|
||||
:committed_on => re.time,
|
||||
:comments => re.message,
|
||||
:parents => parents)
|
||||
unless cs.new_record?
|
||||
re.paths.each do |e|
|
||||
if from_revision = e[:from_revision]
|
||||
e[:from_revision] = scmid_for_inserting_db(from_revision)
|
||||
end
|
||||
cs.create_change(e)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
117
app/models/repository/subversion.rb
Normal file
117
app/models/repository/subversion.rb
Normal file
|
@ -0,0 +1,117 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
require 'redmine/scm/adapters/subversion_adapter'
|
||||
|
||||
class Repository::Subversion < Repository
|
||||
attr_protected :root_url
|
||||
validates_presence_of :url
|
||||
validates_format_of :url, :with => %r{\A(http|https|svn(\+[^\s:\/\\]+)?|file):\/\/.+}i
|
||||
|
||||
def self.scm_adapter_class
|
||||
Redmine::Scm::Adapters::SubversionAdapter
|
||||
end
|
||||
|
||||
def self.scm_name
|
||||
'Subversion'
|
||||
end
|
||||
|
||||
def supports_directory_revisions?
|
||||
true
|
||||
end
|
||||
|
||||
def repo_log_encoding
|
||||
'UTF-8'
|
||||
end
|
||||
|
||||
def latest_changesets(path, rev, limit=10)
|
||||
revisions = scm.revisions(path, rev, nil, :limit => limit)
|
||||
if revisions
|
||||
identifiers = revisions.collect(&:identifier).compact
|
||||
changesets.where(:revision => identifiers).reorder("committed_on DESC").includes(:repository, :user).to_a
|
||||
else
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
# Returns a path relative to the url of the repository
|
||||
def relative_path(path)
|
||||
path.gsub(Regexp.new("^\/?#{Regexp.escape(relative_url)}"), '')
|
||||
end
|
||||
|
||||
def fetch_changesets
|
||||
scm_info = scm.info
|
||||
if scm_info
|
||||
# latest revision found in database
|
||||
db_revision = latest_changeset ? latest_changeset.revision.to_i : 0
|
||||
# latest revision in the repository
|
||||
scm_revision = scm_info.lastrev.identifier.to_i
|
||||
if db_revision < scm_revision
|
||||
logger.debug "Fetching changesets for repository #{url}" if logger && logger.debug?
|
||||
identifier_from = db_revision + 1
|
||||
while (identifier_from <= scm_revision)
|
||||
# loads changesets by batches of 200
|
||||
identifier_to = [identifier_from + 199, scm_revision].min
|
||||
revisions = scm.revisions('', identifier_to, identifier_from, :with_paths => true)
|
||||
revisions.reverse_each do |revision|
|
||||
transaction do
|
||||
changeset = Changeset.create(:repository => self,
|
||||
:revision => revision.identifier,
|
||||
:committer => revision.author,
|
||||
:committed_on => revision.time,
|
||||
:comments => revision.message)
|
||||
|
||||
revision.paths.each do |change|
|
||||
changeset.create_change(change)
|
||||
end unless changeset.new_record?
|
||||
end
|
||||
end unless revisions.nil?
|
||||
identifier_from = identifier_to + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def load_entries_changesets(entries)
|
||||
return unless entries
|
||||
entries_with_identifier =
|
||||
entries.select {|entry| entry.lastrev && entry.lastrev.identifier.present?}
|
||||
identifiers = entries_with_identifier.map {|entry| entry.lastrev.identifier}.compact.uniq
|
||||
if identifiers.any?
|
||||
changesets_by_identifier =
|
||||
changesets.where(:revision => identifiers).
|
||||
includes(:user, :repository).group_by(&:revision)
|
||||
entries_with_identifier.each do |entry|
|
||||
if m = changesets_by_identifier[entry.lastrev.identifier]
|
||||
entry.changeset = m.first
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Returns the relative url of the repository
|
||||
# Eg: root_url = file:///var/svn/foo
|
||||
# url = file:///var/svn/foo/bar
|
||||
# => returns /bar
|
||||
def relative_url
|
||||
@relative_url ||= url.gsub(Regexp.new("^#{Regexp.escape(root_url || scm.root_url)}", Regexp::IGNORECASE), '')
|
||||
end
|
||||
end
|
311
app/models/role.rb
Normal file
311
app/models/role.rb
Normal file
|
@ -0,0 +1,311 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
class Role < ActiveRecord::Base
|
||||
include Redmine::SafeAttributes
|
||||
|
||||
# Custom coder for the permissions attribute that should be an
|
||||
# array of symbols. Rails 3 uses Psych which can be *unbelievably*
|
||||
# slow on some platforms (eg. mingw32).
|
||||
class PermissionsAttributeCoder
|
||||
def self.load(str)
|
||||
str.to_s.scan(/:([a-z0-9_]+)/).flatten.map(&:to_sym)
|
||||
end
|
||||
|
||||
def self.dump(value)
|
||||
YAML.dump(value)
|
||||
end
|
||||
end
|
||||
|
||||
# Built-in roles
|
||||
BUILTIN_NON_MEMBER = 1
|
||||
BUILTIN_ANONYMOUS = 2
|
||||
|
||||
ISSUES_VISIBILITY_OPTIONS = [
|
||||
['all', :label_issues_visibility_all],
|
||||
['default', :label_issues_visibility_public],
|
||||
['own', :label_issues_visibility_own]
|
||||
]
|
||||
|
||||
TIME_ENTRIES_VISIBILITY_OPTIONS = [
|
||||
['all', :label_time_entries_visibility_all],
|
||||
['own', :label_time_entries_visibility_own]
|
||||
]
|
||||
|
||||
USERS_VISIBILITY_OPTIONS = [
|
||||
['all', :label_users_visibility_all],
|
||||
['members_of_visible_projects', :label_users_visibility_members_of_visible_projects]
|
||||
]
|
||||
|
||||
scope :sorted, lambda { order(:builtin, :position) }
|
||||
scope :givable, lambda { order(:position).where(:builtin => 0) }
|
||||
scope :builtin, lambda { |*args|
|
||||
compare = (args.first == true ? 'not' : '')
|
||||
where("#{compare} builtin = 0")
|
||||
}
|
||||
|
||||
before_destroy :check_deletable
|
||||
has_many :workflow_rules, :dependent => :delete_all do
|
||||
def copy(source_role)
|
||||
ActiveSupport::Deprecation.warn "role.workflow_rules.copy is deprecated and will be removed in Redmine 4.0, use role.copy_worflow_rules instead"
|
||||
proxy_association.owner.copy_workflow_rules(source_role)
|
||||
end
|
||||
end
|
||||
has_and_belongs_to_many :custom_fields, :join_table => "#{table_name_prefix}custom_fields_roles#{table_name_suffix}", :foreign_key => "role_id"
|
||||
|
||||
has_and_belongs_to_many :managed_roles, :class_name => 'Role',
|
||||
:join_table => "#{table_name_prefix}roles_managed_roles#{table_name_suffix}",
|
||||
:association_foreign_key => "managed_role_id"
|
||||
|
||||
has_many :member_roles, :dependent => :destroy
|
||||
has_many :members, :through => :member_roles
|
||||
acts_as_positioned :scope => :builtin
|
||||
|
||||
serialize :permissions, ::Role::PermissionsAttributeCoder
|
||||
store :settings, :accessors => [:permissions_all_trackers, :permissions_tracker_ids]
|
||||
attr_protected :builtin
|
||||
|
||||
validates_presence_of :name
|
||||
validates_uniqueness_of :name
|
||||
validates_length_of :name, :maximum => 30
|
||||
validates_inclusion_of :issues_visibility,
|
||||
:in => ISSUES_VISIBILITY_OPTIONS.collect(&:first),
|
||||
:if => lambda {|role| role.respond_to?(:issues_visibility) && role.issues_visibility_changed?}
|
||||
validates_inclusion_of :users_visibility,
|
||||
:in => USERS_VISIBILITY_OPTIONS.collect(&:first),
|
||||
:if => lambda {|role| role.respond_to?(:users_visibility) && role.users_visibility_changed?}
|
||||
validates_inclusion_of :time_entries_visibility,
|
||||
:in => TIME_ENTRIES_VISIBILITY_OPTIONS.collect(&:first),
|
||||
:if => lambda {|role| role.respond_to?(:time_entries_visibility) && role.time_entries_visibility_changed?}
|
||||
|
||||
safe_attributes 'name',
|
||||
'assignable',
|
||||
'position',
|
||||
'issues_visibility',
|
||||
'users_visibility',
|
||||
'time_entries_visibility',
|
||||
'all_roles_managed',
|
||||
'managed_role_ids',
|
||||
'permissions',
|
||||
'permissions_all_trackers',
|
||||
'permissions_tracker_ids'
|
||||
|
||||
# Copies attributes from another role, arg can be an id or a Role
|
||||
def copy_from(arg, options={})
|
||||
return unless arg.present?
|
||||
role = arg.is_a?(Role) ? arg : Role.find_by_id(arg.to_s)
|
||||
self.attributes = role.attributes.dup.except("id", "name", "position", "builtin", "permissions")
|
||||
self.permissions = role.permissions.dup
|
||||
self.managed_role_ids = role.managed_role_ids.dup
|
||||
self
|
||||
end
|
||||
|
||||
def permissions=(perms)
|
||||
perms = perms.collect {|p| p.to_sym unless p.blank? }.compact.uniq if perms
|
||||
write_attribute(:permissions, perms)
|
||||
end
|
||||
|
||||
def add_permission!(*perms)
|
||||
self.permissions = [] unless permissions.is_a?(Array)
|
||||
|
||||
permissions_will_change!
|
||||
perms.each do |p|
|
||||
p = p.to_sym
|
||||
permissions << p unless permissions.include?(p)
|
||||
end
|
||||
save!
|
||||
end
|
||||
|
||||
def remove_permission!(*perms)
|
||||
return unless permissions.is_a?(Array)
|
||||
permissions_will_change!
|
||||
perms.each { |p| permissions.delete(p.to_sym) }
|
||||
save!
|
||||
end
|
||||
|
||||
# Returns true if the role has the given permission
|
||||
def has_permission?(perm)
|
||||
!permissions.nil? && permissions.include?(perm.to_sym)
|
||||
end
|
||||
|
||||
def consider_workflow?
|
||||
has_permission?(:add_issues) || has_permission?(:edit_issues)
|
||||
end
|
||||
|
||||
def <=>(role)
|
||||
if role
|
||||
if builtin == role.builtin
|
||||
position <=> role.position
|
||||
else
|
||||
builtin <=> role.builtin
|
||||
end
|
||||
else
|
||||
-1
|
||||
end
|
||||
end
|
||||
|
||||
def to_s
|
||||
name
|
||||
end
|
||||
|
||||
def name
|
||||
case builtin
|
||||
when 1; l(:label_role_non_member, :default => read_attribute(:name))
|
||||
when 2; l(:label_role_anonymous, :default => read_attribute(:name))
|
||||
else; read_attribute(:name)
|
||||
end
|
||||
end
|
||||
|
||||
# Return true if the role is a builtin role
|
||||
def builtin?
|
||||
self.builtin != 0
|
||||
end
|
||||
|
||||
# Return true if the role is the anonymous role
|
||||
def anonymous?
|
||||
builtin == 2
|
||||
end
|
||||
|
||||
# Return true if the role is a project member role
|
||||
def member?
|
||||
!self.builtin?
|
||||
end
|
||||
|
||||
# Return true if role is allowed to do the specified action
|
||||
# action can be:
|
||||
# * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
|
||||
# * a permission Symbol (eg. :edit_project)
|
||||
def allowed_to?(action)
|
||||
if action.is_a? Hash
|
||||
allowed_actions.include? "#{action[:controller]}/#{action[:action]}"
|
||||
else
|
||||
allowed_permissions.include? action
|
||||
end
|
||||
end
|
||||
|
||||
# Return all the permissions that can be given to the role
|
||||
def setable_permissions
|
||||
setable_permissions = Redmine::AccessControl.permissions - Redmine::AccessControl.public_permissions
|
||||
setable_permissions -= Redmine::AccessControl.members_only_permissions if self.builtin == BUILTIN_NON_MEMBER
|
||||
setable_permissions -= Redmine::AccessControl.loggedin_only_permissions if self.builtin == BUILTIN_ANONYMOUS
|
||||
setable_permissions
|
||||
end
|
||||
|
||||
def permissions_tracker_ids(*args)
|
||||
if args.any?
|
||||
Array(permissions_tracker_ids[args.first.to_s]).map(&:to_i)
|
||||
else
|
||||
super || {}
|
||||
end
|
||||
end
|
||||
|
||||
def permissions_tracker_ids=(arg)
|
||||
h = arg.to_hash
|
||||
h.values.each {|v| v.reject!(&:blank?)}
|
||||
super(h)
|
||||
end
|
||||
|
||||
# Returns true if tracker_id belongs to the list of
|
||||
# trackers for which permission is given
|
||||
def permissions_tracker_ids?(permission, tracker_id)
|
||||
permissions_tracker_ids(permission).include?(tracker_id)
|
||||
end
|
||||
|
||||
def permissions_all_trackers
|
||||
super || {}
|
||||
end
|
||||
|
||||
def permissions_all_trackers=(arg)
|
||||
super(arg.to_hash)
|
||||
end
|
||||
|
||||
# Returns true if permission is given for all trackers
|
||||
def permissions_all_trackers?(permission)
|
||||
permissions_all_trackers[permission.to_s].to_s != '0'
|
||||
end
|
||||
|
||||
# Returns true if permission is given for the tracker
|
||||
# (explicitly or for all trackers)
|
||||
def permissions_tracker?(permission, tracker)
|
||||
permissions_all_trackers?(permission) ||
|
||||
permissions_tracker_ids?(permission, tracker.try(:id))
|
||||
end
|
||||
|
||||
# Sets the trackers that are allowed for a permission.
|
||||
# tracker_ids can be an array of tracker ids or :all for
|
||||
# no restrictions.
|
||||
#
|
||||
# Examples:
|
||||
# role.set_permission_trackers :add_issues, [1, 3]
|
||||
# role.set_permission_trackers :add_issues, :all
|
||||
def set_permission_trackers(permission, tracker_ids)
|
||||
h = {permission.to_s => (tracker_ids == :all ? '1' : '0')}
|
||||
self.permissions_all_trackers = permissions_all_trackers.merge(h)
|
||||
|
||||
h = {permission.to_s => (tracker_ids == :all ? [] : tracker_ids)}
|
||||
self.permissions_tracker_ids = permissions_tracker_ids.merge(h)
|
||||
|
||||
self
|
||||
end
|
||||
|
||||
def copy_workflow_rules(source_role)
|
||||
WorkflowRule.copy(nil, source_role, nil, self)
|
||||
end
|
||||
|
||||
# Find all the roles that can be given to a project member
|
||||
def self.find_all_givable
|
||||
Role.givable.to_a
|
||||
end
|
||||
|
||||
# Return the builtin 'non member' role. If the role doesn't exist,
|
||||
# it will be created on the fly.
|
||||
def self.non_member
|
||||
find_or_create_system_role(BUILTIN_NON_MEMBER, 'Non member')
|
||||
end
|
||||
|
||||
# Return the builtin 'anonymous' role. If the role doesn't exist,
|
||||
# it will be created on the fly.
|
||||
def self.anonymous
|
||||
find_or_create_system_role(BUILTIN_ANONYMOUS, 'Anonymous')
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def allowed_permissions
|
||||
@allowed_permissions ||= permissions + Redmine::AccessControl.public_permissions.collect {|p| p.name}
|
||||
end
|
||||
|
||||
def allowed_actions
|
||||
@actions_allowed ||= allowed_permissions.inject([]) { |actions, permission| actions += Redmine::AccessControl.allowed_actions(permission) }.flatten
|
||||
end
|
||||
|
||||
def check_deletable
|
||||
raise "Cannot delete role" if members.any?
|
||||
raise "Cannot delete builtin role" if builtin?
|
||||
end
|
||||
|
||||
def self.find_or_create_system_role(builtin, name)
|
||||
role = unscoped.where(:builtin => builtin).first
|
||||
if role.nil?
|
||||
role = unscoped.create(:name => name) do |r|
|
||||
r.builtin = builtin
|
||||
end
|
||||
raise "Unable to create the #{name} role (#{role.errors.full_messages.join(',')})." if role.new_record?
|
||||
end
|
||||
role
|
||||
end
|
||||
end
|
321
app/models/setting.rb
Normal file
321
app/models/setting.rb
Normal file
|
@ -0,0 +1,321 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
class Setting < ActiveRecord::Base
|
||||
|
||||
DATE_FORMATS = [
|
||||
'%Y-%m-%d',
|
||||
'%d/%m/%Y',
|
||||
'%d.%m.%Y',
|
||||
'%d-%m-%Y',
|
||||
'%m/%d/%Y',
|
||||
'%d %b %Y',
|
||||
'%d %B %Y',
|
||||
'%b %d, %Y',
|
||||
'%B %d, %Y'
|
||||
]
|
||||
|
||||
TIME_FORMATS = [
|
||||
'%H:%M',
|
||||
'%I:%M %p'
|
||||
]
|
||||
|
||||
ENCODINGS = %w(US-ASCII
|
||||
windows-1250
|
||||
windows-1251
|
||||
windows-1252
|
||||
windows-1253
|
||||
windows-1254
|
||||
windows-1255
|
||||
windows-1256
|
||||
windows-1257
|
||||
windows-1258
|
||||
windows-31j
|
||||
ISO-2022-JP
|
||||
ISO-2022-KR
|
||||
ISO-8859-1
|
||||
ISO-8859-2
|
||||
ISO-8859-3
|
||||
ISO-8859-4
|
||||
ISO-8859-5
|
||||
ISO-8859-6
|
||||
ISO-8859-7
|
||||
ISO-8859-8
|
||||
ISO-8859-9
|
||||
ISO-8859-13
|
||||
ISO-8859-15
|
||||
KOI8-R
|
||||
UTF-8
|
||||
UTF-16
|
||||
UTF-16BE
|
||||
UTF-16LE
|
||||
EUC-JP
|
||||
Shift_JIS
|
||||
CP932
|
||||
GB18030
|
||||
GBK
|
||||
ISCII91
|
||||
EUC-KR
|
||||
Big5
|
||||
Big5-HKSCS
|
||||
TIS-620)
|
||||
|
||||
cattr_accessor :available_settings
|
||||
self.available_settings ||= {}
|
||||
|
||||
validates_uniqueness_of :name, :if => Proc.new {|setting| setting.new_record? || setting.name_changed?}
|
||||
validates_inclusion_of :name, :in => Proc.new {available_settings.keys}
|
||||
validates_numericality_of :value, :only_integer => true, :if => Proc.new { |setting|
|
||||
(s = available_settings[setting.name]) && s['format'] == 'int'
|
||||
}
|
||||
attr_protected :id
|
||||
|
||||
# Hash used to cache setting values
|
||||
@cached_settings = {}
|
||||
@cached_cleared_on = Time.now
|
||||
|
||||
def value
|
||||
v = read_attribute(:value)
|
||||
# Unserialize serialized settings
|
||||
if available_settings[name]['serialized'] && v.is_a?(String)
|
||||
v = YAML::load(v)
|
||||
v = force_utf8_strings(v)
|
||||
end
|
||||
v = v.to_sym if available_settings[name]['format'] == 'symbol' && !v.blank?
|
||||
v
|
||||
end
|
||||
|
||||
def value=(v)
|
||||
v = v.to_yaml if v && available_settings[name] && available_settings[name]['serialized']
|
||||
write_attribute(:value, v.to_s)
|
||||
end
|
||||
|
||||
# Returns the value of the setting named name
|
||||
def self.[](name)
|
||||
v = @cached_settings[name]
|
||||
v ? v : (@cached_settings[name] = find_or_default(name).value)
|
||||
end
|
||||
|
||||
def self.[]=(name, v)
|
||||
setting = find_or_default(name)
|
||||
setting.value = (v ? v : "")
|
||||
@cached_settings[name] = nil
|
||||
setting.save
|
||||
setting.value
|
||||
end
|
||||
|
||||
# Updates multiple settings from params and sends a security notification if needed
|
||||
def self.set_all_from_params(settings)
|
||||
return nil unless settings.is_a?(Hash)
|
||||
settings = settings.dup.symbolize_keys
|
||||
|
||||
errors = validate_all_from_params(settings)
|
||||
return errors if errors.present?
|
||||
|
||||
changes = []
|
||||
settings.each do |name, value|
|
||||
next unless available_settings[name.to_s]
|
||||
previous_value = Setting[name]
|
||||
set_from_params name, value
|
||||
if available_settings[name.to_s]['security_notifications'] && Setting[name] != previous_value
|
||||
changes << name
|
||||
end
|
||||
end
|
||||
if changes.any?
|
||||
Mailer.security_settings_updated(changes)
|
||||
end
|
||||
nil
|
||||
end
|
||||
|
||||
def self.validate_all_from_params(settings)
|
||||
messages = []
|
||||
|
||||
if settings.key?(:mail_handler_body_delimiters) || settings.key?(:mail_handler_enable_regex_delimiters)
|
||||
regexp = Setting.mail_handler_enable_regex_delimiters?
|
||||
if settings.key?(:mail_handler_enable_regex_delimiters)
|
||||
regexp = settings[:mail_handler_enable_regex_delimiters].to_s != '0'
|
||||
end
|
||||
if regexp
|
||||
settings[:mail_handler_body_delimiters].to_s.split(/[\r\n]+/).each do |delimiter|
|
||||
begin
|
||||
Regexp.new(delimiter)
|
||||
rescue RegexpError => e
|
||||
messages << [:mail_handler_body_delimiters, "#{l('activerecord.errors.messages.not_a_regexp')} (#{e.message})"]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
messages
|
||||
end
|
||||
|
||||
# Sets a setting value from params
|
||||
def self.set_from_params(name, params)
|
||||
params = params.dup
|
||||
params.delete_if {|v| v.blank? } if params.is_a?(Array)
|
||||
params.symbolize_keys! if params.is_a?(Hash)
|
||||
|
||||
m = "#{name}_from_params"
|
||||
if respond_to? m
|
||||
self[name.to_sym] = send m, params
|
||||
else
|
||||
self[name.to_sym] = params
|
||||
end
|
||||
end
|
||||
|
||||
# Returns a hash suitable for commit_update_keywords setting
|
||||
#
|
||||
# Example:
|
||||
# params = {:keywords => ['fixes', 'closes'], :status_id => ["3", "5"], :done_ratio => ["", "100"]}
|
||||
# Setting.commit_update_keywords_from_params(params)
|
||||
# # => [{'keywords => 'fixes', 'status_id' => "3"}, {'keywords => 'closes', 'status_id' => "5", 'done_ratio' => "100"}]
|
||||
def self.commit_update_keywords_from_params(params)
|
||||
s = []
|
||||
if params.is_a?(Hash) && params.key?(:keywords) && params.values.all? {|v| v.is_a? Array}
|
||||
attributes = params.except(:keywords).keys
|
||||
params[:keywords].each_with_index do |keywords, i|
|
||||
next if keywords.blank?
|
||||
s << attributes.inject({}) {|h, a|
|
||||
value = params[a][i].to_s
|
||||
h[a.to_s] = value if value.present?
|
||||
h
|
||||
}.merge('keywords' => keywords)
|
||||
end
|
||||
end
|
||||
s
|
||||
end
|
||||
|
||||
# Helper that returns an array based on per_page_options setting
|
||||
def self.per_page_options_array
|
||||
per_page_options.split(%r{[\s,]}).collect(&:to_i).select {|n| n > 0}.sort
|
||||
end
|
||||
|
||||
# Helper that returns a Hash with single update keywords as keys
|
||||
def self.commit_update_keywords_array
|
||||
a = []
|
||||
if commit_update_keywords.is_a?(Array)
|
||||
commit_update_keywords.each do |rule|
|
||||
next unless rule.is_a?(Hash)
|
||||
rule = rule.dup
|
||||
rule.delete_if {|k, v| v.blank?}
|
||||
keywords = rule['keywords'].to_s.downcase.split(",").map(&:strip).reject(&:blank?)
|
||||
next if keywords.empty?
|
||||
a << rule.merge('keywords' => keywords)
|
||||
end
|
||||
end
|
||||
a
|
||||
end
|
||||
|
||||
def self.openid?
|
||||
Object.const_defined?(:OpenID) && self[:openid].to_i > 0
|
||||
end
|
||||
|
||||
# Checks if settings have changed since the values were read
|
||||
# and clears the cache hash if it's the case
|
||||
# Called once per request
|
||||
def self.check_cache
|
||||
settings_updated_on = Setting.maximum(:updated_on)
|
||||
if settings_updated_on && @cached_cleared_on <= settings_updated_on
|
||||
clear_cache
|
||||
end
|
||||
end
|
||||
|
||||
# Clears the settings cache
|
||||
def self.clear_cache
|
||||
@cached_settings.clear
|
||||
@cached_cleared_on = Time.now
|
||||
logger.info "Settings cache cleared." if logger
|
||||
end
|
||||
|
||||
def self.define_plugin_setting(plugin)
|
||||
if plugin.settings
|
||||
name = "plugin_#{plugin.id}"
|
||||
define_setting name, {'default' => plugin.settings[:default], 'serialized' => true}
|
||||
end
|
||||
end
|
||||
|
||||
# Defines getter and setter for each setting
|
||||
# Then setting values can be read using: Setting.some_setting_name
|
||||
# or set using Setting.some_setting_name = "some value"
|
||||
def self.define_setting(name, options={})
|
||||
available_settings[name.to_s] = options
|
||||
|
||||
src = <<-END_SRC
|
||||
def self.#{name}
|
||||
self[:#{name}]
|
||||
end
|
||||
|
||||
def self.#{name}?
|
||||
self[:#{name}].to_i > 0
|
||||
end
|
||||
|
||||
def self.#{name}=(value)
|
||||
self[:#{name}] = value
|
||||
end
|
||||
END_SRC
|
||||
class_eval src, __FILE__, __LINE__
|
||||
end
|
||||
|
||||
def self.load_available_settings
|
||||
YAML::load(File.open("#{Rails.root}/config/settings.yml")).each do |name, options|
|
||||
define_setting name, options
|
||||
end
|
||||
end
|
||||
|
||||
def self.load_plugin_settings
|
||||
Redmine::Plugin.all.each do |plugin|
|
||||
define_plugin_setting(plugin)
|
||||
end
|
||||
end
|
||||
|
||||
load_available_settings
|
||||
load_plugin_settings
|
||||
|
||||
private
|
||||
|
||||
def force_utf8_strings(arg)
|
||||
if arg.is_a?(String)
|
||||
arg.dup.force_encoding('UTF-8')
|
||||
elsif arg.is_a?(Array)
|
||||
arg.map do |a|
|
||||
force_utf8_strings(a)
|
||||
end
|
||||
elsif arg.is_a?(Hash)
|
||||
arg = arg.dup
|
||||
arg.each do |k,v|
|
||||
arg[k] = force_utf8_strings(v)
|
||||
end
|
||||
arg
|
||||
else
|
||||
arg
|
||||
end
|
||||
end
|
||||
|
||||
# Returns the Setting instance for the setting named name
|
||||
# (record found in database or new record with default value)
|
||||
def self.find_or_default(name)
|
||||
name = name.to_s
|
||||
raise "There's no setting named #{name}" unless available_settings.has_key?(name)
|
||||
setting = where(:name => name).order(:id => :desc).first
|
||||
unless setting
|
||||
setting = new
|
||||
setting.name = name
|
||||
setting.value = available_settings[name]['default']
|
||||
end
|
||||
setting
|
||||
end
|
||||
end
|
169
app/models/time_entry.rb
Normal file
169
app/models/time_entry.rb
Normal file
|
@ -0,0 +1,169 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
class TimeEntry < ActiveRecord::Base
|
||||
include Redmine::SafeAttributes
|
||||
# could have used polymorphic association
|
||||
# project association here allows easy loading of time entries at project level with one database trip
|
||||
belongs_to :project
|
||||
belongs_to :issue
|
||||
belongs_to :user
|
||||
belongs_to :activity, :class_name => 'TimeEntryActivity'
|
||||
|
||||
attr_protected :user_id, :tyear, :tmonth, :tweek
|
||||
|
||||
acts_as_customizable
|
||||
acts_as_event :title => Proc.new { |o|
|
||||
related = o.issue if o.issue && o.issue.visible?
|
||||
related ||= o.project
|
||||
"#{l_hours(o.hours)} (#{related.event_title})"
|
||||
},
|
||||
:url => Proc.new {|o| {:controller => 'timelog', :action => 'index', :project_id => o.project, :issue_id => o.issue}},
|
||||
:author => :user,
|
||||
:group => :issue,
|
||||
:description => :comments
|
||||
|
||||
acts_as_activity_provider :timestamp => "#{table_name}.created_on",
|
||||
:author_key => :user_id,
|
||||
:scope => joins(:project).preload(:project)
|
||||
|
||||
validates_presence_of :user_id, :activity_id, :project_id, :hours, :spent_on
|
||||
validates_presence_of :issue_id, :if => lambda { Setting.timelog_required_fields.include?('issue_id') }
|
||||
validates_presence_of :comments, :if => lambda { Setting.timelog_required_fields.include?('comments') }
|
||||
validates_numericality_of :hours, :allow_nil => true, :message => :invalid
|
||||
validates_length_of :comments, :maximum => 1024, :allow_nil => true
|
||||
validates :spent_on, :date => true
|
||||
before_validation :set_project_if_nil
|
||||
validate :validate_time_entry
|
||||
|
||||
scope :visible, lambda {|*args|
|
||||
joins(:project).
|
||||
where(TimeEntry.visible_condition(args.shift || User.current, *args))
|
||||
}
|
||||
scope :left_join_issue, lambda {
|
||||
joins("LEFT OUTER JOIN #{Issue.table_name} ON #{Issue.table_name}.id = #{TimeEntry.table_name}.issue_id")
|
||||
}
|
||||
scope :on_issue, lambda {|issue|
|
||||
joins(:issue).
|
||||
where("#{Issue.table_name}.root_id = #{issue.root_id} AND #{Issue.table_name}.lft >= #{issue.lft} AND #{Issue.table_name}.rgt <= #{issue.rgt}")
|
||||
}
|
||||
|
||||
safe_attributes 'hours', 'comments', 'project_id', 'issue_id', 'activity_id', 'spent_on', 'custom_field_values', 'custom_fields'
|
||||
|
||||
# Returns a SQL conditions string used to find all time entries visible by the specified user
|
||||
def self.visible_condition(user, options={})
|
||||
Project.allowed_to_condition(user, :view_time_entries, options) do |role, user|
|
||||
if role.time_entries_visibility == 'all'
|
||||
nil
|
||||
elsif role.time_entries_visibility == 'own' && user.id && user.logged?
|
||||
"#{table_name}.user_id = #{user.id}"
|
||||
else
|
||||
'1=0'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Returns true if user or current user is allowed to view the time entry
|
||||
def visible?(user=nil)
|
||||
(user || User.current).allowed_to?(:view_time_entries, self.project) do |role, user|
|
||||
if role.time_entries_visibility == 'all'
|
||||
true
|
||||
elsif role.time_entries_visibility == 'own'
|
||||
self.user == user
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(attributes=nil, *args)
|
||||
super
|
||||
if new_record? && self.activity.nil?
|
||||
if default_activity = TimeEntryActivity.default
|
||||
self.activity_id = default_activity.id
|
||||
end
|
||||
self.hours = nil if hours == 0
|
||||
end
|
||||
end
|
||||
|
||||
def safe_attributes=(attrs, user=User.current)
|
||||
if attrs
|
||||
attrs = super(attrs)
|
||||
if issue_id_changed? && issue
|
||||
if issue.visible?(user) && user.allowed_to?(:log_time, issue.project)
|
||||
if attrs[:project_id].blank? && issue.project_id != project_id
|
||||
self.project_id = issue.project_id
|
||||
end
|
||||
@invalid_issue_id = nil
|
||||
else
|
||||
@invalid_issue_id = issue_id
|
||||
end
|
||||
end
|
||||
end
|
||||
attrs
|
||||
end
|
||||
|
||||
def set_project_if_nil
|
||||
self.project = issue.project if issue && project.nil?
|
||||
end
|
||||
|
||||
def validate_time_entry
|
||||
errors.add :hours, :invalid if hours && (hours < 0 || hours >= 1000)
|
||||
errors.add :project_id, :invalid if project.nil?
|
||||
errors.add :issue_id, :invalid if (issue_id && !issue) || (issue && project!=issue.project) || @invalid_issue_id
|
||||
errors.add :activity_id, :inclusion if activity_id_changed? && project && !project.activities.include?(activity)
|
||||
end
|
||||
|
||||
def hours=(h)
|
||||
write_attribute :hours, (h.is_a?(String) ? (h.to_hours || h) : h)
|
||||
end
|
||||
|
||||
def hours
|
||||
h = read_attribute(:hours)
|
||||
if h.is_a?(Float)
|
||||
h.round(2)
|
||||
else
|
||||
h
|
||||
end
|
||||
end
|
||||
|
||||
# tyear, tmonth, tweek assigned where setting spent_on attributes
|
||||
# these attributes make time aggregations easier
|
||||
def spent_on=(date)
|
||||
super
|
||||
self.tyear = spent_on ? spent_on.year : nil
|
||||
self.tmonth = spent_on ? spent_on.month : nil
|
||||
self.tweek = spent_on ? Date.civil(spent_on.year, spent_on.month, spent_on.day).cweek : nil
|
||||
end
|
||||
|
||||
# Returns true if the time entry can be edited by usr, otherwise false
|
||||
def editable_by?(usr)
|
||||
visible?(usr) && (
|
||||
(usr == user && usr.allowed_to?(:edit_own_time_entries, project)) || usr.allowed_to?(:edit_time_entries, project)
|
||||
)
|
||||
end
|
||||
|
||||
# Returns the custom_field_values that can be edited by the given user
|
||||
def editable_custom_field_values(user=nil)
|
||||
visible_custom_field_values
|
||||
end
|
||||
|
||||
# Returns the custom fields that can be edited by the given user
|
||||
def editable_custom_fields(user=nil)
|
||||
editable_custom_field_values(user).map(&:custom_field).uniq
|
||||
end
|
||||
end
|
38
app/models/time_entry_activity.rb
Normal file
38
app/models/time_entry_activity.rb
Normal file
|
@ -0,0 +1,38 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
class TimeEntryActivity < Enumeration
|
||||
has_many :time_entries, :foreign_key => 'activity_id'
|
||||
|
||||
OptionName = :enumeration_activities
|
||||
|
||||
def option_name
|
||||
OptionName
|
||||
end
|
||||
|
||||
def objects
|
||||
TimeEntry.where(:activity_id => self_and_descendants(1).map(&:id))
|
||||
end
|
||||
|
||||
def objects_count
|
||||
objects.count
|
||||
end
|
||||
|
||||
def transfer_relations(to)
|
||||
objects.update_all(:activity_id => to.id)
|
||||
end
|
||||
end
|
22
app/models/time_entry_activity_custom_field.rb
Normal file
22
app/models/time_entry_activity_custom_field.rb
Normal file
|
@ -0,0 +1,22 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
class TimeEntryActivityCustomField < CustomField
|
||||
def type_name
|
||||
:enumeration_activities
|
||||
end
|
||||
end
|
23
app/models/time_entry_custom_field.rb
Normal file
23
app/models/time_entry_custom_field.rb
Normal file
|
@ -0,0 +1,23 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
class TimeEntryCustomField < CustomField
|
||||
def type_name
|
||||
:label_spent_time
|
||||
end
|
||||
end
|
||||
|
227
app/models/time_entry_query.rb
Normal file
227
app/models/time_entry_query.rb
Normal file
|
@ -0,0 +1,227 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
class TimeEntryQuery < Query
|
||||
|
||||
self.queried_class = TimeEntry
|
||||
self.view_permission = :view_time_entries
|
||||
|
||||
self.available_columns = [
|
||||
QueryColumn.new(:project, :sortable => "#{Project.table_name}.name", :groupable => true),
|
||||
QueryColumn.new(:spent_on, :sortable => ["#{TimeEntry.table_name}.spent_on", "#{TimeEntry.table_name}.created_on"], :default_order => 'desc', :groupable => true),
|
||||
QueryColumn.new(:tweek, :sortable => ["#{TimeEntry.table_name}.spent_on", "#{TimeEntry.table_name}.created_on"], :caption => :label_week),
|
||||
QueryColumn.new(:user, :sortable => lambda {User.fields_for_order_statement}, :groupable => true),
|
||||
QueryColumn.new(:activity, :sortable => "#{TimeEntryActivity.table_name}.position", :groupable => true),
|
||||
QueryColumn.new(:issue, :sortable => "#{Issue.table_name}.id"),
|
||||
QueryAssociationColumn.new(:issue, :tracker, :caption => :field_tracker, :sortable => "#{Tracker.table_name}.position"),
|
||||
QueryAssociationColumn.new(:issue, :status, :caption => :field_status, :sortable => "#{IssueStatus.table_name}.position"),
|
||||
QueryColumn.new(:comments),
|
||||
QueryColumn.new(:hours, :sortable => "#{TimeEntry.table_name}.hours", :totalable => true),
|
||||
]
|
||||
|
||||
def initialize(attributes=nil, *args)
|
||||
super attributes
|
||||
self.filters ||= { 'spent_on' => {:operator => "*", :values => []} }
|
||||
end
|
||||
|
||||
def initialize_available_filters
|
||||
add_available_filter "spent_on", :type => :date_past
|
||||
|
||||
add_available_filter("project_id",
|
||||
:type => :list, :values => lambda { project_values }
|
||||
) if project.nil?
|
||||
|
||||
if project && !project.leaf?
|
||||
add_available_filter "subproject_id",
|
||||
:type => :list_subprojects,
|
||||
:values => lambda { subproject_values }
|
||||
end
|
||||
|
||||
add_available_filter("issue_id", :type => :tree, :label => :label_issue)
|
||||
add_available_filter("issue.tracker_id",
|
||||
:type => :list,
|
||||
:name => l("label_attribute_of_issue", :name => l(:field_tracker)),
|
||||
:values => lambda { trackers.map {|t| [t.name, t.id.to_s]} })
|
||||
add_available_filter("issue.status_id",
|
||||
:type => :list,
|
||||
:name => l("label_attribute_of_issue", :name => l(:field_status)),
|
||||
:values => lambda { issue_statuses_values })
|
||||
add_available_filter("issue.fixed_version_id",
|
||||
:type => :list,
|
||||
:name => l("label_attribute_of_issue", :name => l(:field_fixed_version)),
|
||||
:values => lambda { fixed_version_values })
|
||||
|
||||
add_available_filter("user_id",
|
||||
:type => :list_optional, :values => lambda { author_values }
|
||||
)
|
||||
|
||||
activities = (project ? project.activities : TimeEntryActivity.shared)
|
||||
add_available_filter("activity_id",
|
||||
:type => :list, :values => activities.map {|a| [a.name, a.id.to_s]}
|
||||
)
|
||||
|
||||
add_available_filter "comments", :type => :text
|
||||
add_available_filter "hours", :type => :float
|
||||
|
||||
add_custom_fields_filters(TimeEntryCustomField)
|
||||
add_associations_custom_fields_filters :project
|
||||
add_custom_fields_filters(issue_custom_fields, :issue)
|
||||
add_associations_custom_fields_filters :user
|
||||
end
|
||||
|
||||
def available_columns
|
||||
return @available_columns if @available_columns
|
||||
@available_columns = self.class.available_columns.dup
|
||||
@available_columns += TimeEntryCustomField.visible.
|
||||
map {|cf| QueryCustomFieldColumn.new(cf) }
|
||||
@available_columns += issue_custom_fields.visible.
|
||||
map {|cf| QueryAssociationCustomFieldColumn.new(:issue, cf, :totalable => false) }
|
||||
@available_columns += ProjectCustomField.visible.
|
||||
map {|cf| QueryAssociationCustomFieldColumn.new(:project, cf) }
|
||||
@available_columns
|
||||
end
|
||||
|
||||
def default_columns_names
|
||||
@default_columns_names ||= begin
|
||||
default_columns = [:spent_on, :user, :activity, :issue, :comments, :hours]
|
||||
|
||||
project.present? ? default_columns : [:project] | default_columns
|
||||
end
|
||||
end
|
||||
|
||||
def default_totalable_names
|
||||
[:hours]
|
||||
end
|
||||
|
||||
def default_sort_criteria
|
||||
[['spent_on', 'desc']]
|
||||
end
|
||||
|
||||
# If a filter against a single issue is set, returns its id, otherwise nil.
|
||||
def filtered_issue_id
|
||||
if value_for('issue_id').to_s =~ /\A(\d+)\z/
|
||||
$1
|
||||
end
|
||||
end
|
||||
|
||||
def base_scope
|
||||
TimeEntry.visible.
|
||||
joins(:project, :user).
|
||||
includes(:activity).
|
||||
references(:activity).
|
||||
left_join_issue.
|
||||
where(statement)
|
||||
end
|
||||
|
||||
def results_scope(options={})
|
||||
order_option = [group_by_sort_order, (options[:order] || sort_clause)].flatten.reject(&:blank?)
|
||||
|
||||
base_scope.
|
||||
order(order_option).
|
||||
joins(joins_for_order_statement(order_option.join(',')))
|
||||
end
|
||||
|
||||
# Returns sum of all the spent hours
|
||||
def total_for_hours(scope)
|
||||
map_total(scope.sum(:hours)) {|t| t.to_f.round(2)}
|
||||
end
|
||||
|
||||
def sql_for_issue_id_field(field, operator, value)
|
||||
case operator
|
||||
when "="
|
||||
"#{TimeEntry.table_name}.issue_id = #{value.first.to_i}"
|
||||
when "~"
|
||||
issue = Issue.where(:id => value.first.to_i).first
|
||||
if issue && (issue_ids = issue.self_and_descendants.pluck(:id)).any?
|
||||
"#{TimeEntry.table_name}.issue_id IN (#{issue_ids.join(',')})"
|
||||
else
|
||||
"1=0"
|
||||
end
|
||||
when "!*"
|
||||
"#{TimeEntry.table_name}.issue_id IS NULL"
|
||||
when "*"
|
||||
"#{TimeEntry.table_name}.issue_id IS NOT NULL"
|
||||
end
|
||||
end
|
||||
|
||||
def sql_for_issue_fixed_version_id_field(field, operator, value)
|
||||
issue_ids = Issue.where(:fixed_version_id => value.map(&:to_i)).pluck(:id)
|
||||
case operator
|
||||
when "="
|
||||
if issue_ids.any?
|
||||
"#{TimeEntry.table_name}.issue_id IN (#{issue_ids.join(',')})"
|
||||
else
|
||||
"1=0"
|
||||
end
|
||||
when "!"
|
||||
if issue_ids.any?
|
||||
"#{TimeEntry.table_name}.issue_id NOT IN (#{issue_ids.join(',')})"
|
||||
else
|
||||
"1=1"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def sql_for_activity_id_field(field, operator, value)
|
||||
condition_on_id = sql_for_field(field, operator, value, Enumeration.table_name, 'id')
|
||||
condition_on_parent_id = sql_for_field(field, operator, value, Enumeration.table_name, 'parent_id')
|
||||
ids = value.map(&:to_i).join(',')
|
||||
table_name = Enumeration.table_name
|
||||
if operator == '='
|
||||
"(#{table_name}.id IN (#{ids}) OR #{table_name}.parent_id IN (#{ids}))"
|
||||
else
|
||||
"(#{table_name}.id NOT IN (#{ids}) AND (#{table_name}.parent_id IS NULL OR #{table_name}.parent_id NOT IN (#{ids})))"
|
||||
end
|
||||
end
|
||||
|
||||
def sql_for_issue_tracker_id_field(field, operator, value)
|
||||
sql_for_field("tracker_id", operator, value, Issue.table_name, "tracker_id")
|
||||
end
|
||||
|
||||
def sql_for_issue_status_id_field(field, operator, value)
|
||||
sql_for_field("status_id", operator, value, Issue.table_name, "status_id")
|
||||
end
|
||||
|
||||
# Accepts :from/:to params as shortcut filters
|
||||
def build_from_params(params)
|
||||
super
|
||||
if params[:from].present? && params[:to].present?
|
||||
add_filter('spent_on', '><', [params[:from], params[:to]])
|
||||
elsif params[:from].present?
|
||||
add_filter('spent_on', '>=', [params[:from]])
|
||||
elsif params[:to].present?
|
||||
add_filter('spent_on', '<=', [params[:to]])
|
||||
end
|
||||
self
|
||||
end
|
||||
|
||||
def joins_for_order_statement(order_options)
|
||||
joins = [super]
|
||||
|
||||
if order_options
|
||||
if order_options.include?('issue_statuses')
|
||||
joins << "LEFT OUTER JOIN #{IssueStatus.table_name} ON #{IssueStatus.table_name}.id = #{Issue.table_name}.status_id"
|
||||
end
|
||||
if order_options.include?('trackers')
|
||||
joins << "LEFT OUTER JOIN #{Tracker.table_name} ON #{Tracker.table_name}.id = #{Issue.table_name}.tracker_id"
|
||||
end
|
||||
end
|
||||
|
||||
joins.compact!
|
||||
joins.any? ? joins.join(' ') : nil
|
||||
end
|
||||
end
|
144
app/models/token.rb
Normal file
144
app/models/token.rb
Normal file
|
@ -0,0 +1,144 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
class Token < ActiveRecord::Base
|
||||
belongs_to :user
|
||||
validates_uniqueness_of :value
|
||||
attr_protected :id
|
||||
|
||||
before_create :delete_previous_tokens, :generate_new_token
|
||||
|
||||
cattr_accessor :validity_time
|
||||
self.validity_time = 1.day
|
||||
|
||||
class << self
|
||||
attr_reader :actions
|
||||
|
||||
def add_action(name, options)
|
||||
options.assert_valid_keys(:max_instances, :validity_time)
|
||||
@actions ||= {}
|
||||
@actions[name.to_s] = options
|
||||
end
|
||||
end
|
||||
|
||||
add_action :api, max_instances: 1, validity_time: nil
|
||||
add_action :autologin, max_instances: 10, validity_time: Proc.new { Setting.autologin.to_i.days }
|
||||
add_action :feeds, max_instances: 1, validity_time: nil
|
||||
add_action :recovery, max_instances: 1, validity_time: Proc.new { Token.validity_time }
|
||||
add_action :register, max_instances: 1, validity_time: Proc.new { Token.validity_time }
|
||||
add_action :session, max_instances: 10, validity_time: nil
|
||||
|
||||
def generate_new_token
|
||||
self.value = Token.generate_token_value
|
||||
end
|
||||
|
||||
# Return true if token has expired
|
||||
def expired?
|
||||
validity_time = self.class.invalid_when_created_before(action)
|
||||
validity_time.present? && created_on < validity_time
|
||||
end
|
||||
|
||||
def max_instances
|
||||
Token.actions.has_key?(action) ? Token.actions[action][:max_instances] : 1
|
||||
end
|
||||
|
||||
def self.invalid_when_created_before(action = nil)
|
||||
if Token.actions.has_key?(action)
|
||||
validity_time = Token.actions[action][:validity_time]
|
||||
validity_time = validity_time.call(action) if validity_time.respond_to? :call
|
||||
else
|
||||
validity_time = self.validity_time
|
||||
end
|
||||
|
||||
if validity_time
|
||||
Time.now - validity_time
|
||||
end
|
||||
end
|
||||
|
||||
# Delete all expired tokens
|
||||
def self.destroy_expired
|
||||
t = Token.arel_table
|
||||
|
||||
# Unknown actions have default validity_time
|
||||
condition = t[:action].not_in(self.actions.keys).and(t[:created_on].lt(invalid_when_created_before))
|
||||
|
||||
self.actions.each do |action, options|
|
||||
validity_time = invalid_when_created_before(action)
|
||||
|
||||
# Do not delete tokens, which don't become invalid
|
||||
next if validity_time.nil?
|
||||
|
||||
condition = condition.or(
|
||||
t[:action].eq(action).and(t[:created_on].lt(validity_time))
|
||||
)
|
||||
end
|
||||
|
||||
Token.where(condition).delete_all
|
||||
end
|
||||
|
||||
# Returns the active user who owns the key for the given action
|
||||
def self.find_active_user(action, key, validity_days=nil)
|
||||
user = find_user(action, key, validity_days)
|
||||
if user && user.active?
|
||||
user
|
||||
end
|
||||
end
|
||||
|
||||
# Returns the user who owns the key for the given action
|
||||
def self.find_user(action, key, validity_days=nil)
|
||||
token = find_token(action, key, validity_days)
|
||||
if token
|
||||
token.user
|
||||
end
|
||||
end
|
||||
|
||||
# Returns the token for action and key with an optional
|
||||
# validity duration (in number of days)
|
||||
def self.find_token(action, key, validity_days=nil)
|
||||
action = action.to_s
|
||||
key = key.to_s
|
||||
return nil unless action.present? && key =~ /\A[a-z0-9]+\z/i
|
||||
|
||||
token = Token.where(:action => action, :value => key).first
|
||||
if token && (token.action == action) && (token.value == key) && token.user
|
||||
if validity_days.nil? || (token.created_on > validity_days.days.ago)
|
||||
token
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def self.generate_token_value
|
||||
Redmine::Utils.random_hex(20)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Removes obsolete tokens (same user and action)
|
||||
def delete_previous_tokens
|
||||
if user
|
||||
scope = Token.where(:user_id => user.id, :action => action)
|
||||
if max_instances > 1
|
||||
ids = scope.order(:updated_on => :desc).offset(max_instances - 1).ids
|
||||
if ids.any?
|
||||
Token.delete(ids)
|
||||
end
|
||||
else
|
||||
scope.delete_all
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
150
app/models/tracker.rb
Normal file
150
app/models/tracker.rb
Normal file
|
@ -0,0 +1,150 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
class Tracker < ActiveRecord::Base
|
||||
include Redmine::SafeAttributes
|
||||
|
||||
CORE_FIELDS_UNDISABLABLE = %w(project_id tracker_id subject priority_id is_private).freeze
|
||||
# Fields that can be disabled
|
||||
# Other (future) fields should be appended, not inserted!
|
||||
CORE_FIELDS = %w(assigned_to_id category_id fixed_version_id parent_issue_id start_date due_date estimated_hours done_ratio description).freeze
|
||||
CORE_FIELDS_ALL = (CORE_FIELDS_UNDISABLABLE + CORE_FIELDS).freeze
|
||||
|
||||
before_destroy :check_integrity
|
||||
belongs_to :default_status, :class_name => 'IssueStatus'
|
||||
has_many :issues
|
||||
has_many :workflow_rules, :dependent => :delete_all do
|
||||
def copy(source_tracker)
|
||||
ActiveSupport::Deprecation.warn "tracker.workflow_rules.copy is deprecated and will be removed in Redmine 4.0, use tracker.copy_worflow_rules instead"
|
||||
proxy_association.owner.copy_workflow_rules(source_tracker)
|
||||
end
|
||||
end
|
||||
has_and_belongs_to_many :projects
|
||||
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'
|
||||
acts_as_positioned
|
||||
|
||||
attr_protected :fields_bits
|
||||
|
||||
validates_presence_of :default_status
|
||||
validates_presence_of :name
|
||||
validates_uniqueness_of :name
|
||||
validates_length_of :name, :maximum => 30
|
||||
|
||||
scope :sorted, lambda { order(:position) }
|
||||
scope :named, lambda {|arg| where("LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip)}
|
||||
|
||||
# Returns the trackers that are visible by the user.
|
||||
#
|
||||
# Examples:
|
||||
# project.trackers.visible(user)
|
||||
# => returns the trackers that are visible by the user in project
|
||||
#
|
||||
# Tracker.visible(user)
|
||||
# => returns the trackers that are visible by the user in at least on project
|
||||
scope :visible, lambda {|*args|
|
||||
user = args.shift || User.current
|
||||
condition = Project.allowed_to_condition(user, :view_issues) do |role, user|
|
||||
unless role.permissions_all_trackers?(:view_issues)
|
||||
tracker_ids = role.permissions_tracker_ids(:view_issues)
|
||||
if tracker_ids.any?
|
||||
"#{Tracker.table_name}.id IN (#{tracker_ids.join(',')})"
|
||||
else
|
||||
'1=0'
|
||||
end
|
||||
end
|
||||
end
|
||||
joins(:projects).where(condition).distinct
|
||||
}
|
||||
|
||||
safe_attributes 'name',
|
||||
'default_status_id',
|
||||
'is_in_roadmap',
|
||||
'core_fields',
|
||||
'position',
|
||||
'custom_field_ids',
|
||||
'project_ids'
|
||||
|
||||
def to_s; name end
|
||||
|
||||
def <=>(tracker)
|
||||
position <=> tracker.position
|
||||
end
|
||||
|
||||
# Returns an array of IssueStatus that are used
|
||||
# in the tracker's workflows
|
||||
def issue_statuses
|
||||
@issue_statuses ||= IssueStatus.where(:id => issue_status_ids).to_a.sort
|
||||
end
|
||||
|
||||
def issue_status_ids
|
||||
if new_record?
|
||||
[]
|
||||
else
|
||||
@issue_status_ids ||= WorkflowTransition.where(:tracker_id => id).distinct.pluck(:old_status_id, :new_status_id).flatten.uniq
|
||||
end
|
||||
end
|
||||
|
||||
def disabled_core_fields
|
||||
i = -1
|
||||
@disabled_core_fields ||= CORE_FIELDS.select { i += 1; (fields_bits || 0) & (2 ** i) != 0}
|
||||
end
|
||||
|
||||
def core_fields
|
||||
CORE_FIELDS - disabled_core_fields
|
||||
end
|
||||
|
||||
def core_fields=(fields)
|
||||
raise ArgumentError.new("Tracker.core_fields takes an array") unless fields.is_a?(Array)
|
||||
|
||||
bits = 0
|
||||
CORE_FIELDS.each_with_index do |field, i|
|
||||
unless fields.include?(field)
|
||||
bits |= 2 ** i
|
||||
end
|
||||
end
|
||||
self.fields_bits = bits
|
||||
@disabled_core_fields = nil
|
||||
core_fields
|
||||
end
|
||||
|
||||
def copy_workflow_rules(source_tracker)
|
||||
WorkflowRule.copy(source_tracker, nil, self, nil)
|
||||
end
|
||||
|
||||
# Returns the fields that are disabled for all the given trackers
|
||||
def self.disabled_core_fields(trackers)
|
||||
if trackers.present?
|
||||
trackers.map(&:disabled_core_fields).reduce(:&)
|
||||
else
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
# Returns the fields that are enabled for one tracker at least
|
||||
def self.core_fields(trackers)
|
||||
if trackers.present?
|
||||
trackers.uniq.map(&:core_fields).reduce(:|)
|
||||
else
|
||||
CORE_FIELDS.dup
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def check_integrity
|
||||
raise Exception.new("Cannot delete tracker") if Issue.where(:tracker_id => self.id).any?
|
||||
end
|
||||
end
|
974
app/models/user.rb
Normal file
974
app/models/user.rb
Normal file
|
@ -0,0 +1,974 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
require "digest/sha1"
|
||||
|
||||
class User < Principal
|
||||
include Redmine::SafeAttributes
|
||||
|
||||
# Different ways of displaying/sorting users
|
||||
USER_FORMATS = {
|
||||
:firstname_lastname => {
|
||||
:string => '#{firstname} #{lastname}',
|
||||
:order => %w(firstname lastname id),
|
||||
:setting_order => 1
|
||||
},
|
||||
:firstname_lastinitial => {
|
||||
:string => '#{firstname} #{lastname.to_s.chars.first}.',
|
||||
:order => %w(firstname lastname id),
|
||||
:setting_order => 2
|
||||
},
|
||||
:firstinitial_lastname => {
|
||||
:string => '#{firstname.to_s.gsub(/(([[:alpha:]])[[:alpha:]]*\.?)/, \'\2.\')} #{lastname}',
|
||||
:order => %w(firstname lastname id),
|
||||
:setting_order => 2
|
||||
},
|
||||
:firstname => {
|
||||
:string => '#{firstname}',
|
||||
:order => %w(firstname id),
|
||||
:setting_order => 3
|
||||
},
|
||||
:lastname_firstname => {
|
||||
:string => '#{lastname} #{firstname}',
|
||||
:order => %w(lastname firstname id),
|
||||
:setting_order => 4
|
||||
},
|
||||
:lastnamefirstname => {
|
||||
:string => '#{lastname}#{firstname}',
|
||||
:order => %w(lastname firstname id),
|
||||
:setting_order => 5
|
||||
},
|
||||
:lastname_comma_firstname => {
|
||||
:string => '#{lastname}, #{firstname}',
|
||||
:order => %w(lastname firstname id),
|
||||
:setting_order => 6
|
||||
},
|
||||
:lastname => {
|
||||
:string => '#{lastname}',
|
||||
:order => %w(lastname id),
|
||||
:setting_order => 7
|
||||
},
|
||||
:username => {
|
||||
:string => '#{login}',
|
||||
:order => %w(login id),
|
||||
:setting_order => 8
|
||||
},
|
||||
}
|
||||
|
||||
MAIL_NOTIFICATION_OPTIONS = [
|
||||
['all', :label_user_mail_option_all],
|
||||
['selected', :label_user_mail_option_selected],
|
||||
['only_my_events', :label_user_mail_option_only_my_events],
|
||||
['only_assigned', :label_user_mail_option_only_assigned],
|
||||
['only_owner', :label_user_mail_option_only_owner],
|
||||
['none', :label_user_mail_option_none]
|
||||
]
|
||||
|
||||
has_and_belongs_to_many :groups,
|
||||
:join_table => "#{table_name_prefix}groups_users#{table_name_suffix}",
|
||||
:after_add => Proc.new {|user, group| group.user_added(user)},
|
||||
:after_remove => Proc.new {|user, group| group.user_removed(user)}
|
||||
has_many :changesets, :dependent => :nullify
|
||||
has_one :preference, :dependent => :destroy, :class_name => 'UserPreference'
|
||||
has_one :rss_token, lambda {where "action='feeds'"}, :class_name => 'Token'
|
||||
has_one :api_token, lambda {where "action='api'"}, :class_name => 'Token'
|
||||
has_one :email_address, lambda {where :is_default => true}, :autosave => true
|
||||
has_many :email_addresses, :dependent => :delete_all
|
||||
belongs_to :auth_source
|
||||
|
||||
scope :logged, lambda { where("#{User.table_name}.status <> #{STATUS_ANONYMOUS}") }
|
||||
scope :status, lambda {|arg| where(arg.blank? ? nil : {:status => arg.to_i}) }
|
||||
|
||||
acts_as_customizable
|
||||
|
||||
attr_accessor :password, :password_confirmation, :generate_password
|
||||
attr_accessor :last_before_login_on
|
||||
attr_accessor :remote_ip
|
||||
|
||||
# Prevents unauthorized assignments
|
||||
attr_protected :password, :password_confirmation, :hashed_password
|
||||
|
||||
LOGIN_LENGTH_LIMIT = 60
|
||||
MAIL_LENGTH_LIMIT = 60
|
||||
|
||||
validates_presence_of :login, :firstname, :lastname, :if => Proc.new { |user| !user.is_a?(AnonymousUser) }
|
||||
validates_uniqueness_of :login, :if => Proc.new { |user| user.login_changed? && user.login.present? }, :case_sensitive => false
|
||||
# Login must contain letters, numbers, underscores only
|
||||
validates_format_of :login, :with => /\A[a-z0-9_\-@\.]*\z/i
|
||||
validates_length_of :login, :maximum => LOGIN_LENGTH_LIMIT
|
||||
validates_length_of :firstname, :lastname, :maximum => 30
|
||||
validates_length_of :identity_url, maximum: 255
|
||||
validates_inclusion_of :mail_notification, :in => MAIL_NOTIFICATION_OPTIONS.collect(&:first), :allow_blank => true
|
||||
validate :validate_password_length
|
||||
validate do
|
||||
if password_confirmation && password != password_confirmation
|
||||
errors.add(:password, :confirmation)
|
||||
end
|
||||
end
|
||||
|
||||
self.valid_statuses = [STATUS_ACTIVE, STATUS_REGISTERED, STATUS_LOCKED]
|
||||
|
||||
before_validation :instantiate_email_address
|
||||
before_create :set_mail_notification
|
||||
before_save :generate_password_if_needed, :update_hashed_password
|
||||
before_destroy :remove_references_before_destroy
|
||||
after_save :update_notified_project_ids, :destroy_tokens, :deliver_security_notification
|
||||
after_destroy :deliver_security_notification
|
||||
|
||||
scope :admin, lambda {|*args|
|
||||
admin = args.size > 0 ? !!args.first : true
|
||||
where(:admin => admin)
|
||||
}
|
||||
scope :in_group, lambda {|group|
|
||||
group_id = group.is_a?(Group) ? group.id : group.to_i
|
||||
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)
|
||||
}
|
||||
scope :not_in_group, lambda {|group|
|
||||
group_id = group.is_a?(Group) ? group.id : group.to_i
|
||||
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)
|
||||
}
|
||||
scope :sorted, lambda { order(*User.fields_for_order_statement)}
|
||||
scope :having_mail, lambda {|arg|
|
||||
addresses = Array.wrap(arg).map {|a| a.to_s.downcase}
|
||||
if addresses.any?
|
||||
joins(:email_addresses).where("LOWER(#{EmailAddress.table_name}.address) IN (?)", addresses).distinct
|
||||
else
|
||||
none
|
||||
end
|
||||
}
|
||||
|
||||
def set_mail_notification
|
||||
self.mail_notification = Setting.default_notification_option if self.mail_notification.blank?
|
||||
true
|
||||
end
|
||||
|
||||
def update_hashed_password
|
||||
# update hashed_password if password was set
|
||||
if self.password && self.auth_source_id.blank?
|
||||
salt_password(password)
|
||||
end
|
||||
end
|
||||
|
||||
alias :base_reload :reload
|
||||
def reload(*args)
|
||||
@name = nil
|
||||
@roles = nil
|
||||
@projects_by_role = nil
|
||||
@project_ids_by_role = nil
|
||||
@membership_by_project_id = nil
|
||||
@notified_projects_ids = nil
|
||||
@notified_projects_ids_changed = false
|
||||
@builtin_role = nil
|
||||
@visible_project_ids = nil
|
||||
@managed_roles = nil
|
||||
base_reload(*args)
|
||||
end
|
||||
|
||||
def mail
|
||||
email_address.try(:address)
|
||||
end
|
||||
|
||||
def mail=(arg)
|
||||
email = email_address || build_email_address
|
||||
email.address = arg
|
||||
end
|
||||
|
||||
def mail_changed?
|
||||
email_address.try(:address_changed?)
|
||||
end
|
||||
|
||||
def mails
|
||||
email_addresses.pluck(:address)
|
||||
end
|
||||
|
||||
def self.find_or_initialize_by_identity_url(url)
|
||||
user = where(:identity_url => url).first
|
||||
unless user
|
||||
user = User.new
|
||||
user.identity_url = url
|
||||
end
|
||||
user
|
||||
end
|
||||
|
||||
def identity_url=(url)
|
||||
if url.blank?
|
||||
write_attribute(:identity_url, '')
|
||||
else
|
||||
begin
|
||||
write_attribute(:identity_url, OpenIdAuthentication.normalize_identifier(url))
|
||||
rescue OpenIdAuthentication::InvalidOpenId
|
||||
# Invalid url, don't save
|
||||
end
|
||||
end
|
||||
self.read_attribute(:identity_url)
|
||||
end
|
||||
|
||||
# Returns the user that matches provided login and password, or nil
|
||||
def self.try_to_login(login, password, active_only=true)
|
||||
login = login.to_s.strip
|
||||
password = password.to_s
|
||||
|
||||
# Make sure no one can sign in with an empty login or password
|
||||
return nil if login.empty? || password.empty?
|
||||
user = find_by_login(login)
|
||||
if user
|
||||
# user is already in local database
|
||||
return nil unless user.check_password?(password)
|
||||
return nil if !user.active? && active_only
|
||||
else
|
||||
# user is not yet registered, try to authenticate with available sources
|
||||
attrs = AuthSource.authenticate(login, password)
|
||||
if attrs
|
||||
user = new(attrs)
|
||||
user.login = login
|
||||
user.language = Setting.default_language
|
||||
if user.save
|
||||
user.reload
|
||||
logger.info("User '#{user.login}' created from external auth source: #{user.auth_source.type} - #{user.auth_source.name}") if logger && user.auth_source
|
||||
end
|
||||
end
|
||||
end
|
||||
user.update_column(:last_login_on, Time.now) if user && !user.new_record? && user.active?
|
||||
user
|
||||
rescue => text
|
||||
raise text
|
||||
end
|
||||
|
||||
# Returns the user who matches the given autologin +key+ or nil
|
||||
def self.try_to_autologin(key)
|
||||
user = Token.find_active_user('autologin', key, Setting.autologin.to_i)
|
||||
if user
|
||||
user.update_column(:last_login_on, Time.now)
|
||||
user
|
||||
end
|
||||
end
|
||||
|
||||
def self.name_formatter(formatter = nil)
|
||||
USER_FORMATS[formatter || Setting.user_format] || USER_FORMATS[:firstname_lastname]
|
||||
end
|
||||
|
||||
# Returns an array of fields names than can be used to make an order statement for users
|
||||
# according to how user names are displayed
|
||||
# Examples:
|
||||
#
|
||||
# User.fields_for_order_statement => ['users.login', 'users.id']
|
||||
# User.fields_for_order_statement('authors') => ['authors.login', 'authors.id']
|
||||
def self.fields_for_order_statement(table=nil)
|
||||
table ||= table_name
|
||||
name_formatter[:order].map {|field| "#{table}.#{field}"}
|
||||
end
|
||||
|
||||
# Return user's full name for display
|
||||
def name(formatter = nil)
|
||||
f = self.class.name_formatter(formatter)
|
||||
if formatter
|
||||
eval('"' + f[:string] + '"')
|
||||
else
|
||||
@name ||= eval('"' + f[:string] + '"')
|
||||
end
|
||||
end
|
||||
|
||||
def active?
|
||||
self.status == STATUS_ACTIVE
|
||||
end
|
||||
|
||||
def registered?
|
||||
self.status == STATUS_REGISTERED
|
||||
end
|
||||
|
||||
def locked?
|
||||
self.status == STATUS_LOCKED
|
||||
end
|
||||
|
||||
def activate
|
||||
self.status = STATUS_ACTIVE
|
||||
end
|
||||
|
||||
def register
|
||||
self.status = STATUS_REGISTERED
|
||||
end
|
||||
|
||||
def lock
|
||||
self.status = STATUS_LOCKED
|
||||
end
|
||||
|
||||
def activate!
|
||||
update_attribute(:status, STATUS_ACTIVE)
|
||||
end
|
||||
|
||||
def register!
|
||||
update_attribute(:status, STATUS_REGISTERED)
|
||||
end
|
||||
|
||||
def lock!
|
||||
update_attribute(:status, STATUS_LOCKED)
|
||||
end
|
||||
|
||||
# Returns true if +clear_password+ is the correct user's password, otherwise false
|
||||
def check_password?(clear_password)
|
||||
if auth_source_id.present?
|
||||
auth_source.authenticate(self.login, clear_password)
|
||||
else
|
||||
User.hash_password("#{salt}#{User.hash_password clear_password}") == hashed_password
|
||||
end
|
||||
end
|
||||
|
||||
# Generates a random salt and computes hashed_password for +clear_password+
|
||||
# The hashed password is stored in the following form: SHA1(salt + SHA1(password))
|
||||
def salt_password(clear_password)
|
||||
self.salt = User.generate_salt
|
||||
self.hashed_password = User.hash_password("#{salt}#{User.hash_password clear_password}")
|
||||
self.passwd_changed_on = Time.now.change(:usec => 0)
|
||||
end
|
||||
|
||||
# Does the backend storage allow this user to change their password?
|
||||
def change_password_allowed?
|
||||
return true if auth_source.nil?
|
||||
return auth_source.allow_password_changes?
|
||||
end
|
||||
|
||||
# Returns true if the user password has expired
|
||||
def password_expired?
|
||||
period = Setting.password_max_age.to_i
|
||||
if period.zero?
|
||||
false
|
||||
else
|
||||
changed_on = self.passwd_changed_on || Time.at(0)
|
||||
changed_on < period.days.ago
|
||||
end
|
||||
end
|
||||
|
||||
def must_change_password?
|
||||
(must_change_passwd? || password_expired?) && change_password_allowed?
|
||||
end
|
||||
|
||||
def generate_password?
|
||||
generate_password == '1' || generate_password == true
|
||||
end
|
||||
|
||||
# Generate and set a random password on given length
|
||||
def random_password(length=40)
|
||||
chars = ("a".."z").to_a + ("A".."Z").to_a + ("0".."9").to_a
|
||||
chars -= %w(0 O 1 l)
|
||||
password = ''
|
||||
length.times {|i| password << chars[SecureRandom.random_number(chars.size)] }
|
||||
self.password = password
|
||||
self.password_confirmation = password
|
||||
self
|
||||
end
|
||||
|
||||
def pref
|
||||
self.preference ||= UserPreference.new(:user => self)
|
||||
end
|
||||
|
||||
def time_zone
|
||||
@time_zone ||= (self.pref.time_zone.blank? ? nil : ActiveSupport::TimeZone[self.pref.time_zone])
|
||||
end
|
||||
|
||||
def force_default_language?
|
||||
Setting.force_default_language_for_loggedin?
|
||||
end
|
||||
|
||||
def language
|
||||
if force_default_language?
|
||||
Setting.default_language
|
||||
else
|
||||
super
|
||||
end
|
||||
end
|
||||
|
||||
def wants_comments_in_reverse_order?
|
||||
self.pref[:comments_sorting] == 'desc'
|
||||
end
|
||||
|
||||
# Return user's RSS key (a 40 chars long string), used to access feeds
|
||||
def rss_key
|
||||
if rss_token.nil?
|
||||
create_rss_token(:action => 'feeds')
|
||||
end
|
||||
rss_token.value
|
||||
end
|
||||
|
||||
# Return user's API key (a 40 chars long string), used to access the API
|
||||
def api_key
|
||||
if api_token.nil?
|
||||
create_api_token(:action => 'api')
|
||||
end
|
||||
api_token.value
|
||||
end
|
||||
|
||||
# Generates a new session token and returns its value
|
||||
def generate_session_token
|
||||
token = Token.create!(:user_id => id, :action => 'session')
|
||||
token.value
|
||||
end
|
||||
|
||||
def delete_session_token(value)
|
||||
Token.where(:user_id => id, :action => 'session', :value => value).delete_all
|
||||
end
|
||||
|
||||
# Generates a new autologin token and returns its value
|
||||
def generate_autologin_token
|
||||
token = Token.create!(:user_id => id, :action => 'autologin')
|
||||
token.value
|
||||
end
|
||||
|
||||
def delete_autologin_token(value)
|
||||
Token.where(:user_id => id, :action => 'autologin', :value => value).delete_all
|
||||
end
|
||||
|
||||
# Returns true if token is a valid session token for the user whose id is user_id
|
||||
def self.verify_session_token(user_id, token)
|
||||
return false if user_id.blank? || token.blank?
|
||||
|
||||
scope = Token.where(:user_id => user_id, :value => token.to_s, :action => 'session')
|
||||
if Setting.session_lifetime?
|
||||
scope = scope.where("created_on > ?", Setting.session_lifetime.to_i.minutes.ago)
|
||||
end
|
||||
if Setting.session_timeout?
|
||||
scope = scope.where("updated_on > ?", Setting.session_timeout.to_i.minutes.ago)
|
||||
end
|
||||
scope.update_all(:updated_on => Time.now) == 1
|
||||
end
|
||||
|
||||
# Return an array of project ids for which the user has explicitly turned mail notifications on
|
||||
def notified_projects_ids
|
||||
@notified_projects_ids ||= memberships.select {|m| m.mail_notification?}.collect(&:project_id)
|
||||
end
|
||||
|
||||
def notified_project_ids=(ids)
|
||||
@notified_projects_ids_changed = true
|
||||
@notified_projects_ids = ids.map(&:to_i).uniq.select {|n| n > 0}
|
||||
end
|
||||
|
||||
# Updates per project notifications (after_save callback)
|
||||
def update_notified_project_ids
|
||||
if @notified_projects_ids_changed
|
||||
ids = (mail_notification == 'selected' ? Array.wrap(notified_projects_ids).reject(&:blank?) : [])
|
||||
members.update_all(:mail_notification => false)
|
||||
members.where(:project_id => ids).update_all(:mail_notification => true) if ids.any?
|
||||
end
|
||||
end
|
||||
private :update_notified_project_ids
|
||||
|
||||
def valid_notification_options
|
||||
self.class.valid_notification_options(self)
|
||||
end
|
||||
|
||||
# Only users that belong to more than 1 project can select projects for which they are notified
|
||||
def self.valid_notification_options(user=nil)
|
||||
# Note that @user.membership.size would fail since AR ignores
|
||||
# :include association option when doing a count
|
||||
if user.nil? || user.memberships.length < 1
|
||||
MAIL_NOTIFICATION_OPTIONS.reject {|option| option.first == 'selected'}
|
||||
else
|
||||
MAIL_NOTIFICATION_OPTIONS
|
||||
end
|
||||
end
|
||||
|
||||
# Find a user account by matching the exact login and then a case-insensitive
|
||||
# version. Exact matches will be given priority.
|
||||
def self.find_by_login(login)
|
||||
login = Redmine::CodesetUtil.replace_invalid_utf8(login.to_s)
|
||||
if login.present?
|
||||
# First look for an exact match
|
||||
user = where(:login => login).detect {|u| u.login == login}
|
||||
unless user
|
||||
# Fail over to case-insensitive if none was found
|
||||
user = where("LOWER(login) = ?", login.downcase).first
|
||||
end
|
||||
user
|
||||
end
|
||||
end
|
||||
|
||||
def self.find_by_rss_key(key)
|
||||
Token.find_active_user('feeds', key)
|
||||
end
|
||||
|
||||
def self.find_by_api_key(key)
|
||||
Token.find_active_user('api', key)
|
||||
end
|
||||
|
||||
# Makes find_by_mail case-insensitive
|
||||
def self.find_by_mail(mail)
|
||||
having_mail(mail).first
|
||||
end
|
||||
|
||||
# Returns true if the default admin account can no longer be used
|
||||
def self.default_admin_account_changed?
|
||||
!User.active.find_by_login("admin").try(:check_password?, "admin")
|
||||
end
|
||||
|
||||
def to_s
|
||||
name
|
||||
end
|
||||
|
||||
CSS_CLASS_BY_STATUS = {
|
||||
STATUS_ANONYMOUS => 'anon',
|
||||
STATUS_ACTIVE => 'active',
|
||||
STATUS_REGISTERED => 'registered',
|
||||
STATUS_LOCKED => 'locked'
|
||||
}
|
||||
|
||||
def css_classes
|
||||
"user #{CSS_CLASS_BY_STATUS[status]}"
|
||||
end
|
||||
|
||||
# Returns the current day according to user's time zone
|
||||
def today
|
||||
if time_zone.nil?
|
||||
Date.today
|
||||
else
|
||||
time_zone.today
|
||||
end
|
||||
end
|
||||
|
||||
# Returns the day of +time+ according to user's time zone
|
||||
def time_to_date(time)
|
||||
if time_zone.nil?
|
||||
time.to_date
|
||||
else
|
||||
time.in_time_zone(time_zone).to_date
|
||||
end
|
||||
end
|
||||
|
||||
def logged?
|
||||
true
|
||||
end
|
||||
|
||||
def anonymous?
|
||||
!logged?
|
||||
end
|
||||
|
||||
# Returns user's membership for the given project
|
||||
# or nil if the user is not a member of project
|
||||
def membership(project)
|
||||
project_id = project.is_a?(Project) ? project.id : project
|
||||
|
||||
@membership_by_project_id ||= Hash.new {|h, project_id|
|
||||
h[project_id] = memberships.where(:project_id => project_id).first
|
||||
}
|
||||
@membership_by_project_id[project_id]
|
||||
end
|
||||
|
||||
def roles
|
||||
@roles ||= Role.joins(members: :project).where(["#{Project.table_name}.status <> ?", Project::STATUS_ARCHIVED]).where(Member.arel_table[:user_id].eq(id)).distinct
|
||||
end
|
||||
|
||||
# Returns the user's bult-in role
|
||||
def builtin_role
|
||||
@builtin_role ||= Role.non_member
|
||||
end
|
||||
|
||||
# Return user's roles for project
|
||||
def roles_for_project(project)
|
||||
# No role on archived projects
|
||||
return [] if project.nil? || project.archived?
|
||||
if membership = membership(project)
|
||||
membership.roles.to_a
|
||||
elsif project.is_public?
|
||||
project.override_roles(builtin_role)
|
||||
else
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
# Returns a hash of user's projects grouped by roles
|
||||
# TODO: No longer used, should be deprecated
|
||||
def projects_by_role
|
||||
return @projects_by_role if @projects_by_role
|
||||
|
||||
result = Hash.new([])
|
||||
project_ids_by_role.each do |role, ids|
|
||||
result[role] = Project.where(:id => ids).to_a
|
||||
end
|
||||
@projects_by_role = result
|
||||
end
|
||||
|
||||
# Returns a hash of project ids grouped by roles.
|
||||
# Includes the projects that the user is a member of and the projects
|
||||
# that grant custom permissions to the builtin groups.
|
||||
def project_ids_by_role
|
||||
# Clear project condition for when called from chained scopes
|
||||
# eg. project.children.visible(user)
|
||||
Project.unscoped do
|
||||
return @project_ids_by_role if @project_ids_by_role
|
||||
|
||||
group_class = anonymous? ? GroupAnonymous : GroupNonMember
|
||||
group_id = group_class.pluck(:id).first
|
||||
|
||||
members = Member.joins(:project, :member_roles).
|
||||
where("#{Project.table_name}.status <> 9").
|
||||
where("#{Member.table_name}.user_id = ? OR (#{Project.table_name}.is_public = ? AND #{Member.table_name}.user_id = ?)", self.id, true, group_id).
|
||||
pluck(:user_id, :role_id, :project_id)
|
||||
|
||||
hash = {}
|
||||
members.each do |user_id, role_id, project_id|
|
||||
# Ignore the roles of the builtin group if the user is a member of the project
|
||||
next if user_id != id && project_ids.include?(project_id)
|
||||
|
||||
hash[role_id] ||= []
|
||||
hash[role_id] << project_id
|
||||
end
|
||||
|
||||
result = Hash.new([])
|
||||
if hash.present?
|
||||
roles = Role.where(:id => hash.keys).to_a
|
||||
hash.each do |role_id, proj_ids|
|
||||
role = roles.detect {|r| r.id == role_id}
|
||||
if role
|
||||
result[role] = proj_ids.uniq
|
||||
end
|
||||
end
|
||||
end
|
||||
@project_ids_by_role = result
|
||||
end
|
||||
end
|
||||
|
||||
# Returns the ids of visible projects
|
||||
def visible_project_ids
|
||||
@visible_project_ids ||= Project.visible(self).pluck(:id)
|
||||
end
|
||||
|
||||
# Returns the roles that the user is allowed to manage for the given project
|
||||
def managed_roles(project)
|
||||
if admin?
|
||||
@managed_roles ||= Role.givable.to_a
|
||||
else
|
||||
membership(project).try(:managed_roles) || []
|
||||
end
|
||||
end
|
||||
|
||||
# Returns true if user is arg or belongs to arg
|
||||
def is_or_belongs_to?(arg)
|
||||
if arg.is_a?(User)
|
||||
self == arg
|
||||
elsif arg.is_a?(Group)
|
||||
arg.users.include?(self)
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
# Return true if the user is allowed to do the specified action on a specific context
|
||||
# Action can be:
|
||||
# * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
|
||||
# * a permission Symbol (eg. :edit_project)
|
||||
# Context can be:
|
||||
# * a project : returns true if user is allowed to do the specified action on this project
|
||||
# * an array of projects : returns true if user is allowed on every project
|
||||
# * nil with options[:global] set : check if user has at least one role allowed for this action,
|
||||
# or falls back to Non Member / Anonymous permissions depending if the user is logged
|
||||
def allowed_to?(action, context, options={}, &block)
|
||||
if context && context.is_a?(Project)
|
||||
return false unless context.allows_to?(action)
|
||||
# Admin users are authorized for anything else
|
||||
return true if admin?
|
||||
|
||||
roles = roles_for_project(context)
|
||||
return false unless roles
|
||||
roles.any? {|role|
|
||||
(context.is_public? || role.member?) &&
|
||||
role.allowed_to?(action) &&
|
||||
(block_given? ? yield(role, self) : true)
|
||||
}
|
||||
elsif context && context.is_a?(Array)
|
||||
if context.empty?
|
||||
false
|
||||
else
|
||||
# Authorize if user is authorized on every element of the array
|
||||
context.map {|project| allowed_to?(action, project, options, &block)}.reduce(:&)
|
||||
end
|
||||
elsif context
|
||||
raise ArgumentError.new("#allowed_to? context argument must be a Project, an Array of projects or nil")
|
||||
elsif options[:global]
|
||||
# Admin users are always authorized
|
||||
return true if admin?
|
||||
|
||||
# authorize if user has at least one role that has this permission
|
||||
roles = self.roles.to_a | [builtin_role]
|
||||
roles.any? {|role|
|
||||
role.allowed_to?(action) &&
|
||||
(block_given? ? yield(role, self) : true)
|
||||
}
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
# Is the user allowed to do the specified action on any project?
|
||||
# See allowed_to? for the actions and valid options.
|
||||
#
|
||||
# NB: this method is not used anywhere in the core codebase as of
|
||||
# 2.5.2, but it's used by many plugins so if we ever want to remove
|
||||
# it it has to be carefully deprecated for a version or two.
|
||||
def allowed_to_globally?(action, options={}, &block)
|
||||
allowed_to?(action, nil, options.reverse_merge(:global => true), &block)
|
||||
end
|
||||
|
||||
def allowed_to_view_all_time_entries?(context)
|
||||
allowed_to?(:view_time_entries, context) do |role, user|
|
||||
role.time_entries_visibility == 'all'
|
||||
end
|
||||
end
|
||||
|
||||
# Returns true if the user is allowed to delete the user's own account
|
||||
def own_account_deletable?
|
||||
Setting.unsubscribe? &&
|
||||
(!admin? || User.active.admin.where("id <> ?", id).exists?)
|
||||
end
|
||||
|
||||
safe_attributes 'firstname',
|
||||
'lastname',
|
||||
'mail',
|
||||
'mail_notification',
|
||||
'notified_project_ids',
|
||||
'language',
|
||||
'custom_field_values',
|
||||
'custom_fields',
|
||||
'identity_url'
|
||||
|
||||
safe_attributes 'login',
|
||||
:if => lambda {|user, current_user| user.new_record?}
|
||||
|
||||
safe_attributes 'status',
|
||||
'auth_source_id',
|
||||
'generate_password',
|
||||
'must_change_passwd',
|
||||
'login',
|
||||
'admin',
|
||||
:if => lambda {|user, current_user| current_user.admin?}
|
||||
|
||||
safe_attributes 'group_ids',
|
||||
:if => lambda {|user, current_user| current_user.admin? && !user.new_record?}
|
||||
|
||||
# Utility method to help check if a user should be notified about an
|
||||
# event.
|
||||
#
|
||||
# TODO: only supports Issue events currently
|
||||
def notify_about?(object)
|
||||
if mail_notification == 'all'
|
||||
true
|
||||
elsif mail_notification.blank? || mail_notification == 'none'
|
||||
false
|
||||
else
|
||||
case object
|
||||
when Issue
|
||||
case mail_notification
|
||||
when 'selected', 'only_my_events'
|
||||
# user receives notifications for created/assigned issues on unselected projects
|
||||
object.author == self || is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.assigned_to_was)
|
||||
when 'only_assigned'
|
||||
is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.assigned_to_was)
|
||||
when 'only_owner'
|
||||
object.author == self
|
||||
end
|
||||
when News
|
||||
# always send to project members except when mail_notification is set to 'none'
|
||||
true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def self.current=(user)
|
||||
RequestStore.store[:current_user] = user
|
||||
end
|
||||
|
||||
def self.current
|
||||
RequestStore.store[:current_user] ||= User.anonymous
|
||||
end
|
||||
|
||||
# Returns the anonymous user. If the anonymous user does not exist, it is created. There can be only
|
||||
# one anonymous user per database.
|
||||
def self.anonymous
|
||||
anonymous_user = AnonymousUser.unscoped.first
|
||||
if anonymous_user.nil?
|
||||
anonymous_user = AnonymousUser.unscoped.create(:lastname => 'Anonymous', :firstname => '', :login => '', :status => 0)
|
||||
raise 'Unable to create the anonymous user.' if anonymous_user.new_record?
|
||||
end
|
||||
anonymous_user
|
||||
end
|
||||
|
||||
# Salts all existing unsalted passwords
|
||||
# It changes password storage scheme from SHA1(password) to SHA1(salt + SHA1(password))
|
||||
# This method is used in the SaltPasswords migration and is to be kept as is
|
||||
def self.salt_unsalted_passwords!
|
||||
transaction do
|
||||
User.where("salt IS NULL OR salt = ''").find_each do |user|
|
||||
next if user.hashed_password.blank?
|
||||
salt = User.generate_salt
|
||||
hashed_password = User.hash_password("#{salt}#{user.hashed_password}")
|
||||
User.where(:id => user.id).update_all(:salt => salt, :hashed_password => hashed_password)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def validate_password_length
|
||||
return if password.blank? && generate_password?
|
||||
# Password length validation based on setting
|
||||
if !password.nil? && password.size < Setting.password_min_length.to_i
|
||||
errors.add(:password, :too_short, :count => Setting.password_min_length.to_i)
|
||||
end
|
||||
end
|
||||
|
||||
def instantiate_email_address
|
||||
email_address || build_email_address
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_password_if_needed
|
||||
if generate_password? && auth_source.nil?
|
||||
length = [Setting.password_min_length.to_i + 2, 10].max
|
||||
random_password(length)
|
||||
end
|
||||
end
|
||||
|
||||
# Delete all outstanding password reset tokens on password change.
|
||||
# Delete the autologin tokens on password change to prohibit session leakage.
|
||||
# This helps to keep the account secure in case the associated email account
|
||||
# was compromised.
|
||||
def destroy_tokens
|
||||
if hashed_password_changed? || (status_changed? && !active?)
|
||||
tokens = ['recovery', 'autologin', 'session']
|
||||
Token.where(:user_id => id, :action => tokens).delete_all
|
||||
end
|
||||
end
|
||||
|
||||
# Removes references that are not handled by associations
|
||||
# Things that are not deleted are reassociated with the anonymous user
|
||||
def remove_references_before_destroy
|
||||
return if self.id.nil?
|
||||
|
||||
substitute = User.anonymous
|
||||
Attachment.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
|
||||
Comment.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
|
||||
Issue.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
|
||||
Issue.where(['assigned_to_id = ?', id]).update_all('assigned_to_id = NULL')
|
||||
Journal.where(['user_id = ?', id]).update_all(['user_id = ?', substitute.id])
|
||||
JournalDetail.
|
||||
where(["property = 'attr' AND prop_key = 'assigned_to_id' AND old_value = ?", id.to_s]).
|
||||
update_all(['old_value = ?', substitute.id.to_s])
|
||||
JournalDetail.
|
||||
where(["property = 'attr' AND prop_key = 'assigned_to_id' AND value = ?", id.to_s]).
|
||||
update_all(['value = ?', substitute.id.to_s])
|
||||
Message.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
|
||||
News.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
|
||||
# Remove private queries and keep public ones
|
||||
::Query.where('user_id = ? AND visibility = ?', id, ::Query::VISIBILITY_PRIVATE).delete_all
|
||||
::Query.where(['user_id = ?', id]).update_all(['user_id = ?', substitute.id])
|
||||
TimeEntry.where(['user_id = ?', id]).update_all(['user_id = ?', substitute.id])
|
||||
Token.where('user_id = ?', id).delete_all
|
||||
Watcher.where('user_id = ?', id).delete_all
|
||||
WikiContent.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
|
||||
WikiContent::Version.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
|
||||
end
|
||||
|
||||
# Return password digest
|
||||
def self.hash_password(clear_password)
|
||||
Digest::SHA1.hexdigest(clear_password || "")
|
||||
end
|
||||
|
||||
# Returns a 128bits random salt as a hex string (32 chars long)
|
||||
def self.generate_salt
|
||||
Redmine::Utils.random_hex(16)
|
||||
end
|
||||
|
||||
# Send a security notification to all admins if the user has gained/lost admin privileges
|
||||
def deliver_security_notification
|
||||
options = {
|
||||
field: :field_admin,
|
||||
value: login,
|
||||
title: :label_user_plural,
|
||||
url: {controller: 'users', action: 'index'}
|
||||
}
|
||||
|
||||
deliver = false
|
||||
if (admin? && id_changed? && active?) || # newly created admin
|
||||
(admin? && admin_changed? && active?) || # regular user became admin
|
||||
(admin? && status_changed? && active?) # locked admin became active again
|
||||
|
||||
deliver = true
|
||||
options[:message] = :mail_body_security_notification_add
|
||||
|
||||
elsif (admin? && destroyed? && active?) || # active admin user was deleted
|
||||
(!admin? && admin_changed? && active?) || # admin is no longer admin
|
||||
(admin? && status_changed? && !active?) # admin was locked
|
||||
|
||||
deliver = true
|
||||
options[:message] = :mail_body_security_notification_remove
|
||||
end
|
||||
|
||||
if deliver
|
||||
users = User.active.where(admin: true).to_a
|
||||
Mailer.security_notification(users, options).deliver
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
class AnonymousUser < User
|
||||
validate :validate_anonymous_uniqueness, :on => :create
|
||||
|
||||
self.valid_statuses = [STATUS_ANONYMOUS]
|
||||
|
||||
def validate_anonymous_uniqueness
|
||||
# There should be only one AnonymousUser in the database
|
||||
errors.add :base, 'An anonymous user already exists.' if AnonymousUser.exists?
|
||||
end
|
||||
|
||||
def available_custom_fields
|
||||
[]
|
||||
end
|
||||
|
||||
# Overrides a few properties
|
||||
def logged?; false end
|
||||
def admin; false end
|
||||
def name(*args); I18n.t(:label_user_anonymous) end
|
||||
def mail=(*args); nil end
|
||||
def mail; nil end
|
||||
def time_zone; nil end
|
||||
def rss_key; nil end
|
||||
|
||||
def pref
|
||||
UserPreference.new(:user => self)
|
||||
end
|
||||
|
||||
# Returns the user's bult-in role
|
||||
def builtin_role
|
||||
@builtin_role ||= Role.anonymous
|
||||
end
|
||||
|
||||
def membership(*args)
|
||||
nil
|
||||
end
|
||||
|
||||
def member_of?(*args)
|
||||
false
|
||||
end
|
||||
|
||||
# Anonymous user can not be destroyed
|
||||
def destroy
|
||||
false
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def instantiate_email_address
|
||||
end
|
||||
end
|
23
app/models/user_custom_field.rb
Normal file
23
app/models/user_custom_field.rb
Normal file
|
@ -0,0 +1,23 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
class UserCustomField < CustomField
|
||||
def type_name
|
||||
:label_user_plural
|
||||
end
|
||||
end
|
||||
|
168
app/models/user_preference.rb
Normal file
168
app/models/user_preference.rb
Normal file
|
@ -0,0 +1,168 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
class UserPreference < ActiveRecord::Base
|
||||
include Redmine::SafeAttributes
|
||||
|
||||
belongs_to :user
|
||||
serialize :others
|
||||
|
||||
attr_protected :others, :user_id
|
||||
|
||||
before_save :set_others_hash, :clear_unused_block_settings
|
||||
|
||||
safe_attributes 'hide_mail',
|
||||
'time_zone',
|
||||
'comments_sorting',
|
||||
'warn_on_leaving_unsaved',
|
||||
'no_self_notified',
|
||||
'textarea_font'
|
||||
|
||||
TEXTAREA_FONT_OPTIONS = ['monospace', 'proportional']
|
||||
|
||||
def initialize(attributes=nil, *args)
|
||||
super
|
||||
if new_record?
|
||||
unless attributes && attributes.key?(:hide_mail)
|
||||
self.hide_mail = Setting.default_users_hide_mail?
|
||||
end
|
||||
unless attributes && attributes.key?(:time_zone)
|
||||
self.time_zone = Setting.default_users_time_zone
|
||||
end
|
||||
unless attributes && attributes.key?(:no_self_notified)
|
||||
self.no_self_notified = true
|
||||
end
|
||||
end
|
||||
self.others ||= {}
|
||||
end
|
||||
|
||||
def set_others_hash
|
||||
self.others ||= {}
|
||||
end
|
||||
|
||||
def [](attr_name)
|
||||
if has_attribute? attr_name
|
||||
super
|
||||
else
|
||||
others ? others[attr_name] : nil
|
||||
end
|
||||
end
|
||||
|
||||
def []=(attr_name, value)
|
||||
if has_attribute? attr_name
|
||||
super
|
||||
else
|
||||
h = (read_attribute(:others) || {}).dup
|
||||
h.update(attr_name => value)
|
||||
write_attribute(:others, h)
|
||||
value
|
||||
end
|
||||
end
|
||||
|
||||
def comments_sorting; self[:comments_sorting] end
|
||||
def comments_sorting=(order); self[:comments_sorting]=order end
|
||||
|
||||
def warn_on_leaving_unsaved; self[:warn_on_leaving_unsaved] || '1'; end
|
||||
def warn_on_leaving_unsaved=(value); self[:warn_on_leaving_unsaved]=value; end
|
||||
|
||||
def no_self_notified; (self[:no_self_notified] == true || self[:no_self_notified] == '1'); end
|
||||
def no_self_notified=(value); self[:no_self_notified]=value; end
|
||||
|
||||
def activity_scope; Array(self[:activity_scope]) ; end
|
||||
def activity_scope=(value); self[:activity_scope]=value ; end
|
||||
|
||||
def textarea_font; self[:textarea_font] end
|
||||
def textarea_font=(value); self[:textarea_font]=value; end
|
||||
|
||||
# Returns the names of groups that are displayed on user's page
|
||||
# Example:
|
||||
# preferences.my_page_groups
|
||||
# # => ['top', 'left, 'right']
|
||||
def my_page_groups
|
||||
Redmine::MyPage.groups
|
||||
end
|
||||
|
||||
def my_page_layout
|
||||
self[:my_page_layout] ||= Redmine::MyPage.default_layout.deep_dup
|
||||
end
|
||||
|
||||
def my_page_layout=(arg)
|
||||
self[:my_page_layout] = arg
|
||||
end
|
||||
|
||||
def my_page_settings(block=nil)
|
||||
s = self[:my_page_settings] ||= {}
|
||||
if block
|
||||
s[block] ||= {}
|
||||
else
|
||||
s
|
||||
end
|
||||
end
|
||||
|
||||
def my_page_settings=(arg)
|
||||
self[:my_page_settings] = arg
|
||||
end
|
||||
|
||||
# Removes block from the user page layout
|
||||
# Example:
|
||||
# preferences.remove_block('news')
|
||||
def remove_block(block)
|
||||
block = block.to_s.underscore
|
||||
my_page_layout.keys.each do |group|
|
||||
my_page_layout[group].delete(block)
|
||||
end
|
||||
my_page_layout
|
||||
end
|
||||
|
||||
# Adds block to the user page layout
|
||||
# Returns nil if block is not valid or if it's already
|
||||
# present in the user page layout
|
||||
def add_block(block)
|
||||
block = block.to_s.underscore
|
||||
return unless Redmine::MyPage.valid_block?(block, my_page_layout.values.flatten)
|
||||
|
||||
remove_block(block)
|
||||
# add it to the first group
|
||||
group = my_page_groups.first
|
||||
my_page_layout[group] ||= []
|
||||
my_page_layout[group].unshift(block)
|
||||
end
|
||||
|
||||
# Sets the block order for the given group.
|
||||
# Example:
|
||||
# preferences.order_blocks('left', ['issueswatched', 'news'])
|
||||
def order_blocks(group, blocks)
|
||||
group = group.to_s
|
||||
if Redmine::MyPage.groups.include?(group) && blocks.present?
|
||||
blocks = blocks.map(&:underscore) & my_page_layout.values.flatten
|
||||
blocks.each {|block| remove_block(block)}
|
||||
my_page_layout[group] = blocks
|
||||
end
|
||||
end
|
||||
|
||||
def update_block_settings(block, settings)
|
||||
block = block.to_s
|
||||
block_settings = my_page_settings(block).merge(settings.symbolize_keys)
|
||||
my_page_settings[block] = block_settings
|
||||
end
|
||||
|
||||
def clear_unused_block_settings
|
||||
blocks = my_page_layout.values.flatten
|
||||
my_page_settings.keep_if {|block, settings| blocks.include?(block)}
|
||||
end
|
||||
private :clear_unused_block_settings
|
||||
end
|
362
app/models/version.rb
Normal file
362
app/models/version.rb
Normal file
|
@ -0,0 +1,362 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
class Version < ActiveRecord::Base
|
||||
include Redmine::SafeAttributes
|
||||
|
||||
after_update :update_issues_from_sharing_change
|
||||
after_save :update_default_project_version
|
||||
before_destroy :nullify_projects_default_version
|
||||
|
||||
belongs_to :project
|
||||
has_many :fixed_issues, :class_name => 'Issue', :foreign_key => 'fixed_version_id', :dependent => :nullify
|
||||
acts_as_customizable
|
||||
acts_as_attachable :view_permission => :view_files,
|
||||
:edit_permission => :manage_files,
|
||||
:delete_permission => :manage_files
|
||||
|
||||
VERSION_STATUSES = %w(open locked closed)
|
||||
VERSION_SHARINGS = %w(none descendants hierarchy tree system)
|
||||
|
||||
validates_presence_of :name
|
||||
validates_uniqueness_of :name, :scope => [:project_id]
|
||||
validates_length_of :name, :maximum => 60
|
||||
validates_length_of :description, :wiki_page_title, :maximum => 255
|
||||
validates :effective_date, :date => true
|
||||
validates_inclusion_of :status, :in => VERSION_STATUSES
|
||||
validates_inclusion_of :sharing, :in => VERSION_SHARINGS
|
||||
attr_protected :id
|
||||
|
||||
scope :named, lambda {|arg| where("LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip)}
|
||||
scope :like, lambda {|arg|
|
||||
if arg.present?
|
||||
pattern = "%#{arg.to_s.strip}%"
|
||||
where("LOWER(#{Version.table_name}.name) LIKE :p", :p => pattern)
|
||||
end
|
||||
}
|
||||
scope :open, lambda { where(:status => 'open') }
|
||||
scope :status, lambda {|status|
|
||||
if status.present?
|
||||
where(:status => status.to_s)
|
||||
end
|
||||
}
|
||||
scope :visible, lambda {|*args|
|
||||
joins(:project).
|
||||
where(Project.allowed_to_condition(args.first || User.current, :view_issues))
|
||||
}
|
||||
|
||||
safe_attributes 'name',
|
||||
'description',
|
||||
'effective_date',
|
||||
'due_date',
|
||||
'wiki_page_title',
|
||||
'status',
|
||||
'sharing',
|
||||
'default_project_version',
|
||||
'custom_field_values',
|
||||
'custom_fields'
|
||||
|
||||
# Returns true if +user+ or current user is allowed to view the version
|
||||
def visible?(user=User.current)
|
||||
user.allowed_to?(:view_issues, self.project)
|
||||
end
|
||||
|
||||
# Version files have same visibility as project files
|
||||
def attachments_visible?(*args)
|
||||
project.present? && project.attachments_visible?(*args)
|
||||
end
|
||||
|
||||
def attachments_deletable?(usr=User.current)
|
||||
project.present? && project.attachments_deletable?(usr)
|
||||
end
|
||||
|
||||
alias :base_reload :reload
|
||||
def reload(*args)
|
||||
@default_project_version = nil
|
||||
base_reload(*args)
|
||||
end
|
||||
|
||||
def start_date
|
||||
@start_date ||= fixed_issues.minimum('start_date')
|
||||
end
|
||||
|
||||
def due_date
|
||||
effective_date
|
||||
end
|
||||
|
||||
def due_date=(arg)
|
||||
self.effective_date=(arg)
|
||||
end
|
||||
|
||||
# Returns the total estimated time for this version
|
||||
# (sum of leaves estimated_hours)
|
||||
def estimated_hours
|
||||
@estimated_hours ||= fixed_issues.sum(:estimated_hours).to_f
|
||||
end
|
||||
|
||||
# Returns the total reported time for this version
|
||||
def spent_hours
|
||||
@spent_hours ||= TimeEntry.joins(:issue).where("#{Issue.table_name}.fixed_version_id = ?", id).sum(:hours).to_f
|
||||
end
|
||||
|
||||
def closed?
|
||||
status == 'closed'
|
||||
end
|
||||
|
||||
def open?
|
||||
status == 'open'
|
||||
end
|
||||
|
||||
# Returns true if the version is completed: closed or due date reached and no open issues
|
||||
def completed?
|
||||
closed? || (effective_date && (effective_date < User.current.today) && (open_issues_count == 0))
|
||||
end
|
||||
|
||||
def behind_schedule?
|
||||
if completed_percent == 100
|
||||
return false
|
||||
elsif due_date && start_date
|
||||
done_date = start_date + ((due_date - start_date+1)* completed_percent/100).floor
|
||||
return done_date <= User.current.today
|
||||
else
|
||||
false # No issues so it's not late
|
||||
end
|
||||
end
|
||||
|
||||
# Returns the completion percentage of this version based on the amount of open/closed issues
|
||||
# and the time spent on the open issues.
|
||||
def completed_percent
|
||||
if issues_count == 0
|
||||
0
|
||||
elsif open_issues_count == 0
|
||||
100
|
||||
else
|
||||
issues_progress(false) + issues_progress(true)
|
||||
end
|
||||
end
|
||||
|
||||
# Returns the percentage of issues that have been marked as 'closed'.
|
||||
def closed_percent
|
||||
if issues_count == 0
|
||||
0
|
||||
else
|
||||
issues_progress(false)
|
||||
end
|
||||
end
|
||||
|
||||
# Returns true if the version is overdue: due date reached and some open issues
|
||||
def overdue?
|
||||
effective_date && (effective_date < User.current.today) && (open_issues_count > 0)
|
||||
end
|
||||
|
||||
# Returns assigned issues count
|
||||
def issues_count
|
||||
load_issue_counts
|
||||
@issue_count
|
||||
end
|
||||
|
||||
# Returns the total amount of open issues for this version.
|
||||
def open_issues_count
|
||||
load_issue_counts
|
||||
@open_issues_count
|
||||
end
|
||||
|
||||
# Returns the total amount of closed issues for this version.
|
||||
def closed_issues_count
|
||||
load_issue_counts
|
||||
@closed_issues_count
|
||||
end
|
||||
|
||||
def wiki_page
|
||||
if project.wiki && !wiki_page_title.blank?
|
||||
@wiki_page ||= project.wiki.find_page(wiki_page_title)
|
||||
end
|
||||
@wiki_page
|
||||
end
|
||||
|
||||
def to_s; name end
|
||||
|
||||
def to_s_with_project
|
||||
"#{project} - #{name}"
|
||||
end
|
||||
|
||||
# Versions are sorted by effective_date and name
|
||||
# Those with no effective_date are at the end, sorted by name
|
||||
def <=>(version)
|
||||
if self.effective_date
|
||||
if version.effective_date
|
||||
if self.effective_date == version.effective_date
|
||||
name == version.name ? id <=> version.id : name <=> version.name
|
||||
else
|
||||
self.effective_date <=> version.effective_date
|
||||
end
|
||||
else
|
||||
-1
|
||||
end
|
||||
else
|
||||
if version.effective_date
|
||||
1
|
||||
else
|
||||
name == version.name ? id <=> version.id : name <=> version.name
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Sort versions by status (open, locked then closed versions)
|
||||
def self.sort_by_status(versions)
|
||||
versions.sort do |a, b|
|
||||
if a.status == b.status
|
||||
a <=> b
|
||||
else
|
||||
b.status <=> a.status
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def css_classes
|
||||
[
|
||||
completed? ? 'version-completed' : 'version-incompleted',
|
||||
"version-#{status}"
|
||||
].join(' ')
|
||||
end
|
||||
|
||||
def self.fields_for_order_statement(table=nil)
|
||||
table ||= table_name
|
||||
["(CASE WHEN #{table}.effective_date IS NULL THEN 1 ELSE 0 END)", "#{table}.effective_date", "#{table}.name", "#{table}.id"]
|
||||
end
|
||||
|
||||
scope :sorted, lambda { order(fields_for_order_statement) }
|
||||
|
||||
# Returns the sharings that +user+ can set the version to
|
||||
def allowed_sharings(user = User.current)
|
||||
VERSION_SHARINGS.select do |s|
|
||||
if sharing == s
|
||||
true
|
||||
else
|
||||
case s
|
||||
when 'system'
|
||||
# Only admin users can set a systemwide sharing
|
||||
user.admin?
|
||||
when 'hierarchy', 'tree'
|
||||
# Only users allowed to manage versions of the root project can
|
||||
# set sharing to hierarchy or tree
|
||||
project.nil? || user.allowed_to?(:manage_versions, project.root)
|
||||
else
|
||||
true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Returns true if the version is shared, otherwise false
|
||||
def shared?
|
||||
sharing != 'none'
|
||||
end
|
||||
|
||||
def deletable?
|
||||
fixed_issues.empty? && !referenced_by_a_custom_field?
|
||||
end
|
||||
|
||||
def default_project_version
|
||||
if @default_project_version.nil?
|
||||
project.present? && project.default_version == self
|
||||
else
|
||||
@default_project_version
|
||||
end
|
||||
end
|
||||
|
||||
def default_project_version=(arg)
|
||||
@default_project_version = (arg == '1' || arg == true)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_issue_counts
|
||||
unless @issue_count
|
||||
@open_issues_count = 0
|
||||
@closed_issues_count = 0
|
||||
fixed_issues.group(:status).count.each do |status, count|
|
||||
if status.is_closed?
|
||||
@closed_issues_count += count
|
||||
else
|
||||
@open_issues_count += count
|
||||
end
|
||||
end
|
||||
@issue_count = @open_issues_count + @closed_issues_count
|
||||
end
|
||||
end
|
||||
|
||||
# Update the issue's fixed versions. Used if a version's sharing changes.
|
||||
def update_issues_from_sharing_change
|
||||
if sharing_changed?
|
||||
if VERSION_SHARINGS.index(sharing_was).nil? ||
|
||||
VERSION_SHARINGS.index(sharing).nil? ||
|
||||
VERSION_SHARINGS.index(sharing_was) > VERSION_SHARINGS.index(sharing)
|
||||
Issue.update_versions_from_sharing_change self
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def update_default_project_version
|
||||
if @default_project_version && project.present?
|
||||
project.update_columns :default_version_id => id
|
||||
end
|
||||
end
|
||||
|
||||
# Returns the average estimated time of assigned issues
|
||||
# or 1 if no issue has an estimated time
|
||||
# Used to weight unestimated issues in progress calculation
|
||||
def estimated_average
|
||||
if @estimated_average.nil?
|
||||
average = fixed_issues.average(:estimated_hours).to_f
|
||||
if average == 0
|
||||
average = 1
|
||||
end
|
||||
@estimated_average = average
|
||||
end
|
||||
@estimated_average
|
||||
end
|
||||
|
||||
# Returns the total progress of open or closed issues. The returned percentage takes into account
|
||||
# the amount of estimated time set for this version.
|
||||
#
|
||||
# Examples:
|
||||
# issues_progress(true) => returns the progress percentage for open issues.
|
||||
# issues_progress(false) => returns the progress percentage for closed issues.
|
||||
def issues_progress(open)
|
||||
@issues_progress ||= {}
|
||||
@issues_progress[open] ||= begin
|
||||
progress = 0
|
||||
if issues_count > 0
|
||||
ratio = open ? 'done_ratio' : 100
|
||||
|
||||
done = fixed_issues.open(open).sum("COALESCE(estimated_hours, #{estimated_average}) * #{ratio}").to_f
|
||||
progress = done / (estimated_average * issues_count)
|
||||
end
|
||||
progress
|
||||
end
|
||||
end
|
||||
|
||||
def referenced_by_a_custom_field?
|
||||
CustomValue.joins(:custom_field).
|
||||
where(:value => id.to_s, :custom_fields => {:field_format => 'version'}).any?
|
||||
end
|
||||
|
||||
def nullify_projects_default_version
|
||||
Project.where(:default_version_id => id).update_all(:default_version_id => nil)
|
||||
end
|
||||
end
|
22
app/models/version_custom_field.rb
Normal file
22
app/models/version_custom_field.rb
Normal file
|
@ -0,0 +1,22 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
class VersionCustomField < CustomField
|
||||
def type_name
|
||||
:label_version_plural
|
||||
end
|
||||
end
|
81
app/models/watcher.rb
Normal file
81
app/models/watcher.rb
Normal file
|
@ -0,0 +1,81 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
class Watcher < ActiveRecord::Base
|
||||
belongs_to :watchable, :polymorphic => true
|
||||
belongs_to :user
|
||||
|
||||
validates_presence_of :user
|
||||
validates_uniqueness_of :user_id, :scope => [:watchable_type, :watchable_id]
|
||||
validate :validate_user
|
||||
attr_protected :id
|
||||
|
||||
# Returns true if at least one object among objects is watched by user
|
||||
def self.any_watched?(objects, user)
|
||||
objects = objects.reject(&:new_record?)
|
||||
if objects.any?
|
||||
objects.group_by {|object| object.class.base_class}.each do |base_class, objects|
|
||||
if Watcher.where(:watchable_type => base_class.name, :watchable_id => objects.map(&:id), :user_id => user.id).exists?
|
||||
return true
|
||||
end
|
||||
end
|
||||
end
|
||||
false
|
||||
end
|
||||
|
||||
# Unwatch things that users are no longer allowed to view
|
||||
def self.prune(options={})
|
||||
if options.has_key?(:user)
|
||||
prune_single_user(options[:user], options)
|
||||
else
|
||||
pruned = 0
|
||||
User.where("id IN (SELECT DISTINCT user_id FROM #{table_name})").each do |user|
|
||||
pruned += prune_single_user(user, options)
|
||||
end
|
||||
pruned
|
||||
end
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def validate_user
|
||||
errors.add :user_id, :invalid unless user.nil? || user.active?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def self.prune_single_user(user, options={})
|
||||
return unless user.is_a?(User)
|
||||
pruned = 0
|
||||
where(:user_id => user.id).each do |watcher|
|
||||
next if watcher.watchable.nil?
|
||||
if options.has_key?(:project)
|
||||
unless watcher.watchable.respond_to?(:project) &&
|
||||
watcher.watchable.project == options[:project]
|
||||
next
|
||||
end
|
||||
end
|
||||
if watcher.watchable.respond_to?(:visible?)
|
||||
unless watcher.watchable.visible?(user)
|
||||
watcher.destroy
|
||||
pruned += 1
|
||||
end
|
||||
end
|
||||
end
|
||||
pruned
|
||||
end
|
||||
end
|
107
app/models/wiki.rb
Normal file
107
app/models/wiki.rb
Normal file
|
@ -0,0 +1,107 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
class Wiki < ActiveRecord::Base
|
||||
include Redmine::SafeAttributes
|
||||
belongs_to :project
|
||||
has_many :pages, lambda {order('title')}, :class_name => 'WikiPage', :dependent => :destroy
|
||||
has_many :redirects, :class_name => 'WikiRedirect'
|
||||
|
||||
acts_as_watchable
|
||||
|
||||
validates_presence_of :start_page
|
||||
validates_format_of :start_page, :with => /\A[^,\.\/\?\;\|\:]*\z/
|
||||
validates_length_of :start_page, maximum: 255
|
||||
attr_protected :id
|
||||
|
||||
before_destroy :delete_redirects
|
||||
|
||||
safe_attributes 'start_page'
|
||||
|
||||
def visible?(user=User.current)
|
||||
!user.nil? && user.allowed_to?(:view_wiki_pages, project)
|
||||
end
|
||||
|
||||
# Returns the wiki page that acts as the sidebar content
|
||||
# or nil if no such page exists
|
||||
def sidebar
|
||||
@sidebar ||= find_page('Sidebar', :with_redirect => false)
|
||||
end
|
||||
|
||||
# find the page with the given title
|
||||
# if page doesn't exist, return a new page
|
||||
def find_or_new_page(title)
|
||||
title = start_page if title.blank?
|
||||
find_page(title) || WikiPage.new(:wiki => self, :title => Wiki.titleize(title))
|
||||
end
|
||||
|
||||
# find the page with the given title
|
||||
def find_page(title, options = {})
|
||||
@page_found_with_redirect = false
|
||||
title = start_page if title.blank?
|
||||
title = Wiki.titleize(title)
|
||||
page = pages.where("LOWER(title) = LOWER(?)", title).first
|
||||
if page.nil? && options[:with_redirect] != false
|
||||
# search for a redirect
|
||||
redirect = redirects.where("LOWER(title) = LOWER(?)", title).first
|
||||
if redirect
|
||||
page = redirect.target_page
|
||||
@page_found_with_redirect = true
|
||||
end
|
||||
end
|
||||
page
|
||||
end
|
||||
|
||||
# Returns true if the last page was found with a redirect
|
||||
def page_found_with_redirect?
|
||||
@page_found_with_redirect
|
||||
end
|
||||
|
||||
# Deletes all redirects from/to the wiki
|
||||
def delete_redirects
|
||||
WikiRedirect.where(:wiki_id => id).delete_all
|
||||
WikiRedirect.where(:redirects_to_wiki_id => id).delete_all
|
||||
end
|
||||
|
||||
# Finds a page by title
|
||||
# The given string can be of one of the forms: "title" or "project:title"
|
||||
# Examples:
|
||||
# Wiki.find_page("bar", project => foo)
|
||||
# Wiki.find_page("foo:bar")
|
||||
def self.find_page(title, options = {})
|
||||
project = options[:project]
|
||||
if title.to_s =~ %r{^([^\:]+)\:(.*)$}
|
||||
project_identifier, title = $1, $2
|
||||
project = Project.find_by_identifier(project_identifier) || Project.find_by_name(project_identifier)
|
||||
end
|
||||
if project && project.wiki
|
||||
page = project.wiki.find_page(title)
|
||||
if page && page.content
|
||||
page
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# turn a string into a valid page title
|
||||
def self.titleize(title)
|
||||
# replace spaces with _ and remove unwanted caracters
|
||||
title = title.gsub(/\s+/, '_').delete(',./?;|:') if title
|
||||
# upcase the first letter
|
||||
title = (title.slice(0..0).upcase + (title.slice(1..-1) || '')) if title
|
||||
title
|
||||
end
|
||||
end
|
174
app/models/wiki_content.rb
Normal file
174
app/models/wiki_content.rb
Normal file
|
@ -0,0 +1,174 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
require 'zlib'
|
||||
|
||||
class WikiContent < ActiveRecord::Base
|
||||
self.locking_column = 'version'
|
||||
belongs_to :page, :class_name => 'WikiPage'
|
||||
belongs_to :author, :class_name => 'User'
|
||||
validates_presence_of :text
|
||||
validates_length_of :comments, :maximum => 1024, :allow_nil => true
|
||||
attr_protected :id
|
||||
|
||||
acts_as_versioned
|
||||
|
||||
after_save :send_notification
|
||||
|
||||
scope :without_text, lambda {select(:id, :page_id, :version, :updated_on)}
|
||||
|
||||
def visible?(user=User.current)
|
||||
page.visible?(user)
|
||||
end
|
||||
|
||||
def project
|
||||
page.project
|
||||
end
|
||||
|
||||
def attachments
|
||||
page.nil? ? [] : page.attachments
|
||||
end
|
||||
|
||||
def notified_users
|
||||
project.notified_users.reject {|user| !visible?(user)}
|
||||
end
|
||||
|
||||
# Returns the mail addresses of users that should be notified
|
||||
def recipients
|
||||
notified_users.collect(&:mail)
|
||||
end
|
||||
|
||||
# Return true if the content is the current page content
|
||||
def current_version?
|
||||
true
|
||||
end
|
||||
|
||||
class Version
|
||||
belongs_to :page, :class_name => '::WikiPage'
|
||||
belongs_to :author, :class_name => '::User'
|
||||
attr_protected :data
|
||||
|
||||
acts_as_event :title => Proc.new {|o| "#{l(:label_wiki_edit)}: #{o.page.title} (##{o.version})"},
|
||||
:description => :comments,
|
||||
:datetime => :updated_on,
|
||||
:type => 'wiki-page',
|
||||
:group => :page,
|
||||
:url => Proc.new {|o| {:controller => 'wiki', :action => 'show', :project_id => o.page.wiki.project, :id => o.page.title, :version => o.version}}
|
||||
|
||||
acts_as_activity_provider :type => 'wiki_edits',
|
||||
:timestamp => "#{WikiContent.versioned_table_name}.updated_on",
|
||||
:author_key => "#{WikiContent.versioned_table_name}.author_id",
|
||||
:permission => :view_wiki_edits,
|
||||
:scope => select("#{WikiContent.versioned_table_name}.updated_on, #{WikiContent.versioned_table_name}.comments, " +
|
||||
"#{WikiContent.versioned_table_name}.#{WikiContent.version_column}, #{WikiPage.table_name}.title, " +
|
||||
"#{WikiContent.versioned_table_name}.page_id, #{WikiContent.versioned_table_name}.author_id, " +
|
||||
"#{WikiContent.versioned_table_name}.id").
|
||||
joins("LEFT JOIN #{WikiPage.table_name} ON #{WikiPage.table_name}.id = #{WikiContent.versioned_table_name}.page_id " +
|
||||
"LEFT JOIN #{Wiki.table_name} ON #{Wiki.table_name}.id = #{WikiPage.table_name}.wiki_id " +
|
||||
"LEFT JOIN #{Project.table_name} ON #{Project.table_name}.id = #{Wiki.table_name}.project_id")
|
||||
|
||||
after_destroy :page_update_after_destroy
|
||||
|
||||
def text=(plain)
|
||||
case Setting.wiki_compression
|
||||
when 'gzip'
|
||||
begin
|
||||
self.data = Zlib::Deflate.deflate(plain, Zlib::BEST_COMPRESSION)
|
||||
self.compression = 'gzip'
|
||||
rescue
|
||||
self.data = plain
|
||||
self.compression = ''
|
||||
end
|
||||
else
|
||||
self.data = plain
|
||||
self.compression = ''
|
||||
end
|
||||
plain
|
||||
end
|
||||
|
||||
def text
|
||||
@text ||= begin
|
||||
str = case compression
|
||||
when 'gzip'
|
||||
Zlib::Inflate.inflate(data)
|
||||
else
|
||||
# uncompressed data
|
||||
data
|
||||
end
|
||||
str.force_encoding("UTF-8")
|
||||
str
|
||||
end
|
||||
end
|
||||
|
||||
def project
|
||||
page.project
|
||||
end
|
||||
|
||||
def attachments
|
||||
page.nil? ? [] : page.attachments
|
||||
end
|
||||
|
||||
# Return true if the content is the current page content
|
||||
def current_version?
|
||||
page.content.version == self.version
|
||||
end
|
||||
|
||||
# Returns the previous version or nil
|
||||
def previous
|
||||
@previous ||= WikiContent::Version.
|
||||
reorder('version DESC').
|
||||
includes(:author).
|
||||
where("wiki_content_id = ? AND version < ?", wiki_content_id, version).first
|
||||
end
|
||||
|
||||
# Returns the next version or nil
|
||||
def next
|
||||
@next ||= WikiContent::Version.
|
||||
reorder('version ASC').
|
||||
includes(:author).
|
||||
where("wiki_content_id = ? AND version > ?", wiki_content_id, version).first
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Updates page's content if the latest version is removed
|
||||
# or destroys the page if it was the only version
|
||||
def page_update_after_destroy
|
||||
latest = page.content.versions.reorder("#{self.class.table_name}.version DESC").first
|
||||
if latest && page.content.version != latest.version
|
||||
raise ActiveRecord::Rollback unless page.content.revert_to!(latest)
|
||||
elsif latest.nil?
|
||||
raise ActiveRecord::Rollback unless page.destroy
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def send_notification
|
||||
# new_record? returns false in after_save callbacks
|
||||
if id_changed?
|
||||
if Setting.notified_events.include?('wiki_content_added')
|
||||
Mailer.wiki_content_added(self).deliver
|
||||
end
|
||||
elsif text_changed?
|
||||
if Setting.notified_events.include?('wiki_content_updated')
|
||||
Mailer.wiki_content_updated(self).deliver
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
298
app/models/wiki_page.rb
Normal file
298
app/models/wiki_page.rb
Normal file
|
@ -0,0 +1,298 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
require 'diff'
|
||||
require 'enumerator'
|
||||
|
||||
class WikiPage < ActiveRecord::Base
|
||||
include Redmine::SafeAttributes
|
||||
|
||||
belongs_to :wiki
|
||||
has_one :content, :class_name => 'WikiContent', :foreign_key => 'page_id', :dependent => :destroy
|
||||
has_one :content_without_text, lambda {without_text.readonly}, :class_name => 'WikiContent', :foreign_key => 'page_id'
|
||||
|
||||
acts_as_attachable :delete_permission => :delete_wiki_pages_attachments
|
||||
acts_as_tree :dependent => :nullify, :order => 'title'
|
||||
|
||||
acts_as_watchable
|
||||
acts_as_event :title => Proc.new {|o| "#{l(:label_wiki)}: #{o.title}"},
|
||||
:description => :text,
|
||||
:datetime => :created_on,
|
||||
:url => Proc.new {|o| {:controller => 'wiki', :action => 'show', :project_id => o.wiki.project, :id => o.title}}
|
||||
|
||||
acts_as_searchable :columns => ['title', "#{WikiContent.table_name}.text"],
|
||||
:scope => joins(:content, {:wiki => :project}),
|
||||
:preload => [:content, {:wiki => :project}],
|
||||
:permission => :view_wiki_pages,
|
||||
:project_key => "#{Wiki.table_name}.project_id"
|
||||
|
||||
attr_accessor :redirect_existing_links
|
||||
|
||||
validates_presence_of :title
|
||||
validates_format_of :title, :with => /\A[^,\.\/\?\;\|\s]*\z/
|
||||
validates_uniqueness_of :title, :scope => :wiki_id, :case_sensitive => false
|
||||
validates_length_of :title, maximum: 255
|
||||
validates_associated :content
|
||||
attr_protected :id
|
||||
|
||||
validate :validate_parent_title
|
||||
before_destroy :delete_redirects
|
||||
before_save :handle_rename_or_move
|
||||
after_save :handle_children_move
|
||||
|
||||
# eager load information about last updates, without loading text
|
||||
scope :with_updated_on, lambda { preload(:content_without_text) }
|
||||
|
||||
# Wiki pages that are protected by default
|
||||
DEFAULT_PROTECTED_PAGES = %w(sidebar)
|
||||
|
||||
safe_attributes 'parent_id', 'parent_title', 'title', 'redirect_existing_links', 'wiki_id',
|
||||
:if => lambda {|page, user| page.new_record? || user.allowed_to?(:rename_wiki_pages, page.project)}
|
||||
|
||||
def initialize(attributes=nil, *args)
|
||||
super
|
||||
if new_record? && DEFAULT_PROTECTED_PAGES.include?(title.to_s.downcase)
|
||||
self.protected = true
|
||||
end
|
||||
end
|
||||
|
||||
def visible?(user=User.current)
|
||||
!user.nil? && user.allowed_to?(:view_wiki_pages, project)
|
||||
end
|
||||
|
||||
def title=(value)
|
||||
value = Wiki.titleize(value)
|
||||
write_attribute(:title, value)
|
||||
end
|
||||
|
||||
def safe_attributes=(attrs, user=User.current)
|
||||
return unless attrs.is_a?(Hash)
|
||||
attrs = attrs.deep_dup
|
||||
|
||||
# Project and Tracker must be set before since new_statuses_allowed_to depends on it.
|
||||
if (w_id = attrs.delete('wiki_id')) && safe_attribute?('wiki_id')
|
||||
if (w = Wiki.find_by_id(w_id)) && w.project && user.allowed_to?(:rename_wiki_pages, w.project)
|
||||
self.wiki = w
|
||||
end
|
||||
end
|
||||
|
||||
super attrs, user
|
||||
end
|
||||
|
||||
# Manages redirects if page is renamed or moved
|
||||
def handle_rename_or_move
|
||||
if !new_record? && (title_changed? || wiki_id_changed?)
|
||||
# Update redirects that point to the old title
|
||||
WikiRedirect.where(:redirects_to => title_was, :redirects_to_wiki_id => wiki_id_was).each do |r|
|
||||
r.redirects_to = title
|
||||
r.redirects_to_wiki_id = wiki_id
|
||||
(r.title == r.redirects_to && r.wiki_id == r.redirects_to_wiki_id) ? r.destroy : r.save
|
||||
end
|
||||
# Remove redirects for the new title
|
||||
WikiRedirect.where(:wiki_id => wiki_id, :title => title).delete_all
|
||||
# Create a redirect to the new title
|
||||
unless redirect_existing_links == "0"
|
||||
WikiRedirect.create(
|
||||
:wiki_id => wiki_id_was, :title => title_was,
|
||||
:redirects_to_wiki_id => wiki_id, :redirects_to => title
|
||||
)
|
||||
end
|
||||
end
|
||||
if !new_record? && wiki_id_changed? && parent.present?
|
||||
unless parent.wiki_id == wiki_id
|
||||
self.parent_id = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
private :handle_rename_or_move
|
||||
|
||||
# Moves child pages if page was moved
|
||||
def handle_children_move
|
||||
if !new_record? && wiki_id_changed?
|
||||
children.each do |child|
|
||||
child.wiki_id = wiki_id
|
||||
child.redirect_existing_links = redirect_existing_links
|
||||
unless child.save
|
||||
WikiPage.where(:id => child.id).update_all :parent_id => nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
private :handle_children_move
|
||||
|
||||
# Deletes redirects to this page
|
||||
def delete_redirects
|
||||
WikiRedirect.where(:redirects_to_wiki_id => wiki_id, :redirects_to => title).delete_all
|
||||
end
|
||||
|
||||
def pretty_title
|
||||
WikiPage.pretty_title(title)
|
||||
end
|
||||
|
||||
def content_for_version(version=nil)
|
||||
if content
|
||||
result = content.versions.find_by_version(version.to_i) if version
|
||||
result ||= content
|
||||
result
|
||||
end
|
||||
end
|
||||
|
||||
def diff(version_to=nil, version_from=nil)
|
||||
version_to = version_to ? version_to.to_i : self.content.version
|
||||
content_to = content.versions.find_by_version(version_to)
|
||||
content_from = version_from ? content.versions.find_by_version(version_from.to_i) : content_to.try(:previous)
|
||||
return nil unless content_to && content_from
|
||||
|
||||
if content_from.version > content_to.version
|
||||
content_to, content_from = content_from, content_to
|
||||
end
|
||||
|
||||
(content_to && content_from) ? WikiDiff.new(content_to, content_from) : nil
|
||||
end
|
||||
|
||||
def annotate(version=nil)
|
||||
version = version ? version.to_i : self.content.version
|
||||
c = content.versions.find_by_version(version)
|
||||
c ? WikiAnnotate.new(c) : nil
|
||||
end
|
||||
|
||||
def self.pretty_title(str)
|
||||
(str && str.is_a?(String)) ? str.tr('_', ' ') : str
|
||||
end
|
||||
|
||||
def project
|
||||
wiki.try(:project)
|
||||
end
|
||||
|
||||
def text
|
||||
content.text if content
|
||||
end
|
||||
|
||||
def updated_on
|
||||
content_attribute(:updated_on)
|
||||
end
|
||||
|
||||
def version
|
||||
content_attribute(:version)
|
||||
end
|
||||
|
||||
# Returns true if usr is allowed to edit the page, otherwise false
|
||||
def editable_by?(usr)
|
||||
!protected? || usr.allowed_to?(:protect_wiki_pages, wiki.project)
|
||||
end
|
||||
|
||||
def attachments_deletable?(usr=User.current)
|
||||
editable_by?(usr) && super(usr)
|
||||
end
|
||||
|
||||
def parent_title
|
||||
@parent_title || (self.parent && self.parent.pretty_title)
|
||||
end
|
||||
|
||||
def parent_title=(t)
|
||||
@parent_title = t
|
||||
parent_page = t.blank? ? nil : self.wiki.find_page(t)
|
||||
self.parent = parent_page
|
||||
end
|
||||
|
||||
# Saves the page and its content if text was changed
|
||||
# Return true if the page was saved
|
||||
def save_with_content(content)
|
||||
ret = nil
|
||||
transaction do
|
||||
ret = save
|
||||
if content.text_changed?
|
||||
begin
|
||||
self.content = content
|
||||
ret = ret && content.changed?
|
||||
rescue ActiveRecord::RecordNotSaved
|
||||
ret = false
|
||||
end
|
||||
end
|
||||
raise ActiveRecord::Rollback unless ret
|
||||
end
|
||||
ret
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def validate_parent_title
|
||||
errors.add(:parent_title, :invalid) if !@parent_title.blank? && parent.nil?
|
||||
errors.add(:parent_title, :circular_dependency) if parent && (parent == self || parent.ancestors.include?(self))
|
||||
if parent_id_changed? && parent && (parent.wiki_id != wiki_id)
|
||||
errors.add(:parent_title, :not_same_project)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def content_attribute(name)
|
||||
(association(:content).loaded? ? content : content_without_text).try(name)
|
||||
end
|
||||
end
|
||||
|
||||
class WikiDiff < Redmine::Helpers::Diff
|
||||
attr_reader :content_to, :content_from
|
||||
|
||||
def initialize(content_to, content_from)
|
||||
@content_to = content_to
|
||||
@content_from = content_from
|
||||
super(content_to.text, content_from.text)
|
||||
end
|
||||
end
|
||||
|
||||
class WikiAnnotate
|
||||
attr_reader :lines, :content
|
||||
|
||||
def initialize(content)
|
||||
@content = content
|
||||
current = content
|
||||
current_lines = current.text.split(/\r?\n/)
|
||||
@lines = current_lines.collect {|t| [nil, nil, t]}
|
||||
positions = []
|
||||
current_lines.size.times {|i| positions << i}
|
||||
while (current.previous)
|
||||
d = current.previous.text.split(/\r?\n/).diff(current.text.split(/\r?\n/)).diffs.flatten
|
||||
d.each_slice(3) do |s|
|
||||
sign, line = s[0], s[1]
|
||||
if sign == '+' && positions[line] && positions[line] != -1
|
||||
if @lines[positions[line]][0].nil?
|
||||
@lines[positions[line]][0] = current.version
|
||||
@lines[positions[line]][1] = current.author
|
||||
end
|
||||
end
|
||||
end
|
||||
d.each_slice(3) do |s|
|
||||
sign, line = s[0], s[1]
|
||||
if sign == '-'
|
||||
positions.insert(line, -1)
|
||||
else
|
||||
positions[line] = nil
|
||||
end
|
||||
end
|
||||
positions.compact!
|
||||
# Stop if every line is annotated
|
||||
break unless @lines.detect { |line| line[0].nil? }
|
||||
current = current.previous
|
||||
end
|
||||
@lines.each { |line|
|
||||
line[0] ||= current.version
|
||||
# if the last known version is > 1 (eg. history was cleared), we don't know the author
|
||||
line[1] ||= current.author if current.version == 1
|
||||
}
|
||||
end
|
||||
end
|
39
app/models/wiki_redirect.rb
Normal file
39
app/models/wiki_redirect.rb
Normal file
|
@ -0,0 +1,39 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
class WikiRedirect < ActiveRecord::Base
|
||||
belongs_to :wiki
|
||||
|
||||
validates_presence_of :wiki_id, :title, :redirects_to
|
||||
validates_length_of :title, :redirects_to, :maximum => 255
|
||||
attr_protected :id
|
||||
|
||||
before_save :set_redirects_to_wiki_id
|
||||
|
||||
def target_page
|
||||
wiki = Wiki.find_by_id(redirects_to_wiki_id)
|
||||
if wiki
|
||||
wiki.find_page(redirects_to, :with_redirect => false)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_redirects_to_wiki_id
|
||||
self.redirects_to_wiki_id ||= wiki_id
|
||||
end
|
||||
end
|
69
app/models/workflow_permission.rb
Normal file
69
app/models/workflow_permission.rb
Normal file
|
@ -0,0 +1,69 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
class WorkflowPermission < WorkflowRule
|
||||
validates_inclusion_of :rule, :in => %w(readonly required)
|
||||
validates_presence_of :old_status
|
||||
validate :validate_field_name
|
||||
|
||||
# Returns the workflow permissions for the given trackers and roles
|
||||
# grouped by status_id
|
||||
#
|
||||
# Example:
|
||||
# WorkflowPermission.rules_by_status_id trackers, roles
|
||||
# # => {1 => {'start_date' => 'required', 'due_date' => 'readonly'}}
|
||||
def self.rules_by_status_id(trackers, roles)
|
||||
WorkflowPermission.where(:tracker_id => trackers.map(&:id), :role_id => roles.map(&:id)).inject({}) do |h, w|
|
||||
h[w.old_status_id] ||= {}
|
||||
h[w.old_status_id][w.field_name] ||= []
|
||||
h[w.old_status_id][w.field_name] << w.rule
|
||||
h
|
||||
end
|
||||
end
|
||||
|
||||
# Replaces the workflow permissions for the given trackers and roles
|
||||
#
|
||||
# Example:
|
||||
# WorkflowPermission.replace_permissions trackers, roles, {'1' => {'start_date' => 'required', 'due_date' => 'readonly'}}
|
||||
def self.replace_permissions(trackers, roles, permissions)
|
||||
trackers = Array.wrap trackers
|
||||
roles = Array.wrap roles
|
||||
|
||||
transaction do
|
||||
permissions.each { |status_id, rule_by_field|
|
||||
rule_by_field.each { |field, rule|
|
||||
where(:tracker_id => trackers.map(&:id), :role_id => roles.map(&:id), :old_status_id => status_id, :field_name => field).destroy_all
|
||||
if rule.present?
|
||||
trackers.each do |tracker|
|
||||
roles.each do |role|
|
||||
WorkflowPermission.create(:role_id => role.id, :tracker_id => tracker.id, :old_status_id => status_id, :field_name => field, :rule => rule)
|
||||
end
|
||||
end
|
||||
end
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def validate_field_name
|
||||
unless Tracker::CORE_FIELDS_ALL.include?(field_name) || field_name.to_s.match(/^\d+$/)
|
||||
errors.add :field_name, :invalid
|
||||
end
|
||||
end
|
||||
end
|
74
app/models/workflow_rule.rb
Normal file
74
app/models/workflow_rule.rb
Normal file
|
@ -0,0 +1,74 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
class WorkflowRule < ActiveRecord::Base
|
||||
self.table_name = "#{table_name_prefix}workflows#{table_name_suffix}"
|
||||
|
||||
belongs_to :role
|
||||
belongs_to :tracker
|
||||
belongs_to :old_status, :class_name => 'IssueStatus'
|
||||
belongs_to :new_status, :class_name => 'IssueStatus'
|
||||
|
||||
validates_presence_of :role, :tracker
|
||||
attr_protected :id
|
||||
|
||||
# Copies workflows from source to targets
|
||||
def self.copy(source_tracker, source_role, target_trackers, target_roles)
|
||||
unless source_tracker.is_a?(Tracker) || source_role.is_a?(Role)
|
||||
raise ArgumentError.new("source_tracker or source_role must be specified, given: #{source_tracker.class.name} and #{source_role.class.name}")
|
||||
end
|
||||
|
||||
target_trackers = [target_trackers].flatten.compact
|
||||
target_roles = [target_roles].flatten.compact
|
||||
|
||||
target_trackers = Tracker.sorted.to_a if target_trackers.empty?
|
||||
target_roles = Role.all.select(&:consider_workflow?) if target_roles.empty?
|
||||
|
||||
target_trackers.each do |target_tracker|
|
||||
target_roles.each do |target_role|
|
||||
copy_one(source_tracker || target_tracker,
|
||||
source_role || target_role,
|
||||
target_tracker,
|
||||
target_role)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Copies a single set of workflows from source to target
|
||||
def self.copy_one(source_tracker, source_role, target_tracker, target_role)
|
||||
unless source_tracker.is_a?(Tracker) && !source_tracker.new_record? &&
|
||||
source_role.is_a?(Role) && !source_role.new_record? &&
|
||||
target_tracker.is_a?(Tracker) && !target_tracker.new_record? &&
|
||||
target_role.is_a?(Role) && !target_role.new_record?
|
||||
|
||||
raise ArgumentError.new("arguments can not be nil or unsaved objects")
|
||||
end
|
||||
|
||||
if source_tracker == target_tracker && source_role == target_role
|
||||
false
|
||||
else
|
||||
transaction do
|
||||
where(:tracker_id => target_tracker.id, :role_id => target_role.id).delete_all
|
||||
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)" +
|
||||
" SELECT #{target_tracker.id}, #{target_role.id}, old_status_id, new_status_id, author, assignee, field_name, #{connection.quote_column_name 'rule'}, type" +
|
||||
" FROM #{WorkflowRule.table_name}" +
|
||||
" WHERE tracker_id = #{source_tracker.id} AND role_id = #{source_role.id}"
|
||||
end
|
||||
true
|
||||
end
|
||||
end
|
||||
end
|
85
app/models/workflow_transition.rb
Normal file
85
app/models/workflow_transition.rb
Normal file
|
@ -0,0 +1,85 @@
|
|||
# Redmine - project management software
|
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
|
||||
class WorkflowTransition < WorkflowRule
|
||||
validates_presence_of :new_status
|
||||
|
||||
def self.replace_transitions(trackers, roles, transitions)
|
||||
trackers = Array.wrap trackers
|
||||
roles = Array.wrap roles
|
||||
|
||||
transaction do
|
||||
records = WorkflowTransition.where(:tracker_id => trackers.map(&:id), :role_id => roles.map(&:id)).to_a
|
||||
|
||||
transitions.each do |old_status_id, transitions_by_new_status|
|
||||
transitions_by_new_status.each do |new_status_id, transition_by_rule|
|
||||
transition_by_rule.each do |rule, transition|
|
||||
trackers.each do |tracker|
|
||||
roles.each do |role|
|
||||
w = records.select {|r|
|
||||
r.old_status_id == old_status_id.to_i &&
|
||||
r.new_status_id == new_status_id.to_i &&
|
||||
r.tracker_id == tracker.id &&
|
||||
r.role_id == role.id &&
|
||||
!r.destroyed?
|
||||
}
|
||||
|
||||
if rule == 'always'
|
||||
w = w.select {|r| !r.author && !r.assignee}
|
||||
else
|
||||
w = w.select {|r| r.author || r.assignee}
|
||||
end
|
||||
if w.size > 1
|
||||
w[1..-1].each(&:destroy)
|
||||
end
|
||||
w = w.first
|
||||
|
||||
if transition == "1" || transition == true
|
||||
unless w
|
||||
w = WorkflowTransition.new(:old_status_id => old_status_id, :new_status_id => new_status_id, :tracker_id => tracker.id, :role_id => role.id)
|
||||
records << w
|
||||
end
|
||||
w.author = true if rule == "author"
|
||||
w.assignee = true if rule == "assignee"
|
||||
w.save if w.changed?
|
||||
elsif w
|
||||
if rule == 'always'
|
||||
w.destroy
|
||||
elsif rule == 'author'
|
||||
if w.assignee
|
||||
w.author = false
|
||||
w.save if w.changed?
|
||||
else
|
||||
w.destroy
|
||||
end
|
||||
elsif rule == 'assignee'
|
||||
if w.author
|
||||
w.assignee = false
|
||||
w.save if w.changed?
|
||||
else
|
||||
w.destroy
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
Loading…
Add table
Add a link
Reference in a new issue