Redmine 3.4.4

This commit is contained in:
Manuel Cillero 2018-02-02 22:19:29 +01:00
commit 64924a6376
2112 changed files with 259028 additions and 0 deletions

492
app/models/attachment.rb Normal file
View 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
View 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

View 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
View 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
View 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
View 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
View 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
View 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'

View 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

View 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

View 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
View 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

View 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

View 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

View 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
View 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

View 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
View 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
View 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"

View 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

View 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"

View 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

View 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
View 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
View 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

File diff suppressed because it is too large Load diff

View 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

View 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
View 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

View 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

View 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
View 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

View 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
View 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
View 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

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

View 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

File diff suppressed because it is too large Load diff

516
app/models/repository.rb Normal file
View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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
View 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
View 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
View 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

View 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

View 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

View 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

View 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
View 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
View 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
View 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

View 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

View 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
View 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

View 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
View 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
View 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
View 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
View 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

View 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

View 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

View 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

View 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