Redmine 4.1.1

This commit is contained in:
Manuel Cillero 2020-11-22 21:20:06 +01:00
parent 33e7b881a5
commit 3d976f1b3b
1593 changed files with 36180 additions and 19489 deletions

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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
@ -28,7 +30,6 @@ class Attachment < ActiveRecord::Base
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}}
@ -86,14 +87,14 @@ class Attachment < ActiveRecord::Base
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
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
@ -200,7 +201,9 @@ class Attachment < ActiveRecord::Base
end
def thumbnailable?
image?
Redmine::Thumbnail.convert_available? && (
image? || (is_pdf? && Redmine::Thumbnail.gs_available?)
)
end
# Returns the full path the attachment thumbnail, or nil
@ -210,17 +213,17 @@ class Attachment < ActiveRecord::Base
size = options[:size].to_i
if size > 0
# Limit the number of thumbnails per image
size = (size / 50) * 50
size = (size / 50.0).ceil * 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")
target = thumbnail_path(size)
begin
Redmine::Thumbnail.generate(self.diskfile, target, size)
Redmine::Thumbnail.generate(self.diskfile, target, size, is_pdf?)
rescue => e
logger.error "An error occured while generating thumbnail for #{disk_filename} to #{target}\nException was: #{e.message}" if logger
return nil
@ -236,7 +239,15 @@ class Attachment < ActiveRecord::Base
end
def is_text?
Redmine::MimeType.is_type?('text', filename)
Redmine::MimeType.is_type?('text', filename) || Redmine::SyntaxHighlighting.filename_supported?(filename)
end
def is_markdown?
Redmine::MimeType.of(filename) == 'text/markdown'
end
def is_textile?
Redmine::MimeType.of(filename) == 'text/x-textile'
end
def is_image?
@ -244,15 +255,23 @@ class Attachment < ActiveRecord::Base
end
def is_diff?
self.filename =~ /\.(patch|diff)$/i
/\.(patch|diff)$/i.match?(filename)
end
def is_pdf?
Redmine::MimeType.of(filename) == "application/pdf"
end
def is_video?
Redmine::MimeType.is_type?('video', filename)
end
def is_audio?
Redmine::MimeType.is_type?('audio', filename)
end
def previewable?
is_text? || is_image?
is_text? || is_image? || is_video? || is_audio?
end
# Returns true if the file is readable
@ -269,7 +288,7 @@ class Attachment < ActiveRecord::Base
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
attachment = Attachment.find_by(:id => attachment_id, :digest => attachment_digest)
if attachment && attachment.container.nil?
attachment
end
@ -418,7 +437,6 @@ class Attachment < ActiveRecord::Base
def reuse_existing_file_if_possible
original_diskfile = nil
reused = with_lock do
if existing = Attachment
.where(digest: self.digest, filesize: self.filesize)
@ -426,14 +444,11 @@ class Attachment < ActiveRecord::Base
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
@ -450,12 +465,19 @@ class Attachment < ActiveRecord::Base
# 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
Dir[thumbnail_path("*")].each do |thumb|
File.delete(thumb)
end
end
def thumbnail_path(size)
File.join(self.class.thumbnails_storage_path,
"#{digest}_#{filesize}_#{size}.thumb")
end
def sanitize_filename(value)
@ -472,21 +494,25 @@ class Attachment < ActiveRecord::Base
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]+)$}
# Singleton class method is public
class << self
# Returns an ASCII or hashed filename that do not
# exists yet in the given subdirectory
def disk_filename(filename, directory=nil)
timestamp = DateTime.now.strftime("%y%m%d%H%M%S")
ascii = ''
if %r{^[a-zA-Z0-9_\.\-]*$}.match?(filename) && 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
while File.exist?(File.join(storage_path, directory.to_s, "#{timestamp}_#{ascii}"))
timestamp.succ!
end
"#{timestamp}_#{ascii}"
end
end

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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
@ -17,7 +19,7 @@
# Generic exception for when the AuthSource can not be reached
# (eg. can not connect to the LDAP)
class AuthSourceException < Exception; end
class AuthSourceException < StandardError; end
class AuthSourceTimeoutException < AuthSourceException; end
class AuthSource < ActiveRecord::Base
@ -30,9 +32,9 @@ class AuthSource < ActiveRecord::Base
validates_presence_of :name
validates_uniqueness_of :name
validates_length_of :name, :maximum => 60
attr_protected :id
safe_attributes 'name',
safe_attributes(
'name',
'host',
'port',
'account',
@ -44,8 +46,9 @@ class AuthSource < ActiveRecord::Base
'attr_mail',
'onthefly_register',
'tls',
'verify_peer',
'filter',
'timeout'
'timeout')
def authenticate(login, password)
end

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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
@ -21,7 +23,7 @@ require 'timeout'
class AuthSourceLdap < AuthSource
NETWORK_EXCEPTIONS = [
Net::LDAP::LdapError,
Net::LDAP::Error,
Errno::ECONNABORTED, Errno::ECONNREFUSED, Errno::ECONNRESET,
Errno::EHOSTDOWN, Errno::EHOSTUNREACH,
SocketError
@ -37,6 +39,14 @@ class AuthSourceLdap < AuthSource
before_validation :strip_ldap_attributes
safe_attributes 'ldap_mode'
LDAP_MODES = [
:ldap,
:ldaps_verify_none,
:ldaps_verify_peer
]
def initialize(attributes=nil, *args)
super
self.port = 389 if self.port == 0
@ -101,6 +111,31 @@ class AuthSourceLdap < AuthSource
raise AuthSourceException.new(e.message)
end
def ldap_mode
case
when tls && verify_peer
:ldaps_verify_peer
when tls && !verify_peer
:ldaps_verify_none
else
:ldap
end
end
def ldap_mode=(ldap_mode)
case ldap_mode.try(:to_sym)
when :ldaps_verify_peer
self.tls = true
self.verify_peer = true
when :ldaps_verify_none
self.tls = true
self.verify_peer = false
else
self.tls = false
self.verify_peer = false
end
end
private
def with_timeout(&block)
@ -117,7 +152,7 @@ class AuthSourceLdap < AuthSource
if filter.present?
Net::LDAP::Filter.construct(filter)
end
rescue Net::LDAP::LdapError, Net::LDAP::FilterSyntaxInvalidError
rescue Net::LDAP::Error, Net::LDAP::FilterSyntaxInvalidError
nil
end
@ -143,9 +178,18 @@ class AuthSourceLdap < AuthSource
def initialize_ldap_con(ldap_user, ldap_password)
options = { :host => self.host,
:port => self.port,
:encryption => (self.tls ? :simple_tls : nil)
:port => self.port
}
if tls
options[:encryption] = {
:method => :simple_tls,
# Always provide non-empty tls_options, to make sure, that all
# OpenSSL::SSL::SSLContext::DEFAULT_PARAMS as well as the default cert
# store are used.
:tls_options => { :verify_mode => verify_peer? ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE }
}
end
options.merge!(:auth => { :method => :simple, :username => ldap_user, :password => ldap_password }) unless ldap_user.blank? && ldap_password.blank?
Net::LDAP.new options
end

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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
@ -28,7 +30,6 @@ class Board < ActiveRecord::Base
validates_length_of :name, :maximum => 30
validates_length_of :description, :maximum => 255
validate :validate_board
attr_protected :id
scope :visible, lambda {|*args|
joins(:project).

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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
@ -21,7 +23,6 @@ class Change < ActiveRecord::Base
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)

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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
@ -46,7 +48,6 @@ class Changeset < ActiveRecord::Base
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).
@ -160,11 +161,7 @@ class Changeset < ActiveRecord::Base
if repository && repository.identifier.present?
repo = "#{repository.identifier}|"
end
tag = if scmid?
"commit:#{repo}#{scmid}"
else
"#{repo}r#{revision}"
end
tag = scmid? ? "commit:#{repo}#{scmid}" : "#{repo}r#{revision}"
if ref_project && project && ref_project != project
tag = "#{project.identifier}:#{tag}"
end
@ -283,14 +280,15 @@ class Changeset < ActiveRecord::Base
return @short_comments, @long_comments
end
public
# Singleton class method is public
class << self
# Strips and reencodes a commit log before insertion into the database
def normalize_comments(str, encoding)
Changeset.to_utf8(str.to_s.strip, encoding)
end
# 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)
def to_utf8(str, encoding)
Redmine::CodesetUtil.to_utf8(str, encoding)
end
end
end

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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
@ -20,19 +22,26 @@ class Comment < ActiveRecord::Base
belongs_to :commented, :polymorphic => true, :counter_cache => true
belongs_to :author, :class_name => 'User'
validates_presence_of :commented, :author, :comments
attr_protected :id
validates_presence_of :commented, :author, :content
after_create :send_notification
after_create_commit :send_notification
safe_attributes 'comments'
def comments=(arg)
self.content = arg
end
def comments
content
end
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
event = "#{commented.class.name.underscore}_comment_added"
if Setting.notified_events.include?(event)
Mailer.public_send("deliver_#{event}", self)
end
end
end

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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
@ -35,7 +37,6 @@ class CustomField < ActiveRecord::Base
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|
@ -43,7 +44,7 @@ class CustomField < ActiveRecord::Base
end
after_save :handle_multiplicity_change
after_save do |field|
if field.visible_changed? && field.visible
if field.saved_change_to_visible? && field.visible
field.roles.clear
end
end
@ -54,10 +55,11 @@ class CustomField < ActiveRecord::Base
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 = ?)",
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)
@ -67,7 +69,8 @@ class CustomField < ActiveRecord::Base
visible? || user.admin?
end
safe_attributes 'name',
safe_attributes(
'name',
'field_format',
'possible_values',
'regexp',
@ -90,7 +93,7 @@ class CustomField < ActiveRecord::Base
'user_role',
'version_status',
'extensions_allowed',
'full_width_layout'
'full_width_layout')
def format
@format ||= Redmine::FieldFormat.find(field_format)
@ -191,6 +194,10 @@ class CustomField < ActiveRecord::Base
full_width_layout == '1'
end
def full_text_formatting?
text_formatting == 'full'
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.
@ -225,19 +232,6 @@ class CustomField < ActiveRecord::Base
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
@ -311,12 +305,16 @@ class CustomField < ActiveRecord::Base
super(attr_name, *args)
end
def css_classes
"cf_#{id}"
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
if !new_record? && multiple_before_last_save && !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" +

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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
@ -16,7 +18,8 @@
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
class CustomFieldValue
attr_accessor :custom_field, :customized, :value, :value_was
attr_accessor :custom_field, :customized, :value_was
attr_reader :value
def initialize(attributes={})
attributes.each do |name, v|

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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
@ -18,13 +20,12 @@
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)
if new_record? && custom_field && !attributes.key?(:value) && (customized.nil? || customized.set_custom_field_default?(self))
self.value ||= custom_field.default_value
end
end

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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
@ -31,9 +33,8 @@ class Document < ActiveRecord::Base
validates_presence_of :project, :title, :category
validates_length_of :title, :maximum => 255
attr_protected :id
after_create :send_notification
after_create_commit :send_notification
scope :visible, lambda {|*args|
joins(:project).
@ -69,7 +70,7 @@ class Document < ActiveRecord::Base
def send_notification
if Setting.notified_events.include?('document_added')
Mailer.document_added(self).deliver
Mailer.deliver_document_added(self, User.current)
end
end
end

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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
@ -18,15 +20,19 @@
class EmailAddress < ActiveRecord::Base
include Redmine::SafeAttributes
belongs_to :user
attr_protected :id
EMAIL_REGEXP = /\A([^@\s]+)@((?:[-a-z0-9]+\.)+(?:(?:xn--[-a-z0-9]+)|(?:[a-z]{2,})))\z/i
after_create :deliver_security_notification_create
after_update :destroy_tokens, :deliver_security_notification_update
after_destroy :destroy_tokens, :deliver_security_notification_destroy
belongs_to :user
after_update :destroy_tokens
after_destroy :destroy_tokens
after_create_commit :deliver_security_notification_create
after_update_commit :deliver_security_notification_update
after_destroy_commit :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_format_of :address, :with => EMAIL_REGEXP, :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?}
@ -53,7 +59,7 @@ class EmailAddress < ActiveRecord::Base
# in that case, the user is just being created and
# should not receive this email.
if user.mails != [address]
deliver_security_notification(user,
deliver_security_notification(
message: :mail_body_security_notification_add,
field: :field_mail,
value: address
@ -63,26 +69,27 @@ class EmailAddress < ActiveRecord::Base
# 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]
if saved_change_to_address?
options = {
recipients: [address_before_last_save],
message: :mail_body_security_notification_change_to,
field: :field_mail,
value: address
}
elsif notify_changed?
recipients = [user, address]
elsif saved_change_to_notify?
options = {
message: notify_was ? :mail_body_security_notification_notify_disabled : :mail_body_security_notification_notify_enabled,
recipients: [address],
message: notify_before_last_save ? :mail_body_security_notification_notify_disabled : :mail_body_security_notification_notify_enabled,
value: address
}
end
deliver_security_notification(recipients, options)
deliver_security_notification(options)
end
# send a security notification to user that an email address was deleted
def deliver_security_notification_destroy
deliver_security_notification([user, address],
deliver_security_notification(
recipients: [address],
message: :mail_body_security_notification_remove,
field: :field_mail,
value: address
@ -90,20 +97,22 @@ class EmailAddress < ActiveRecord::Base
end
# generic method to send security notifications for email addresses
def deliver_security_notification(recipients, options={})
Mailer.security_notification(recipients,
def deliver_security_notification(options={})
Mailer.deliver_security_notification(
user,
User.current,
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?
if saved_change_to_address? || destroyed?
tokens = ['recovery']
Token.where(:user_id => user_id, :action => tokens).delete_all
end

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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
@ -21,7 +23,6 @@ class EnabledModule < ActiveRecord::Base
validates_presence_of :name
validates_uniqueness_of :name, :scope => :project_id
attr_protected :id
after_create :module_enabled

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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
@ -29,8 +31,6 @@ class Enumeration < ActiveRecord::Base
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
@ -148,7 +148,7 @@ class Enumeration < ActiveRecord::Base
# position as the overridden enumeration
def update_position
super
if position_changed?
if saved_change_to_position?
self.class.where.not(:parent_id => nil).update_all(
"position = coalesce((
select position

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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
@ -28,7 +30,6 @@ class Group < Principal
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]
@ -38,11 +39,12 @@ class Group < Principal
scope :named, lambda {|arg| where("LOWER(#{table_name}.lastname) = LOWER(?)", arg.to_s.strip)}
scope :givable, lambda {where(:type => 'Group')}
safe_attributes 'name',
safe_attributes(
'name',
'user_ids',
'custom_field_values',
'custom_fields',
:if => lambda {|group, user| user.admin? && !group.builtin?}
:if => lambda {|group, user| user.admin? && !group.builtin?})
def to_s
name.to_s

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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
@ -31,10 +33,23 @@ class Import < ActiveRecord::Base
'%Y-%m-%d',
'%d/%m/%Y',
'%m/%d/%Y',
'%Y/%m/%d',
'%d.%m.%Y',
'%d-%m-%Y'
]
def self.menu_item
nil
end
def self.layout
'base'
end
def self.authorized?(user)
user.admin?
end
def initialize(*args)
super
self.settings ||= {}
@ -47,13 +62,13 @@ class Import < ActiveRecord::Base
Redmine::Utils.save_upload(arg, filepath)
end
def set_default_settings
def set_default_settings(options={})
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
rescue => e
end
end
wrapper = '"'
@ -66,8 +81,17 @@ class Import < ActiveRecord::Base
'separator' => separator,
'wrapper' => wrapper,
'encoding' => encoding,
'date_format' => date_format
'date_format' => date_format,
'notifications' => '0'
)
if options.key?(:project_id) && !options[:project_id].blank?
# Do not fail if project doesn't exist
begin
project = Project.find(options[:project_id])
self.settings.merge!('mapping' => {'project_id' => project.id})
rescue; end
end
end
def to_param
@ -77,7 +101,7 @@ class Import < ActiveRecord::Base
# 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/
if filename.present? && /\A[0-9a-f]+\z/.match?(filename)
File.join(Rails.root, "tmp", "imports", filename)
else
nil
@ -141,8 +165,8 @@ class Import < ActiveRecord::Base
# 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]
settings['callbacks'][position] ||= []
settings['callbacks'][position] << [name, args]
save!
end
@ -174,6 +198,7 @@ class Import < ActiveRecord::Base
if position > resume_after
item = items.build
item.position = position
item.unique_id = row_value(row, 'unique_id') if use_unique_id?
if object = build_object(row, item)
if object.save
@ -186,7 +211,7 @@ class Import < ActiveRecord::Base
item.save!
imported += 1
do_callbacks(item.position, object)
do_callbacks(use_unique_id? ? item.unique_id : item.position, object)
end
current = position
end
@ -257,7 +282,7 @@ class Import < ActiveRecord::Base
if file_exists?
begin
File.delete filepath
rescue Exception => e
rescue => e
logger.error "Unable to delete file #{filepath}: #{e.message}" if logger
end
end
@ -267,4 +292,8 @@ class Import < ActiveRecord::Base
def yes?(value)
value == lu(user, :general_text_yes) || value == '1'
end
def use_unique_id?
mapping['unique_id'].present?
end
end

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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
@ -54,8 +56,7 @@ class Issue < ActiveRecord::Base
DONE_RATIO_OPTIONS = %w(issue_field issue_status)
attr_accessor :deleted_attachment_ids
attr_reader :current_journal
attr_writer :deleted_attachment_ids
delegate :notes, :notes=, :private_notes, :private_notes=, :to => :current_journal, :allow_nil => true
validates_presence_of :subject, :project, :tracker
@ -69,7 +70,6 @@ class Issue < ActiveRecord::Base
validates :start_date, :date => true
validates :due_date, :date => true
validate :validate_issue, :validate_required_fields, :validate_permissions
attr_protected :id
scope :visible, lambda {|*args|
joins(:project).
@ -77,7 +77,7 @@ class Issue < ActiveRecord::Base
}
scope :open, lambda {|*args|
is_closed = args.size > 0 ? !args.first : false
is_closed = !args.empty? ? !args.first : false
joins(:status).
where(:issue_statuses => {:is_closed => is_closed})
}
@ -108,34 +108,35 @@ class Issue < ActiveRecord::Base
before_validation :default_assign, on: :create
before_validation :clear_disabled_fields
before_save :close_duplicates, :update_done_ratio_from_issue_status,
:force_updated_on_change, :update_closed_on, :set_assigned_to_was
after_save {|issue| issue.send :after_project_change if !issue.id_changed? && issue.project_id_changed?}
:force_updated_on_change, :update_closed_on
after_save {|issue| issue.send :after_project_change if !issue.saved_change_to_id? && issue.saved_change_to_project_id?}
after_save :reschedule_following_issues, :update_nested_set_attributes,
:update_parent_attributes, :delete_selected_attachments, :create_journal
# Should be after_create but would be called before previous after_save callbacks
after_save :after_create_from_copy
after_destroy :update_parent_attributes
after_create :send_notification
after_create_commit :send_notification
# Returns a SQL conditions string used to find all issues visible by the specified user
def self.visible_condition(user, options={})
Project.allowed_to_condition(user, :view_issues, options) do |role, user|
sql = if user.id && user.logged?
case role.issues_visibility
when 'all'
'1=1'
when 'default'
user_ids = [user.id] + user.groups.map(&:id).compact
"(#{table_name}.is_private = #{connection.quoted_false} OR #{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
when 'own'
user_ids = [user.id] + user.groups.map(&:id).compact
"(#{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
sql =
if user.id && user.logged?
case role.issues_visibility
when 'all'
'1=1'
when 'default'
user_ids = [user.id] + user.groups.pluck(:id).compact
"(#{table_name}.is_private = #{connection.quoted_false} OR #{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
when 'own'
user_ids = [user.id] + user.groups.pluck(:id).compact
"(#{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
else
'1=0'
end
else
'1=0'
"(#{table_name}.is_private = #{connection.quoted_false})"
end
else
"(#{table_name}.is_private = #{connection.quoted_false})"
end
unless role.permissions_all_trackers?(:view_issues)
tracker_ids = role.permissions_tracker_ids(:view_issues)
if tracker_ids.any?
@ -151,20 +152,21 @@ class Issue < ActiveRecord::Base
# Returns true if usr or current user is allowed to view the issue
def visible?(usr=nil)
(usr || User.current).allowed_to?(:view_issues, self.project) do |role, user|
visible = if user.logged?
case role.issues_visibility
when 'all'
true
when 'default'
!self.is_private? || (self.author == user || user.is_or_belongs_to?(assigned_to))
when 'own'
self.author == user || user.is_or_belongs_to?(assigned_to)
visible =
if user.logged?
case role.issues_visibility
when 'all'
true
when 'default'
!self.is_private? || (self.author == user || user.is_or_belongs_to?(assigned_to))
when 'own'
self.author == user || user.is_or_belongs_to?(assigned_to)
else
false
end
else
false
!self.is_private?
end
else
!self.is_private?
end
unless role.permissions_all_trackers?(:view_issues)
visible &&= role.permissions_tracker_ids?(:view_issues, tracker_id)
end
@ -179,7 +181,9 @@ class Issue < ActiveRecord::Base
# Returns true if user or current user is allowed to edit the issue
def attributes_editable?(user=User.current)
user_tracker_permission?(user, :edit_issues)
user_tracker_permission?(user, :edit_issues) || (
user_tracker_permission?(user, :edit_own_issues) && author == user
)
end
# Overrides Redmine::Acts::Attachable::InstanceMethods#attachments_editable?
@ -206,7 +210,7 @@ class Issue < ActiveRecord::Base
end
end
def create_or_update
def create_or_update(*args)
super
ensure
@status_was = nil
@ -260,6 +264,11 @@ class Issue < ActiveRecord::Base
end
end
# Overrides Redmine::Acts::Customizable::InstanceMethods#set_custom_field_default?
def set_custom_field_default?(custom_value)
new_record? || project_id_changed?|| tracker_id_changed?
end
# Copies attributes from another issue, arg can be an id or an Issue
def copy_from(arg, options={})
issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg)
@ -445,7 +454,8 @@ class Issue < ActiveRecord::Base
write_attribute :estimated_hours, (h.is_a?(String) ? (h.to_hours || h) : h)
end
safe_attributes 'project_id',
safe_attributes(
'project_id',
'tracker_id',
'status_id',
'category_id',
@ -462,29 +472,31 @@ class Issue < ActiveRecord::Base
'custom_fields',
'lock_version',
'notes',
:if => lambda {|issue, user| issue.new_record? || issue.attributes_editable?(user) }
safe_attributes 'notes',
:if => lambda {|issue, user| issue.notes_addable?(user)}
safe_attributes 'private_notes',
:if => lambda {|issue, user| !issue.new_record? && user.allowed_to?(:set_notes_private, issue.project)}
safe_attributes 'watcher_user_ids',
:if => lambda {|issue, user| issue.new_record? && user.allowed_to?(:add_issue_watchers, issue.project)}
safe_attributes 'is_private',
:if => lambda {|issue, user| issue.new_record? || issue.attributes_editable?(user)})
safe_attributes(
'notes',
:if => lambda {|issue, user| issue.notes_addable?(user)})
safe_attributes(
'private_notes',
:if => lambda {|issue, user| !issue.new_record? && user.allowed_to?(:set_notes_private, issue.project)})
safe_attributes(
'watcher_user_ids',
:if => lambda {|issue, user| issue.new_record? && user.allowed_to?(:add_issue_watchers, issue.project)})
safe_attributes(
'is_private',
:if => lambda {|issue, user|
user.allowed_to?(:set_issues_private, issue.project) ||
(issue.author_id == user.id && user.allowed_to?(:set_own_issues_private, issue.project))
}
safe_attributes 'parent_issue_id',
:if => lambda {|issue, user| (issue.new_record? || issue.attributes_editable?(user)) &&
user.allowed_to?(:manage_subtasks, issue.project)}
safe_attributes 'deleted_attachment_ids',
:if => lambda {|issue, user| issue.attachments_deletable?(user)}
})
safe_attributes(
'parent_issue_id',
:if => lambda {|issue, user|
(issue.new_record? || issue.attributes_editable?(user)) &&
user.allowed_to?(:manage_subtasks, issue.project)
})
safe_attributes(
'deleted_attachment_ids',
:if => lambda {|issue, user| issue.attachments_deletable?(user)})
def safe_attribute_names(user=nil)
names = super
@ -511,6 +523,10 @@ class Issue < ActiveRecord::Base
# attr_accessible is too rough because we still want things like
# Issue.new(:project => foo) to work
def safe_attributes=(attrs, user=User.current)
if attrs.respond_to?(:to_unsafe_hash)
attrs = attrs.to_unsafe_hash
end
@attributes_set_by = user
return unless attrs.is_a?(Hash)
@ -518,7 +534,7 @@ class Issue < ActiveRecord::Base
# Project and Tracker must be set before since new_statuses_allowed_to depends on it.
if (p = attrs.delete('project_id')) && safe_attribute?('project_id')
if p.is_a?(String) && !p.match(/^\d*$/)
if p.is_a?(String) && !/^\d*$/.match?(p)
p_id = Project.find_by_identifier(p).try(:id)
else
p_id = p.to_i
@ -527,7 +543,7 @@ class Issue < ActiveRecord::Base
self.project_id = p_id
end
if project_id_changed? && attrs['category_id'].to_s == category_id_was.to_s
if project_id_changed? && attrs['category_id'].present? && attrs['category_id'].to_s == category_id_was.to_s
# Discard submitted category on previous project
attrs.delete('category_id')
end
@ -563,8 +579,6 @@ class Issue < ActiveRecord::Base
if (u = attrs.delete('assigned_to_id')) && safe_attribute?('assigned_to_id')
self.assigned_to_id = u
end
attrs = delete_unsafe_attributes(attrs, user)
return if attrs.empty?
@ -585,8 +599,7 @@ class Issue < ActiveRecord::Base
attrs['custom_fields'].select! {|c| editable_custom_field_ids.include?(c['id'].to_s)}
end
# mass-assignment security bypass
assign_attributes attrs, :without_protection => true
assign_attributes attrs
end
def disabled_core_fields
@ -760,7 +773,7 @@ class Issue < ActiveRecord::Base
user = new_record? ? author : current_journal.try(:user)
required_attribute_names(user).each do |attribute|
if attribute =~ /^\d+$/
if /^\d+$/.match?(attribute)
attribute = attribute.to_i
v = custom_field_values.detect {|v| v.custom_field_id == attribute }
if v && Array(v.value).detect(&:present?).nil?
@ -1006,32 +1019,26 @@ class Issue < ActiveRecord::Base
statuses
end
# Returns the previous assignee (user or group) if changed
def assigned_to_was
# assigned_to_id_was is reset before after_save callbacks
user_id = @previous_assigned_to_id || assigned_to_id_was
if user_id && user_id != assigned_to_id
@assigned_to_was ||= Principal.find_by_id(user_id)
end
end
# Returns the original tracker
def tracker_was
Tracker.find_by_id(tracker_id_was)
Tracker.find_by_id(tracker_id_in_database)
end
# Returns the previous assignee whenever we're before the save
# or in after_* callbacks
def previous_assignee
if previous_assigned_to_id = assigned_to_id_change_to_be_saved.nil? ? assigned_to_id_before_last_save : assigned_to_id_in_database
Principal.find_by_id(previous_assigned_to_id)
end
end
# Returns the users that should be notified
def notified_users
notified = []
# Author and assignee are always notified unless they have been
# locked or don't want to be notified
notified << author if author
if assigned_to
notified += (assigned_to.is_a?(Group) ? assigned_to.users : [assigned_to])
end
if assigned_to_was
notified += (assigned_to_was.is_a?(Group) ? assigned_to_was.users : [assigned_to_was])
end
notified = [author, assigned_to, previous_assignee].compact.uniq
notified = notified.map {|n| n.is_a?(Group) ? n.users : n}.flatten
notified.uniq!
notified = notified.select {|u| u.active? && u.notify_about?(self)}
notified += project.notified_users
@ -1046,21 +1053,6 @@ class Issue < ActiveRecord::Base
notified_users.collect(&:mail)
end
def each_notification(users, &block)
if users.any?
if custom_field_values.detect {|value| !value.custom_field.visible?}
users_by_custom_field_visibility = users.group_by do |user|
visible_custom_field_values(user).map(&:custom_field_id).sort
end
users_by_custom_field_visibility.values.each do |users|
yield(users)
end
else
yield(users)
end
end
end
def notify?
@notify != false
end
@ -1076,11 +1068,12 @@ class Issue < ActiveRecord::Base
# Returns the total number of hours spent on this issue and its descendants
def total_spent_hours
@total_spent_hours ||= if leaf?
spent_hours
else
self_and_descendants.joins(:time_entries).sum("#{TimeEntry.table_name}.hours").to_f || 0.0
end
@total_spent_hours ||=
if leaf?
spent_hours
else
self_and_descendants.joins(:time_entries).sum("#{TimeEntry.table_name}.hours").to_f || 0.0
end
end
def total_estimated_hours
@ -1364,7 +1357,7 @@ class Issue < ActiveRecord::Base
# Returns a string of css classes that apply to the issue
def css_classes(user=User.current)
s = "issue tracker-#{tracker_id} status-#{status_id} #{priority.try(:css_classes)}"
s = +"issue tracker-#{tracker_id} status-#{status_id} #{priority.try(:css_classes)}"
s << ' closed' if closed?
s << ' overdue' if overdue?
s << ' child' if child?
@ -1387,7 +1380,7 @@ class Issue < ActiveRecord::Base
# Unassigns issues from versions that are no longer shared
# after +project+ was moved
def self.update_versions_from_hierarchy_change(project)
moved_project_ids = project.self_and_descendants.reload.collect(&:id)
moved_project_ids = project.self_and_descendants.reload.pluck(:id)
# Update issues of the moved projects and issues assigned to a version of a moved project
Issue.update_versions(
["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)",
@ -1463,28 +1456,28 @@ class Issue < ActiveRecord::Base
end
end
def self.by_tracker(project)
count_and_group_by(:project => project, :association => :tracker)
def self.by_tracker(project, with_subprojects=false)
count_and_group_by(:project => project, :association => :tracker, :with_subprojects => with_subprojects)
end
def self.by_version(project)
count_and_group_by(:project => project, :association => :fixed_version)
def self.by_version(project, with_subprojects=false)
count_and_group_by(:project => project, :association => :fixed_version, :with_subprojects => with_subprojects)
end
def self.by_priority(project)
count_and_group_by(:project => project, :association => :priority)
def self.by_priority(project, with_subprojects=false)
count_and_group_by(:project => project, :association => :priority, :with_subprojects => with_subprojects)
end
def self.by_category(project)
count_and_group_by(:project => project, :association => :category)
def self.by_category(project, with_subprojects=false)
count_and_group_by(:project => project, :association => :category, :with_subprojects => with_subprojects)
end
def self.by_assigned_to(project)
count_and_group_by(:project => project, :association => :assigned_to)
def self.by_assigned_to(project, with_subprojects=false)
count_and_group_by(:project => project, :association => :assigned_to, :with_subprojects => with_subprojects)
end
def self.by_author(project)
count_and_group_by(:project => project, :association => :author)
def self.by_author(project, with_subprojects=false)
count_and_group_by(:project => project, :association => :author, :with_subprojects => with_subprojects)
end
def self.by_subproject(project)
@ -1521,8 +1514,15 @@ class Issue < ActiveRecord::Base
end
# Returns a scope of projects that user can assign the issue to
def allowed_target_projects(user=User.current)
current_project = new_record? ? nil : project
def allowed_target_projects(user=User.current, context=nil)
if new_record? && context.is_a?(Project) && !copy?
current_project = context.self_and_descendants
elsif new_record?
current_project = nil
else
current_project = project
end
self.class.allowed_target_projects(user, current_project)
end
@ -1530,8 +1530,10 @@ class Issue < ActiveRecord::Base
# If current_project is given, it will be included in the scope
def self.allowed_target_projects(user=User.current, current_project=nil)
condition = Project.allowed_to_condition(user, :add_issues)
if current_project
if current_project.is_a?(Project)
condition = ["(#{condition}) OR #{Project.table_name}.id = ?", current_project.id]
elsif current_project
condition = ["(#{condition}) AND #{Project.table_name}.id IN (?)", current_project.map(&:id)]
end
Project.where(condition).having_trackers
end
@ -1589,7 +1591,7 @@ class Issue < ActiveRecord::Base
# Move subtasks that were in the same project
children.each do |child|
next unless child.project_id == project_id_was
next unless child.project_id == project_id_before_last_save
# Change project and keep project
child.send :project=, project, true
unless child.save
@ -1648,7 +1650,7 @@ class Issue < ActiveRecord::Base
end
def update_nested_set_attributes
if parent_id_changed?
if saved_change_to_parent_id?
update_nested_set_attributes_on_parent_change
end
remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
@ -1656,7 +1658,7 @@ class Issue < ActiveRecord::Base
# Updates the nested set for when an existing issue is moved
def update_nested_set_attributes_on_parent_change
former_parent_id = parent_id_was
former_parent_id = parent_id_before_last_save
# delete invalid relations of all descendants
self_and_descendants.each do |issue|
issue.relations.each do |relation|
@ -1713,7 +1715,7 @@ class Issue < ActiveRecord::Base
estimated * ratio
}.sum
progress = done / (average * children.count)
p.done_ratio = progress.round
p.done_ratio = progress.floor
end
end
end
@ -1723,21 +1725,24 @@ class Issue < ActiveRecord::Base
end
end
# Update issues so their versions are not pointing to a
# fixed_version that is not shared with the issue's project
def self.update_versions(conditions=nil)
# Only need to update issues with a fixed_version from
# a different project and that is not systemwide shared
Issue.joins(:project, :fixed_version).
where("#{Issue.table_name}.fixed_version_id IS NOT NULL" +
" AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
" AND #{Version.table_name}.sharing <> 'system'").
where(conditions).each do |issue|
next if issue.project.nil? || issue.fixed_version.nil?
unless issue.project.shared_versions.include?(issue.fixed_version)
issue.init_journal(User.current)
issue.fixed_version = nil
issue.save
# Singleton class method is public
class << self
# Update issues so their versions are not pointing to a
# fixed_version that is not shared with the issue's project
def update_versions(conditions=nil)
# Only need to update issues with a fixed_version from
# a different project and that is not systemwide shared
Issue.joins(:project, :fixed_version).
where("#{Issue.table_name}.fixed_version_id IS NOT NULL" +
" AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
" AND #{Version.table_name}.sharing <> 'system'").
where(conditions).each do |issue|
next if issue.project.nil? || issue.fixed_version.nil?
unless issue.project.shared_versions.include?(issue.fixed_version)
issue.init_journal(User.current)
issue.fixed_version = nil
issue.save
end
end
end
end
@ -1793,7 +1798,7 @@ class Issue < ActiveRecord::Base
# Updates start/due dates of following issues
def reschedule_following_issues
if start_date_changed? || due_date_changed?
if saved_change_to_start_date? || saved_change_to_due_date?
relations_from.each do |relation|
relation.set_issue_to_dates(@current_journal)
end
@ -1802,7 +1807,7 @@ class Issue < ActiveRecord::Base
# Closes duplicates if the issue is being closed
def close_duplicates
if closing?
if Setting.close_duplicate_issues? && closing?
duplicates.each do |duplicate|
# Reload is needed in case the duplicate was updated by a previous duplicate
duplicate.reload
@ -1852,18 +1857,6 @@ class Issue < ActiveRecord::Base
end
end
# Stores the previous assignee so we can still have access
# to it during after_save callbacks (assigned_to_id_was is reset)
def set_assigned_to_was
@previous_assigned_to_id = assigned_to_id_was
end
# Clears the previous assignee at the end of after_save callbacks
def clear_assigned_to_was
@assigned_to_was = nil
@previous_assigned_to_id = nil
end
def clear_disabled_fields
if tracker
tracker.disabled_core_fields.each do |attribute|

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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
@ -24,7 +26,6 @@ class IssueCategory < ActiveRecord::Base
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'

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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
@ -16,12 +18,11 @@
# 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
has_and_belongs_to_many :projects, :join_table => "#{table_name_prefix}custom_fields_projects#{table_name_suffix}", :foreign_key => "custom_field_id", :autosave => true
has_and_belongs_to_many :trackers, :join_table => "#{table_name_prefix}custom_fields_trackers#{table_name_suffix}", :foreign_key => "custom_field_id", :autosave => true
safe_attributes 'project_ids',
'tracker_ids'
'tracker_ids'
def type_name
:label_issue_plural

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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
@ -16,6 +18,13 @@
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
class IssueImport < Import
def self.menu_item
:issues
end
def self.authorized?(user)
user.allowed_to?(:import_issues, nil, :global => true)
end
# Returns the objects that were imported
def saved_objects
@ -77,7 +86,7 @@ class IssueImport < Import
def build_object(row, item)
issue = Issue.new
issue.author = user
issue.notify = false
issue.notify = !!ActiveRecord::Type::Boolean.new.cast(settings['notifications'])
tracker_id = nil
if tracker
@ -141,18 +150,30 @@ class IssueImport < Import
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
if parent_issue_id.start_with? '#'
# refers to existing issue
attributes['parent_issue_id'] = parent_issue_id[1..-1]
elsif use_unique_id?
# refers to other row with unique id
issue_id = items.where(:unique_id => parent_issue_id).first.try(:obj_id)
if issue_id
attributes['parent_issue_id'] = 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
add_callback(parent_issue_id, 'set_as_parent', item.position)
end
elsif parent_issue_id =~ /\A\d+\z/
# refers to other row by position
parent_issue_id = parent_issue_id.to_i
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
else
# Something is odd. Assign parent_issue_id to trigger validation error
attributes['parent_issue_id'] = parent_issue_id
end
end
@ -170,12 +191,13 @@ class IssueImport < Import
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
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

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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
@ -19,7 +21,7 @@ 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?}
after_save {|priority| priority.class.compute_position_names if (priority.saved_change_to_position? && priority.position) || priority.saved_change_to_active? || priority.saved_change_to_is_default?}
OptionName = :enumeration_issue_priorities
@ -52,7 +54,8 @@ class IssuePriority < Enumeration
if priorities.any?
default = priorities.detect(&:is_default?) || priorities[(priorities.size - 1) / 2]
priorities.each_with_index do |priority, index|
name = case
name =
case
when priority.position == default.position
"default"
when priority.position < default.position

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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
@ -20,4 +22,3 @@ class IssuePriorityCustomField < CustomField
:enumeration_issue_priorities
end
end

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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
@ -25,24 +27,28 @@ class IssueQuery < Query
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),
QueryAssociationColumn.new(:parent, :subject, :caption => :field_parent_issue_subject),
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'),
TimestampQueryColumn.new(:updated_on, :sortable => "#{Issue.table_name}.updated_on", :default_order => 'desc', :groupable => true),
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(:start_date, :sortable => "#{Issue.table_name}.start_date", :groupable => true),
QueryColumn.new(:due_date, :sortable => "#{Issue.table_name}.due_date", :groupable => true),
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 #{Issue.visible_condition(User.current).gsub(/\bissues\b/, 'subtasks')} AND subtasks.root_id = #{Issue.table_name}.root_id AND subtasks.lft >= #{Issue.table_name}.lft AND subtasks.rgt <= #{Issue.table_name}.rgt), 0)" },
QueryColumn.new(
:total_estimated_hours,
:sortable => -> {
"COALESCE((SELECT SUM(estimated_hours) FROM #{Issue.table_name} subtasks" +
" WHERE #{Issue.visible_condition(User.current).gsub(/\bissues\b/, 'subtasks')} AND 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'),
TimestampQueryColumn.new(:created_on, :sortable => "#{Issue.table_name}.created_on", :default_order => 'desc', :groupable => true),
TimestampQueryColumn.new(:closed_on, :sortable => "#{Issue.table_name}.closed_on", :default_order => 'desc', :groupable => true),
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),
@ -73,59 +79,76 @@ class IssueQuery < Query
options[:draw_progress_line] = (arg == '1' ? '1' : nil)
end
def build_from_params(params)
def draw_selected_columns
r = options[:draw_selected_columns]
r == '1'
end
def draw_selected_columns=(arg)
options[:draw_selected_columns] = (arg == '1' ? '1' : nil)
end
def build_from_params(params, defaults={})
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.draw_relations = params[:draw_relations] || (params[:query] && params[:query][:draw_relations]) || options[:draw_relations]
self.draw_progress_line = params[:draw_progress_line] || (params[:query] && params[:query][:draw_progress_line]) || options[:draw_progress_line]
self.draw_selected_columns = params[:draw_selected_columns] || (params[:query] && params[:query][:draw_selected_columns]) || options[:draw_progress_line]
self
end
def initialize_available_filters
add_available_filter "status_id",
add_available_filter(
"status_id",
:type => :list_status, :values => lambda { issue_statuses_values }
add_available_filter("project_id",
)
add_available_filter(
"project_id",
:type => :list, :values => lambda { project_values }
) if project.nil?
add_available_filter "tracker_id",
add_available_filter(
"tracker_id",
:type => :list, :values => trackers.collect{|s| [s.name, s.id.to_s] }
add_available_filter "priority_id",
)
add_available_filter(
"priority_id",
:type => :list, :values => IssuePriority.all.collect{|s| [s.name, s.id.to_s] }
add_available_filter("author_id",
)
add_available_filter(
"author_id",
:type => :list, :values => lambda { author_values }
)
add_available_filter("assigned_to_id",
add_available_filter(
"assigned_to_id",
:type => :list_optional, :values => lambda { assigned_to_values }
)
add_available_filter("member_of_group",
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",
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",
add_available_filter(
"fixed_version_id",
:type => :list_optional, :values => lambda { fixed_version_values }
add_available_filter "fixed_version.due_date",
)
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",
)
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",
)
add_available_filter(
"category_id",
:type => :list_optional,
:values => lambda { project.issue_categories.collect{|s| [s.name, s.id.to_s] } } if project
: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
@ -134,37 +157,54 @@ class IssueQuery < Query
add_available_filter "start_date", :type => :date
add_available_filter "due_date", :type => :date
add_available_filter "estimated_hours", :type => :float
if User.current.allowed_to?(:view_time_entries, project, :global => true)
add_available_filter "spent_time", :type => :float, :label => :label_spent_time
end
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",
add_available_filter(
"is_private",
:type => :list,
:values => [[l(:general_text_yes), "1"], [l(:general_text_no), "0"]]
)
end
add_available_filter "attachment",
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"]]
add_available_filter(
"watcher_id",
:type => :list, :values => lambda { watcher_values }
)
end
add_available_filter("updated_by",
add_available_filter(
"updated_by",
:type => :list, :values => lambda { author_values }
)
add_available_filter("last_updated_by",
add_available_filter(
"last_updated_by",
:type => :list, :values => lambda { author_values }
)
if project && !project.leaf?
add_available_filter "subproject_id",
add_available_filter(
"subproject_id",
:type => :list_subprojects,
:values => lambda { subproject_values }
)
end
add_available_filter(
"project.status",
:type => :list,
:name => l(:label_attribute_of_project, :name => l(:field_status)),
:values => lambda { project_statuses_values }
) if project.nil? || !project.leaf?
add_custom_fields_filters(issue_custom_fields)
add_associations_custom_fields_filters :project, :author, :assigned_to, :fixed_version
@ -195,11 +235,13 @@ class IssueQuery < Query
" 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
@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}" +
@ -208,10 +250,12 @@ class IssueQuery < Query
" 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
@available_columns.insert(
index + 1,
QueryColumn.new(:total_spent_hours,
:sortable => "COALESCE((#{subselect}), 0)",
:default_order => 'desc',
:caption => :label_total_spent_time)
)
end
@ -274,6 +318,10 @@ class IssueQuery < Query
# Valid options are :order, :offset, :limit, :include, :conditions
def issues(options={})
order_option = [group_by_sort_order, (options[:order] || sort_clause)].flatten.reject(&:blank?)
# The default order of IssueQuery is issues.id DESC(by IssueQuery#default_sort_criteria)
unless ["#{Issue.table_name}.id ASC", "#{Issue.table_name}.id DESC"].any?{|i| order_option.include?(i)}
order_option << "#{Issue.table_name}.id DESC"
end
scope = Issue.visible.
joins(:status, :project).
@ -316,6 +364,10 @@ class IssueQuery < Query
# Returns the issues ids
def issue_ids(options={})
order_option = [group_by_sort_order, (options[:order] || sort_clause)].flatten.reject(&:blank?)
# The default order of IssueQuery is issues.id DESC(by IssueQuery#default_sort_criteria)
unless ["#{Issue.table_name}.id ASC", "#{Issue.table_name}.id DESC"].any?{|i| order_option.include?(i)}
order_option << "#{Issue.table_name}.id DESC"
end
Issue.visible.
joins(:status, :project).
@ -381,27 +433,43 @@ class IssueQuery < Query
"#{neg} EXISTS (#{subquery})"
end
def sql_for_spent_time_field(field, operator, value)
first, second = value.first.to_f, value.second.to_f
sql_op =
case operator
when "=", ">=", "<=" then "#{operator} #{first}"
when "><" then "BETWEEN #{first} AND #{second}"
when "*" then "> 0"
when "!*" then "= 0"
else
return nil
end
"COALESCE((" +
"SELECT ROUND(CAST(SUM(hours) AS DECIMAL(30,3)), 2) " +
"FROM #{TimeEntry.table_name} " +
"WHERE issue_id = #{Issue.table_name}.id), 0) #{sql_op}"
end
def sql_for_watcher_id_field(field, operator, value)
db_table = Watcher.table_name
me, others = value.partition { |id| ['0', User.current.id.to_s].include?(id) }
sql = if others.any?
"SELECT #{Issue.table_name}.id FROM #{Issue.table_name} " +
"INNER JOIN #{db_table} ON #{Issue.table_name}.id = #{db_table}.watchable_id AND #{db_table}.watchable_type = 'Issue' " +
"LEFT OUTER JOIN #{Project.table_name} ON #{Project.table_name}.id = #{Issue.table_name}.project_id " +
"WHERE (" +
sql_for_field(field, '=', me, db_table, 'user_id') +
') OR (' +
Project.allowed_to_condition(User.current, :view_issue_watchers) +
' AND ' +
sql_for_field(field, '=', others, db_table, 'user_id') +
')'
else
"SELECT #{db_table}.watchable_id FROM #{db_table} " +
"WHERE #{db_table}.watchable_type='Issue' AND " +
sql_for_field(field, '=', me, db_table, 'user_id')
end
sql =
if others.any?
"SELECT #{Issue.table_name}.id FROM #{Issue.table_name} " +
"INNER JOIN #{db_table} ON #{Issue.table_name}.id = #{db_table}.watchable_id AND #{db_table}.watchable_type = 'Issue' " +
"LEFT OUTER JOIN #{Project.table_name} ON #{Project.table_name}.id = #{Issue.table_name}.project_id " +
"WHERE (" +
sql_for_field(field, '=', me, db_table, 'user_id') +
') OR (' +
Project.allowed_to_condition(User.current, :view_issue_watchers) +
' AND ' +
sql_for_field(field, '=', others, db_table, 'user_id') +
')'
else
"SELECT #{db_table}.watchable_id FROM #{db_table} " +
"WHERE #{db_table}.watchable_type='Issue' AND " +
sql_for_field(field, '=', me, db_table, 'user_id')
end
"#{Issue.table_name}.id #{ operator == '=' ? 'IN' : 'NOT IN' } (#{sql})"
end
@ -475,13 +543,22 @@ class IssueQuery < Query
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})"
when "^", "$"
c = sql_contains("a.filename", value.first, (operator == "^" ? :starts_with : :ends_with) => true)
"EXISTS (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}"
# accepts a comma separated list of ids
ids = value.first.to_s.scan(/\d+/).map(&:to_i).uniq
if ids.present?
"#{Issue.table_name}.parent_id IN (#{ids.join(",")})"
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
@ -499,9 +576,11 @@ class IssueQuery < Query
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}"
# accepts a comma separated list of child ids
child_ids = value.first.to_s.scan(/\d+/).map(&:to_i).uniq
ids = Issue.where(:id => child_ids).pluck(:parent_id).compact.uniq
if ids.present?
"#{Issue.table_name}.id IN (#{ids.join(",")})"
else
"1=0"
end
@ -554,8 +633,8 @@ class IssueQuery < Query
relation_type = relation_options[:reverse] || relation_type
join_column, target_join_column = target_join_column, join_column
end
sql = case operator
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)}')"
@ -570,7 +649,6 @@ class IssueQuery < Query
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 ")
@ -578,12 +656,16 @@ class IssueQuery < Query
"(#{sql})"
end
def sql_for_project_status_field(field, operator, value, options={})
sql_for_field(field, operator, value, Project.table_name, "status")
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|
IssueRelation::TYPES.each_key do |relation_type|
alias_method "sql_for_#{relation_type}_field".to_sym, :sql_for_relations
end

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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
@ -31,6 +33,7 @@ class IssueRelation < ActiveRecord::Base
end
include Redmine::SafeAttributes
include Redmine::Utils::DateCalculation
belongs_to :issue_from, :class_name => 'Issue'
belongs_to :issue_to, :class_name => 'Issue'
@ -72,16 +75,19 @@ class IssueRelation < ActiveRecord::Base
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'
'delay',
'issue_to_id'
def safe_attributes=(attrs, user=User.current)
if attrs.respond_to?(:to_unsafe_hash)
attrs = attrs.to_unsafe_hash
end
return unless attrs.is_a?(Hash)
attrs = attrs.deep_dup
@ -146,9 +152,11 @@ class IssueRelation < ActiveRecord::Base
end
def label_for(issue)
TYPES[relation_type] ?
TYPES[relation_type][(self.issue_from_id == issue.id) ? :name : :sym_name] :
:unknow
if TYPES[relation_type]
TYPES[relation_type][(self.issue_from_id == issue.id) ? :name : :sym_name]
else
:unknow
end
end
def to_s(issue=nil)
@ -186,7 +194,7 @@ class IssueRelation < ActiveRecord::Base
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
add_working_days((issue_from.due_date || issue_from.start_date), (1 + delay))
end
end

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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
@ -30,15 +32,15 @@ class IssueStatus < ActiveRecord::Base
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',
safe_attributes(
'name',
'is_closed',
'position',
'default_done_ratio'
'default_done_ratio')
# Update all the +Issues+ setting their done_ratio to the value of their +IssueStatus+
def self.update_issue_done_ratios
@ -89,7 +91,7 @@ class IssueStatus < ActiveRecord::Base
# 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
if saved_change_to_is_closed? && 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).

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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
@ -26,7 +28,6 @@ class Journal < ActiveRecord::Base
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,
@ -43,7 +44,7 @@ class Journal < ActiveRecord::Base
" (#{JournalDetail.table_name}.prop_key = 'status_id' OR #{Journal.table_name}.notes <> '')").distinct
before_create :split_private_notes
after_commit :send_notification, :on => :create
after_create_commit :send_notification
scope :visible, lambda {|*args|
user = args.shift || User.current
@ -54,10 +55,12 @@ class Journal < ActiveRecord::Base
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)}
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={})
@ -95,19 +98,6 @@ class Journal < ActiveRecord::Base
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)
@ -138,7 +128,7 @@ class Journal < ActiveRecord::Base
# Returns a string of css classes
def css_classes
s = 'journal'
s = +'journal'
s << ' has-notes' unless notes.blank?
s << ' has-details' unless details.blank?
s << ' private-notes' if private_notes?
@ -320,7 +310,8 @@ class Journal < ActiveRecord::Base
(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?)
(Setting.notified_events.include?('issue_priority_updated') && new_value_for('priority_id').present?) ||
(Setting.notified_events.include?('issue_fixed_version_updated') && detail_for_attribute('fixed_version_id').present?)
)
Mailer.deliver_issue_edit(self)
end

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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
@ -17,7 +19,6 @@
class JournalDetail < ActiveRecord::Base
belongs_to :journal
attr_protected :id
def custom_field
if property == 'cf'

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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
@ -41,10 +43,8 @@ class MailHandler < ActionMailer::Base
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)
mail = Mail.new(raw_mail.b)
set_payload_for_mail(payload, mail)
new.receive(mail, options)
end
@ -53,7 +53,7 @@ class MailHandler < ActionMailer::Base
# Receives an email and rescues any exception
def self.safe_receive(*args)
receive(*args)
rescue Exception => e
rescue => e
Rails.logger.error "MailHandler: an unexpected error occurred when receiving email: #{e.message}"
return false
end
@ -91,10 +91,9 @@ class MailHandler < ActionMailer::Base
@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
emission_address = Setting.mail_from.to_s.gsub(/(?:.*<|>.*|\(.*\))/, '').strip
if sender_email.casecmp(emission_address) == 0
logger&.info "MailHandler: ignoring email from Redmine emission address [#{sender_email}]"
return false
end
# Ignore auto generated emails
@ -102,19 +101,15 @@ class MailHandler < ActionMailer::Base
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
if (ignored_value.is_a?(Regexp) && ignored_value.match?(value)) || value == ignored_value
logger&.info "MailHandler: ignoring email with #{key}:#{value} header"
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
logger&.info "MailHandler: ignoring email from non-active user [#{@user.login}]"
return false
end
if @user.nil?
@ -125,24 +120,18 @@ class MailHandler < ActionMailer::Base
when 'create'
@user = create_user_from_email
if @user
if logger
logger.info "MailHandler: [#{@user.login}] account created"
end
logger&.info "MailHandler: [#{@user.login}] account created"
add_user_to_group(handler_options[:default_group])
unless handler_options[:no_account_notice]
::Mailer.account_information(@user, @user.password).deliver
::Mailer.deliver_account_information(@user, @user.password)
end
else
if logger
logger.error "MailHandler: could not create account for [#{sender_email}]"
end
logger&.error "MailHandler: could not create account for [#{sender_email}]"
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
logger&.info "MailHandler: ignoring email from unknown user [#{sender_email}]"
return false
end
end
@ -176,13 +165,13 @@ class MailHandler < ActionMailer::Base
end
rescue ActiveRecord::RecordInvalid => e
# TODO: send a email to the user
logger.error "MailHandler: #{e.message}" if logger
logger&.error "MailHandler: #{e.message}"
false
rescue MissingInformation => e
logger.error "MailHandler: missing information from #{user}: #{e.message}" if logger
logger&.error "MailHandler: missing information from #{user}: #{e.message}"
false
rescue UnauthorizedAction => e
logger.error "MailHandler: unauthorized attempt from #{user}" if logger
logger&.error "MailHandler: unauthorized attempt from #{user}: #{e.message}"
false
end
@ -195,7 +184,7 @@ class MailHandler < ActionMailer::Base
project = target_project
# check permission
unless handler_options[:no_permission_check]
raise UnauthorizedAction unless user.allowed_to?(:add_issues, project)
raise UnauthorizedAction, "not allowed to add issues to project [#{project.name}]" unless user.allowed_to?(:add_issues, project)
end
issue = Issue.new(:author => user, :project => project)
@ -210,34 +199,40 @@ class MailHandler < ActionMailer::Base
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)'
issue.subject = "(#{ll(Setting.default_language, :text_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')
if handler_options[:issue][:is_private] == '1'
issue.is_private = true
end
# 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
logger&.info "MailHandler: issue ##{issue.id} created by #{user}"
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
issue = Issue.find_by(:id => issue_id)
if issue.nil?
logger&.info "MailHandler: ignoring reply from [#{email.from.first}] to a nonexistent issue"
return nil
end
# 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
raise UnauthorizedAction, "not allowed to add notes on issues to project [#{project.name}]"
end
end
# ignore CLI-supplied defaults for new issues
handler_options[:issue].clear
handler_options[:issue] = {}
journal = issue.init_journal(user)
if from_journal && from_journal.private_notes?
@ -252,43 +247,48 @@ class MailHandler < ActionMailer::Base
add_watchers(issue)
issue.save!
add_attachments(issue)
if logger
logger.info "MailHandler: issue ##{issue.id} updated by #{user}"
end
logger&.info "MailHandler: issue ##{issue.id} updated by #{user}"
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'
journal = Journal.find_by(:id => journal_id)
if journal.nil?
logger&.info "MailHandler: ignoring reply from [#{email.from.first}] to a nonexistent journal"
return nil
end
if journal.journalized_type == 'Issue'
receive_issue_reply(journal.journalized_id, journal)
else
logger&.info "MailHandler: ignoring reply from [#{email.from.first}] to a journal whose journalized_type is not Issue"
return nil
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
message = Message.find_by(:id => message_id)&.root
if message.nil?
logger&.info "MailHandler: ignoring reply from [#{email.from.first}] to a nonexistent message"
return nil
end
unless handler_options[:no_permission_check]
raise UnauthorizedAction unless user.allowed_to?(:add_messages, message.project)
end
unless handler_options[:no_permission_check]
raise UnauthorizedAction, "not allowed to add messages to project [#{project.name}]" 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 [#{email.from.first}] to a locked topic"
end
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
logger&.info "MailHandler: ignoring reply from [#{email.from.first}] to a locked topic"
end
end
@ -310,8 +310,12 @@ class MailHandler < ActionMailer::Base
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
if Setting.mail_handler_enable_regex_excluded_filenames?
regexp = %r{\A#{pattern}\z}i
else
regexp = %r{\A#{Regexp.escape(pattern).gsub("\\*", ".*")}\z}i
end
if regexp.match?(attachment.filename.to_s)
logger.info "MailHandler: ignoring attachment #{attachment.filename} matching #{pattern}"
return false
end
@ -340,10 +344,12 @@ class MailHandler < ActionMailer::Base
@keywords[attr]
else
@keywords[attr] = begin
override = options.key?(:override) ?
options[:override] :
(handler_options[:allow_override] & [attr.to_s.downcase.gsub(/\s+/, '_'), 'all']).present?
override =
if options.key?(:override)
options[:override]
else
(handler_options[:allow_override] & [attr.to_s.downcase.gsub(/\s+/, '_'), 'all']).present?
end
if override && (v = extract_keyword!(cleaned_up_text_body, attr, options[:format]))
v
elsif !handler_options[:issue][attr].blank?
@ -409,7 +415,7 @@ class MailHandler < ActionMailer::Base
target = Project.find_by_identifier(default_project)
end
end
raise MissingInformation.new('Unable to determine target project') if target.nil?
raise MissingInformation, 'Unable to determine target project' if target.nil?
target
end
@ -425,12 +431,39 @@ class MailHandler < ActionMailer::Base
'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')
'done_ratio' => get_keyword(:done_ratio, :format => '(\d|10)?0'),
'is_private' => get_keyword_bool(:is_private),
'parent_issue_id' => get_keyword(:parent_issue)
}.delete_if {|k, v| v.blank? }
attrs
end
def get_keyword_bool(attr)
true_values = ["1"]
false_values = ["0"]
locales = [Setting.default_language]
if user
locales << user.language
end
locales.select(&:present?).each do |locale|
true_values << l("general_text_yes", :default => '', :locale => locale)
true_values << l("general_text_Yes", :default => '', :locale => locale)
false_values << l("general_text_no", :default => '', :locale => locale)
false_values << l("general_text_No", :default => '', :locale => locale)
end
values = (true_values + false_values).select(&:present?)
format = Regexp.union values
if value = get_keyword(attr, :format => format)
if true_values.include?(value)
return true
elsif false_values.include?(value)
return false
end
end
nil
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|
@ -441,16 +474,23 @@ class MailHandler < ActionMailer::Base
end
end
# Returns the text/plain part of the email
# If not found (eg. HTML-only email), returns the body with tags removed
# Returns the text content of the email.
# If the value of Setting.mail_handler_preferred_body_part is 'html',
# it returns text converted from the text/html part of the email.
# Otherwise, it returns text/plain part.
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
parse_order =
if Setting.mail_handler_preferred_body_part == 'html'
['text/html', 'text/plain']
else
['text/plain', 'text/html']
end
parse_order.each do |mime_type|
@plain_text_body ||= email_parts_to_text(email.all_parts.select {|p| p.mime_type == mime_type}).presence
return @plain_text_body unless @plain_text_body.nil?
end
# If there is still no body found, and there are no mime-parts defined,
# we use the whole raw mail body
@ -465,11 +505,13 @@ class MailHandler < ActionMailer::Base
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_charset =
if Mail::RubyVer.respond_to?(:pick_encoding)
Mail::RubyVer.pick_encoding(p.charset).to_s
else
p.charset
end
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)
@ -485,58 +527,58 @@ class MailHandler < ActionMailer::Base
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
private_class_method :assign_string_attribute_with_limit
# 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?
# Singleton class method is public
class << self
# Converts a HTML email body to text
def html_body_to_text(html)
Redmine::WikiFormatting.html_parser.to_text(html)
end
user
# Converts a plain/text email body to text
def 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
# Returns a User from an email address and a full name
def 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
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?
if from_addr = email.header['from'].try(:addrs).to_a.first
addr = from_addr.address
name = from_addr.display_name || from_addr.comments.to_a.first
user = self.class.new_user_from_attributes(addr, name)
if handler_options[:no_notification]
user.mail_notification = 'none'
@ -544,11 +586,11 @@ class MailHandler < ActionMailer::Base
if user.save
user
else
logger.error "MailHandler: failed to create User: #{user.errors.full_messages}" if logger
logger&.error "MailHandler: failed to create User: #{user.errors.full_messages}"
nil
end
else
logger.error "MailHandler: failed to create User: no FROM address found" if logger
logger&.error "MailHandler: failed to create User: no FROM address found"
nil
end
end
@ -574,7 +616,7 @@ class MailHandler < ActionMailer::Base
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
logger&.error "MailHandler: invalid regexp delimiter found in mail_handler_body_delimiters setting (#{e.message})"
end
end

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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
@ -26,6 +28,32 @@ class Mailer < ActionMailer::Base
include Redmine::I18n
include Roadie::Rails::Automatic
# Overrides ActionMailer::Base#process in order to set the recipient as the current user
# and his language as the default locale.
# The first argument of all actions of this Mailer must be a User (the recipient),
# otherwise an ArgumentError is raised.
def process(action, *args)
user = args.first
raise ArgumentError, "First argument has to be a user, was #{user.inspect}" unless user.is_a?(User)
initial_user = User.current
initial_language = ::I18n.locale
begin
User.current = user
lang = find_language(user.language) if user.logged?
lang ||= Setting.default_language
set_language_if_valid(lang)
super(action, *args)
ensure
User.current = initial_user
::I18n.locale = initial_language
end
end
# Default URL options for generating URLs in emails based on host_name and protocol
# defined in application settings.
def self.default_url_options
options = {:protocol => Setting.protocol}
if Setting.host_name.to_s =~ /\A(https?\:\/\/)?(.+?)(\:(\d+))?(\/.+)?\z/i
@ -39,9 +67,10 @@ class Mailer < ActionMailer::Base
options
end
# Builds a mail for notifying to_users and cc_users about a new issue
def issue_add(issue, to_users, cc_users)
# Builds a mail for notifying user about a new issue
def issue_add(user, issue)
redmine_headers 'Project' => issue.project.identifier,
'Issue-Tracker' => issue.tracker.name,
'Issue-Id' => issue.id,
'Issue-Author' => issue.author.login
redmine_headers 'Issue-Assignee' => issue.assigned_to.login if issue.assigned_to
@ -49,26 +78,31 @@ class Mailer < ActionMailer::Base
references issue
@author = issue.author
@issue = issue
@users = to_users + cc_users
@user = user
@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}"
subject = "[#{issue.project.name} - #{issue.tracker.name} ##{issue.id}]"
subject += " (#{issue.status.name})" if Setting.show_status_changes_in_mail_subject?
subject += " #{issue.subject}"
mail :to => user,
:subject => subject
end
# Notifies users about a new issue
# Notifies users about a new issue.
#
# Example:
# Mailer.deliver_issue_add(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
users = issue.notified_users | issue.notified_watchers
users.each do |user|
issue_add(user, issue).deliver_later
end
end
# Builds a mail for notifying to_users and cc_users about an issue update
def issue_edit(journal, to_users, cc_users)
# Builds a mail for notifying user about an issue update
def issue_edit(user, journal)
issue = journal.journalized
redmine_headers 'Project' => issue.project.identifier,
'Issue-Tracker' => issue.tracker.name,
'Issue-Id' => issue.id,
'Issue-Author' => issue.author.login
redmine_headers 'Issue-Assignee' => issue.assigned_to.login if issue.assigned_to
@ -76,61 +110,56 @@ class Mailer < ActionMailer::Base
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
s += "(#{issue.status.name}) " if journal.new_value_for('status_id') && Setting.show_status_changes_in_mail_subject?
s += issue.subject
@issue = issue
@users = to_users + cc_users
@user = user
@journal = journal
@journal_details = journal.visible_details(@users.first)
@journal_details = journal.visible_details
@issue_url = url_for(:controller => 'issues', :action => 'show', :id => issue, :anchor => "change-#{journal.id}")
mail :to => to_users,
:cc => cc_users,
mail :to => user,
:subject => s
end
# Notifies users about an issue update
# Notifies users about an issue update.
#
# Example:
# Mailer.deliver_issue_edit(journal)
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
users = journal.notified_users | journal.notified_watchers
users.select! do |user|
journal.notes? || journal.visible_details(user).any?
end
users.each do |user|
issue_edit(user, journal).deliver_later
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)
# Builds a mail to user about a new document.
def document_added(user, document, author)
redmine_headers 'Project' => document.project.identifier
@author = User.current
@author = author
@document = document
@user = user
@document_url = url_for(:controller => 'documents', :action => 'show', :id => document)
mail :to => document.notified_users,
mail :to => user,
: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.
# Notifies users that document was created by author
#
# Example:
# attachments_added(attachments) => Mail::Message object
# Mailer.attachments_added(attachments).deliver => sends an email to the project's recipients
def attachments_added(attachments)
# Mailer.deliver_document_added(document, author)
def self.deliver_document_added(document, author)
users = document.notified_users
users.each do |user|
document_added(user, document, author).deliver_later
end
end
# Builds a mail to user about new attachements.
def attachments_added(user, attachments)
container = attachments.first.container
added_to = ''
added_to_url = ''
@ -139,47 +168,66 @@ class Mailer < ActionMailer::Base
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
@user = user
@added_to = added_to
@added_to_url = added_to_url
mail :to => recipients,
mail :to => user,
: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.
# Notifies users about new attachments
#
# Example:
# news_added(news) => Mail::Message object
# Mailer.news_added(news).deliver => sends an email to the news' project recipients
def news_added(news)
# Mailer.deliver_attachments_added(attachments)
def self.deliver_attachments_added(attachments)
container = attachments.first.container
case container.class.name
when 'Project', 'Version'
users = container.project.notified_users.select {|user| user.allowed_to?(:view_files, container.project)}
when 'Document'
users = container.notified_users
end
users.each do |user|
attachments_added(user, attachments).deliver_later
end
end
# Builds a mail to user about a new news.
def news_added(user, news)
redmine_headers 'Project' => news.project.identifier
@author = news.author
message_id news
references news
@news = news
@user = user
@news_url = url_for(:controller => 'news', :action => 'show', :id => news)
mail :to => news.notified_users,
:cc => news.notified_watchers_for_added_news,
mail :to => user,
: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.
# Notifies users about new news
#
# 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)
# Mailer.deliver_news_added(news)
def self.deliver_news_added(news)
users = news.notified_users | news.notified_watchers_for_added_news
users.each do |user|
news_added(user, news).deliver_later
end
end
# Builds a mail to user about a new news comment.
def news_comment_added(user, comment)
news = comment.commented
redmine_headers 'Project' => news.project.identifier
@author = comment.author
@ -187,84 +235,112 @@ class Mailer < ActionMailer::Base
references news
@news = news
@comment = comment
@user = user
@news_url = url_for(:controller => 'news', :action => 'show', :id => news)
mail :to => news.notified_users,
:cc => news.notified_watchers,
mail :to => user,
: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.
# Notifies users about a new comment on a news
#
# Example:
# message_posted(message) => Mail::Message object
# Mailer.message_posted(message).deliver => sends an email to the recipients
def message_posted(message)
# Mailer.deliver_news_comment_added(comment)
def self.deliver_news_comment_added(comment)
news = comment.commented
users = news.notified_users | news.notified_watchers
users.each do |user|
news_comment_added(user, comment).deliver_later
end
end
# Builds a mail to user about a new message.
def message_posted(user, 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
@user = user
@message_url = url_for(message.event_url)
mail :to => recipients,
:cc => cc,
mail :to => user,
: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.
# Notifies users about a new forum message.
#
# 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)
# Mailer.deliver_message_posted(message)
def self.deliver_message_posted(message)
users = message.notified_users
users |= message.root.notified_watchers
users |= message.board.notified_watchers
users.each do |user|
message_posted(user, message).deliver_later
end
end
# Builds a mail to user about a new wiki content.
def wiki_content_added(user, 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
@user = user
@wiki_content_url = url_for(:controller => 'wiki', :action => 'show',
:project_id => wiki_content.project,
:id => wiki_content.page.title)
mail :to => recipients,
:cc => cc,
mail :to => user,
: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.
# Notifies users about a new wiki content (wiki page added).
#
# 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)
# Mailer.deliver_wiki_content_added(wiki_content)
def self.deliver_wiki_content_added(wiki_content)
users = wiki_content.notified_users | wiki_content.page.wiki.notified_watchers
users.each do |user|
wiki_content_added(user, wiki_content).deliver_later
end
end
# Builds a mail to user about an update of the specified wiki content.
def wiki_content_updated(user, 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
@user = user
@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,
mail :to => user,
: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.
# Notifies users about the update of the specified wiki content
#
# Example:
# account_information(user, password) => Mail::Message object
# Mailer.account_information(user, password).deliver => sends account information to the user
# Mailer.deliver_wiki_content_updated(wiki_content)
def self.deliver_wiki_content_updated(wiki_content)
users = wiki_content.notified_users
users |= wiki_content.page.notified_watchers
users |= wiki_content.page.wiki.notified_watchers
users.each do |user|
wiki_content_updated(user, wiki_content).deliver_later
end
end
# Builds a mail to user about his account information.
def account_information(user, password)
set_language_if_valid user.language
@user = user
@password = password
@login_url = url_for(:controller => 'account', :action => 'login')
@ -272,108 +348,221 @@ class Mailer < ActionMailer::Base
: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
# Notifies user about his account information.
def self.deliver_account_information(user, password)
account_information(user, password).deliver_later
end
# Builds a mail to user about an account activation request.
def account_activation_request(user, new_user)
@new_user = new_user
@url = url_for(:controller => 'users', :action => 'index',
:status => User::STATUS_REGISTERED,
:sort_key => 'created_on', :sort_order => 'desc')
mail :to => recipients,
mail :to => user,
: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.
# Notifies admin users that an account activation request needs
# their approval.
#
# Example:
# account_activated(user) => Mail::Message object
# Mailer.account_activated(user).deliver => sends an email to the registered user
# Exemple:
# Mailer.deliver_account_activation_request(new_user)
def self.deliver_account_activation_request(new_user)
# Send the email to all active administrators
users = User.active.where(:admin => true)
users.each do |user|
account_activation_request(user, new_user).deliver_later
end
end
# Builds a mail to notify user that his account was activated.
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
# Notifies user that his account was activated.
#
# Exemple:
# Mailer.deliver_account_activated(user)
def self.deliver_account_activated(user)
account_activated(user).deliver_later
end
# Builds a mail with the password recovery link.
def lost_password(user, token, recipient=nil)
recipient ||= 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, options={})
# Sends an email to user with a password recovery link.
# The email will be sent to the email address specifiedby recipient if provided.
#
# Exemple:
# Mailer.deliver_account_activated(user, token)
# Mailer.deliver_account_activated(user, token, 'foo@example.net')
def self.deliver_lost_password(user, token, recipient=nil)
lost_password(user, token, recipient).deliver_later
end
# Notifies user that his password was updated by sender.
#
# Exemple:
# Mailer.deliver_password_updated(user, sender)
def self.deliver_password_updated(user, sender)
# 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,
deliver_security_notification(
user,
sender,
message: :mail_body_password_updated,
title: :button_change_password,
remote_ip: options[:remote_ip],
originator: user,
url: {controller: 'my', action: 'password'}
).deliver
)
end
def register(token)
set_language_if_valid(token.user.language)
# Builds a mail to user with his account activation link.
def register(user, token)
@token = token
@url = url_for(:controller => 'account', :action => 'activate', :token => token.value)
mail :to => token.user.mail,
mail :to => user.mail,
:subject => l(:mail_subject_register, Setting.app_title)
end
def security_notification(recipients, options={})
@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]
)
# Sends an mail to user with his account activation link.
#
# Exemple:
# Mailer.deliver_register(user, token)
def self.deliver_register(user, token)
register(user, token).deliver_later
end
# Build a mail to user and the additional recipients given in
# options[:recipients] about a security related event made by sender.
#
# Example:
# security_notification(user,
# sender,
# message: :mail_body_security_notification_add,
# field: :field_mail,
# value: address
# ) => Mail::Message object
def security_notification(user, sender, options={})
@sender = sender
redmine_headers 'Sender' => sender.login
@message =
l(options[:message],
field: (options[:field] && l(options[:field])),
value: options[:value])
@title = options[:title] && l(options[:title])
@originator = options[:originator] || User.current
@remote_ip = options[:remote_ip] || @originator.remote_ip
@remote_ip = options[:remote_ip] || @sender.remote_ip
@url = options[:url] && (options[:url].is_a?(Hash) ? url_for(options[:url]) : options[:url])
redmine_headers 'Sender' => @originator.login
redmine_headers 'Url' => @url
mail :to => recipients,
mail :to => [user, *options[:recipients]].uniq,
:subject => "[#{Setting.app_title}] #{l(:mail_subject_security_notification)}"
end
def settings_updated(recipients, changes)
redmine_headers 'Sender' => User.current.login
# Notifies the given users about a security related event made by sender.
#
# You can specify additional recipients in options[:recipients]. These will be
# added to all generated mails for all given users. Usually, you'll want to
# give only a single user when setting the additional recipients.
#
# Example:
# Mailer.deliver_security_notification(users,
# sender,
# message: :mail_body_security_notification_add,
# field: :field_mail,
# value: address
# )
def self.deliver_security_notification(users, sender, options={})
# Symbols cannot be serialized:
# ActiveJob::SerializationError: Unsupported argument type: Symbol
options = options.transform_values {|v| v.is_a?(Symbol) ? v.to_s : v }
# sender's remote_ip would be lost on serialization/deserialization
# we have to pass it with options
options[:remote_ip] ||= sender.remote_ip
Array.wrap(users).each do |user|
security_notification(user, sender, options).deliver_later
end
end
# Build a mail to user about application settings changes made by sender.
def settings_updated(user, sender, changes, options={})
@sender = sender
redmine_headers 'Sender' => sender.login
@changes = changes
@remote_ip = options[:remote_ip] || @sender.remote_ip
@url = url_for(controller: 'settings', action: 'index')
mail :to => recipients,
mail :to => user,
:subject => "[#{Setting.app_title}] #{l(:mail_subject_security_notification)}"
end
# Notifies admins about settings changes
def self.security_settings_updated(changes)
# Notifies admins about application settings changes made by sender, where
# changes is an array of settings names.
#
# Exemple:
# Mailer.deliver_settings_updated(sender, [:login_required, :self_registration])
def self.deliver_settings_updated(sender, changes, options={})
return unless changes.present?
# Symbols cannot be serialized:
# ActiveJob::SerializationError: Unsupported argument type: Symbol
changes = changes.map(&:to_s)
# sender's remote_ip would be lost on serialization/deserialization
# we have to pass it with options
options[:remote_ip] ||= sender.remote_ip
users = User.active.where(admin: true).to_a
settings_updated(users, changes).deliver
users.each do |user|
settings_updated(user, sender, changes, options).deliver_later
end
end
# Build a test email to user.
def test_email(user)
set_language_if_valid(user.language)
@url = url_for(:controller => 'welcome')
mail :to => user.mail,
mail :to => user,
:subject => 'Redmine test'
end
# Send a test email to user. Will raise error that may occur during delivery.
#
# Exemple:
# Mailer.deliver_test_email(user)
def self.deliver_test_email(user)
raise_delivery_errors_was = self.raise_delivery_errors
self.raise_delivery_errors = true
# Email must be delivered synchronously so we can catch errors
test_email(user).deliver_now
ensure
self.raise_delivery_errors = raise_delivery_errors_was
end
# Builds a reminder mail to user about issues that are due in the next days.
def reminder(user, issues, days)
@issues = issues
@days = days
@issues_url = url_for(:controller => 'issues', :action => 'index',
:set_filter => 1, :assigned_to_id => 'me',
:sort => 'due_date:asc')
query = IssueQuery.new(:name => '_')
query.add_filter('assigned_to_id', '=', ['me'])
@open_issues_count = query.issue_count
mail :to => user,
:subject => l(:mail_subject_reminder, :count => issues.size, :days => days)
end
# Sends reminders to issue assignees
# Available options:
# * :days => how many days in the future to remind about (defaults to 7)
@ -387,7 +576,7 @@ class Mailer < ActionMailer::Base
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]}")
raise ActiveRecord::RecordNotFound.new("Couldn't find Version named #{options[:version]}")
end
user_ids = options[:users]
@ -413,7 +602,8 @@ class Mailer < ActionMailer::Base
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?
visible_issues.sort!{|a, b| (a.due_date <=> b.due_date).nonzero? || (a.id <=> b.id)}
reminder(assignee, visible_issues, days).deliver_later if visible_issues.present?
end
end
end
@ -427,27 +617,46 @@ class Mailer < ActionMailer::Base
ActionMailer::Base.perform_deliveries = was_enabled
end
# Sends emails synchronously in the given block
# Execute the given block with inline sending of emails if the default Async
# queue is used for the mailer. See the Rails guide:
# Using the asynchronous queue from a Rake task will generally not work because
# Rake will likely end, causing the in-process thread pool to be deleted, before
# any/all of the .deliver_later emails are processed
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")
adapter = ActionMailer::DeliveryJob.queue_adapter
if adapter.is_a?(ActiveJob::QueueAdapters::AsyncAdapter)
ActionMailer::DeliveryJob.queue_adapter = ActiveJob::QueueAdapters::InlineAdapter.new
end
yield
ensure
ActionMailer::Base.delivery_method = saved_method
ActionMailer::DeliveryJob.queue_adapter = adapter
end
def mail(headers={}, &block)
# Add a display name to the From field if Setting.mail_from does not
# include it
begin
mail_from = Mail::Address.new(Setting.mail_from)
if mail_from.display_name.blank? && mail_from.comments.blank?
mail_from.display_name =
@author&.logged? ? @author.name : Setting.app_title
end
from = mail_from.format
list_id = "<#{mail_from.address.to_s.tr('@', '.')}>"
rescue Mail::Field::IncompleteParseError
# Use Setting.mail_from as it is if Mail::Address cannot parse it
# (probably the emission address is not RFC compliant)
from = Setting.mail_from.to_s
list_id = "<#{from.tr('@', '.')}>"
end
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('@', '.')}>"
'From' => from,
'List-Id' => list_id
# Replaces users with their email addresses
[:to, :cc, :bcc].each do |key|
@ -459,13 +668,13 @@ class Mailer < ActionMailer::Base
# 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
if @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?
if @author&.logged?
redmine_headers 'Sender' => @author.login
end
@ -477,13 +686,13 @@ class Mailer < ActionMailer::Base
end
if @message_id_object
headers[:message_id] = "<#{self.class.message_id_for(@message_id_object)}>"
headers[:message_id] = "<#{self.class.message_id_for(@message_id_object, @user)}>"
end
if @references_objects
headers[:references] = @references_objects.collect {|o| "<#{self.class.references_for(o)}>"}.join(' ')
headers[:references] = @references_objects.collect {|o| "<#{self.class.references_for(o, @user)}>"}.join(' ')
end
m = if block_given?
if block_given?
super headers, &block
else
super headers do |format|
@ -491,15 +700,6 @@ class Mailer < ActionMailer::Base
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)
@ -508,7 +708,7 @@ class Mailer < ActionMailer::Base
# Log errors when raise_delivery_errors is set to false, Rails does not
mail.raise_delivery_errors = true
super
rescue Exception => e
rescue => e
if ActionMailer::Base.raise_delivery_errors
raise e
else
@ -517,15 +717,6 @@ class Mailer < ActionMailer::Base
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
#
@ -552,30 +743,31 @@ class Mailer < ActionMailer::Base
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)
# Singleton class method is public
class << self
def token_for(object, user)
timestamp = object.send(object.respond_to?(:created_on) ? :created_on : :updated_on)
hash = [
"redmine",
"#{object.class.name.demodulize.underscore}-#{object.id}",
timestamp.utc.strftime("%Y%m%d%H%M%S")
]
hash << user.id if user
host = Setting.mail_from.to_s.strip.gsub(%r{^.*@|>}, '')
host = "#{::Socket.gethostname}.redmine" if host.empty?
"#{hash.join('.')}@#{host}"
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 Message-Id for the given object
def message_id_for(object, user)
token_for(object, user)
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)
# Returns a uniq token for a given object referenced by all notifications
# related to this object
def references_for(object, user)
token_for(object, user)
end
end
def message_id(object)
@ -586,8 +778,4 @@ class Mailer < ActionMailer::Base
@references_objects ||= []
@references_objects << object
end
def mylogger
Rails.logger
end
end

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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
@ -25,13 +27,12 @@ class Member < ActiveRecord::Base
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
# Sort by first role and principal
scope :sorted, lambda {
includes(:member_roles, :roles, :principal).
reorder("#{Role.table_name}.position").
@ -109,6 +110,16 @@ class Member < ActiveRecord::Base
member_roles.any? {|mr| mr.role_id == role.id && mr.inherited_from.present?}
end
# Returns an Array of Project and/or Group from which the given role
# was inherited, or an empty Array if the role was not inherited
def role_inheritance(role)
member_roles.
select {|mr| mr.role_id == role.id && mr.inherited_from.present?}.
map {|mr| mr.inherited_from_member_role.try(:member)}.
compact.
map {|m| m.project == project ? m.principal : m.project}
end
# Returns true if the member's role is editable by user
def role_editable?(role, user=User.current)
if has_inherited_role?(role)

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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
@ -26,7 +28,6 @@ class MemberRole < ActiveRecord::Base
validates_presence_of :role
validate :validate_role_member
attr_protected :id
def validate_role_member
errors.add :role_id, :invalid if role && !role.member?
@ -36,6 +37,11 @@ class MemberRole < ActiveRecord::Base
!inherited_from.nil?
end
# Returns the MemberRole from which self was inherited, or nil
def inherited_from_member_role
MemberRole.find_by_id(inherited_from) if inherited_from
end
# Destroys the MemberRole without destroying its Member if it doesn't have
# any other roles
def destroy_without_member_removal

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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
@ -22,7 +24,6 @@ class Message < ActiveRecord::Base
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},
@ -32,8 +33,17 @@ class Message < ActiveRecord::Base
: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}"})}
:url =>
Proc.new {|o|
{:controller => 'messages', :action => 'show',
:board_id => o.board_id}.
merge(
if o.parent_id.nil?
{:id => o.id}
else
{:id => o.parent_id, :r => o.id, :anchor => "message-#{o.id}"}
end)
}
acts_as_activity_provider :scope => preload({:board => :project}, :author),
:author_key => :author_id
@ -46,7 +56,7 @@ class Message < ActiveRecord::Base
after_create :add_author_as_watcher, :reset_counters!
after_update :update_messages_board
after_destroy :reset_counters!
after_create :send_notification
after_create_commit :send_notification
scope :visible, lambda {|*args|
joins(:board => :project).
@ -55,9 +65,9 @@ class Message < ActiveRecord::Base
safe_attributes 'subject', 'content'
safe_attributes 'locked', 'sticky', 'board_id',
:if => lambda {|message, user|
user.allowed_to?(:edit_messages, message.project)
}
:if => lambda {|message, user|
user.allowed_to?(:edit_messages, message.project)
}
def visible?(user=User.current)
!user.nil? && user.allowed_to?(:view_messages, project)
@ -69,9 +79,9 @@ class Message < ActiveRecord::Base
end
def update_messages_board
if board_id_changed?
if saved_change_to_board_id?
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_before_last_save)
Board.reset_counters!(board_id)
end
end
@ -115,7 +125,7 @@ class Message < ActiveRecord::Base
def send_notification
if Setting.notified_events.include?('message_posted')
Mailer.message_posted(self).deliver
Mailer.deliver_message_posted(self)
end
end
end

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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
@ -24,7 +26,6 @@ class News < ActiveRecord::Base
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
@ -36,7 +37,7 @@ class News < ActiveRecord::Base
acts_as_watchable
after_create :add_author_as_watcher
after_create :send_notification
after_create_commit :send_notification
scope :visible, lambda {|*args|
joins(:project).
@ -92,7 +93,7 @@ class News < ActiveRecord::Base
def send_notification
if Setting.notified_events.include?('news_added')
Mailer.news_added(self).deliver
Mailer.deliver_news_added(self)
end
end
end

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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
@ -56,7 +58,8 @@ class Principal < ActiveRecord::Base
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 (?))",
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
@ -69,15 +72,20 @@ class Principal < ActiveRecord::Base
where({})
else
pattern = "%#{q}%"
sql = %w(login firstname lastname).map {|column| "LOWER(#{table_name}.#{column}) LIKE LOWER(:p)"}.join(" OR ")
sql = +"LOWER(#{table_name}.login) LIKE LOWER(:p)"
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)
tokens = q.split(/\s+/).reject(&:blank?).map { |token| "%#{token}%" }
if tokens.present?
sql << ' OR ('
sql << tokens.map.with_index do |token, index|
params.merge!(:"token_#{index}" => token)
"(LOWER(#{table_name}.firstname) LIKE LOWER(:token_#{index}) OR LOWER(#{table_name}.lastname) LIKE LOWER(:token_#{index}))"
end.join(' AND ')
sql << ')'
end
where(sql, params)
end
}
@ -89,7 +97,9 @@ class Principal < ActiveRecord::Base
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)
# include active and locked users
where(:status => [STATUS_LOCKED, STATUS_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
@ -125,7 +135,7 @@ class Principal < ActiveRecord::Base
end
def visible?(user=User.current)
Principal.visible(user).where(:id => id).first == self
Principal.visible(user).find_by(:id => id) == self
end
# Returns true if the principal is a member of project
@ -167,13 +177,14 @@ class Principal < ActiveRecord::Base
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(/ /)
if principal.nil? && / /.match?(keyword)
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
}
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}

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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
@ -67,8 +69,6 @@ class Project < ActiveRecord::Base
:url => Proc.new {|o| {:controller => 'projects', :action => 'show', :id => o}},
:author => nil
attr_protected :status
validates_presence_of :name, :identifier
validates_uniqueness_of :identifier, :if => Proc.new {|p| p.identifier_changed?}
validates_length_of :name, :maximum => 255
@ -80,9 +80,9 @@ class Project < ActiveRecord::Base
validates_exclusion_of :identifier, :in => %w( new )
validate :validate_parent
after_save :update_inherited_members, :if => Proc.new {|project| project.inherit_members_changed?}
after_save :remove_inherited_member_roles, :add_inherited_member_roles, :if => Proc.new {|project| project.parent_id_changed?}
after_update :update_versions_from_hierarchy_change, :if => Proc.new {|project| project.parent_id_changed?}
after_save :update_inherited_members, :if => Proc.new {|project| project.saved_change_to_inherit_members?}
after_save :remove_inherited_member_roles, :add_inherited_member_roles, :if => Proc.new {|project| project.saved_change_to_parent_id?}
after_update :update_versions_from_hierarchy_change, :if => Proc.new {|project| project.saved_change_to_parent_id?}
before_destroy :delete_all_members
scope :has_module, lambda {|mod|
@ -93,14 +93,8 @@ class Project < ActiveRecord::Base
scope :all_public, lambda { where(:is_public => true) }
scope :visible, lambda {|*args| where(Project.visible_condition(args.shift || User.current, *args)) }
scope :allowed_to, lambda {|*args|
user = User.current
permission = nil
if args.first.is_a?(Symbol)
permission = args.shift
else
user = args.shift
permission = args.shift
end
user = args.first.is_a?(Symbol) ? User.current : args.shift
permission = args.shift
where(Project.allowed_to_condition(user, permission, *args))
}
scope :like, lambda {|arg|
@ -181,7 +175,7 @@ class Project < ActiveRecord::Base
base_statement = (perm && perm.read? ? "#{Project.table_name}.status <> #{Project::STATUS_ARCHIVED}" : "#{Project.table_name}.status = #{Project::STATUS_ACTIVE}")
if !options[:skip_pre_condition] && perm && perm.project_module
# If the permission belongs to a project module, make sure the module is enabled
base_statement << " AND EXISTS (SELECT 1 AS one FROM #{EnabledModule.table_name} em WHERE em.project_id = #{Project.table_name}.id AND em.name='#{perm.project_module}')"
base_statement += " AND EXISTS (SELECT 1 AS one FROM #{EnabledModule.table_name} em WHERE em.project_id = #{Project.table_name}.id AND em.name='#{perm.project_module}')"
end
if project = options[:project]
project_statement = project.project_condition(options[:with_subprojects])
@ -257,6 +251,15 @@ class Project < ActiveRecord::Base
scope
end
# Creates or updates project time entry activities
def update_or_create_time_entry_activities(activities)
transaction do
activities.each do |id, activity|
update_or_create_time_entry_activity(id, activity)
end
end
end
# Will create a new Project specific Activity or update an existing one
#
# This will raise a ActiveRecord::Rollback if the TimeEntryActivity
@ -266,7 +269,7 @@ class Project < ActiveRecord::Base
self.create_time_entry_activity_if_needed(activity_hash)
else
activity = project.time_entry_activities.find_by_id(id.to_i)
activity.update_attributes(activity_hash) if activity
activity.update(activity_hash) if activity
end
end
@ -304,7 +307,7 @@ class Project < ActiveRecord::Base
end
def self.find(*args)
if args.first && args.first.is_a?(String) && !args.first.match(/^\d*$/)
if args.first && args.first.is_a?(String) && !/^\d*$/.match?(args.first)
project = find_by_identifier(*args)
raise ActiveRecord::RecordNotFound, "Couldn't find Project with identifier=#{args.first}" if project.nil?
project
@ -344,7 +347,7 @@ class Project < ActiveRecord::Base
nil
else
# id is used for projects with a numeric identifier (compatibility)
@to_param ||= (identifier.to_s =~ %r{^\d*$} ? id.to_s : identifier)
@to_param ||= (%r{^\d*$}.match?(identifier.to_s) ? id.to_s : identifier)
end
end
@ -380,15 +383,11 @@ class Project < ActiveRecord::Base
true
end
# Unarchives the project
# All its ancestors must be active
# Unarchives the project and its archived ancestors
def unarchive
return false if ancestors.detect {|a| a.archived?}
new_status = STATUS_ACTIVE
if parent
new_status = parent.status
end
update_attribute :status, new_status
new_status = ancestors.any?(&:closed?) ? STATUS_CLOSED : STATUS_ACTIVE
self_and_ancestors.status(STATUS_ARCHIVED).update_all :status => new_status
reload
end
def close
@ -414,14 +413,6 @@ class Project < ActiveRecord::Base
@allowed_parents
end
# Sets the parent of the project with authorization check
def set_allowed_parent!(p)
ActiveSupport::Deprecation.warn "Project#set_allowed_parent! is deprecated and will be removed in Redmine 4, use #safe_attributes= instead."
p = p.id if p.is_a?(Project)
send :safe_attributes, {:project_id => p}
save
end
# Sets the parent of the project and saves the project
# Argument can be either a Project, a String, a Fixnum or nil
def set_parent!(p)
@ -527,8 +518,8 @@ class Project < ActiveRecord::Base
member
end
# Default role that is given to non-admin users that
# create a project
# Default role that is given to non-admin users that
# create a project
def self.default_member_role
Role.givable.find_by_id(Setting.new_project_user_role_id.to_i) || Role.givable.first
end
@ -625,10 +616,11 @@ class Project < ActiveRecord::Base
end
def css_classes
s = 'project'
s = +'project'
s << ' root' if root?
s << ' child' if child?
s << (leaf? ? ' leaf' : ' parent')
s << ' public' if is_public?
unless active?
if archived?
s << ' archived'
@ -641,20 +633,22 @@ class Project < ActiveRecord::Base
# The earliest start date of a project, based on it's issues and versions
def start_date
@start_date ||= [
issues.minimum('start_date'),
shared_versions.minimum('effective_date'),
Issue.fixed_version(shared_versions).minimum('start_date')
].compact.min
@start_date ||=
[
issues.minimum('start_date'),
shared_versions.minimum('effective_date'),
Issue.fixed_version(shared_versions).minimum('start_date')
].compact.min
end
# The latest due date of an issue or version
def due_date
@due_date ||= [
issues.maximum('due_date'),
shared_versions.maximum('effective_date'),
Issue.fixed_version(shared_versions).maximum('due_date')
].compact.max
@due_date ||=
[
issues.maximum('due_date'),
shared_versions.maximum('effective_date'),
Issue.fixed_version(shared_versions).maximum('due_date')
].compact.max
end
def overdue?
@ -746,7 +740,8 @@ class Project < ActiveRecord::Base
target.destroy unless target.blank?
end
safe_attributes 'name',
safe_attributes(
'name',
'description',
'homepage',
'is_public',
@ -757,10 +752,12 @@ class Project < ActiveRecord::Base
'issue_custom_field_ids',
'parent_id',
'default_version_id',
'default_assigned_to_id'
'default_assigned_to_id')
safe_attributes 'enabled_module_names',
:if => lambda {|project, user|
safe_attributes(
'enabled_module_names',
:if =>
lambda {|project, user|
if project.new_record?
if user.admin?
true
@ -770,12 +767,17 @@ class Project < ActiveRecord::Base
else
user.allowed_to?(:select_project_modules, project)
end
}
})
safe_attributes 'inherit_members',
:if => lambda {|project, user| project.parent.nil? || project.parent.visible?(user)}
safe_attributes(
'inherit_members',
:if => lambda {|project, user| project.parent.nil? || project.parent.visible?(user)})
def safe_attributes=(attrs, user=User.current)
if attrs.respond_to?(:to_unsafe_hash)
attrs = attrs.to_unsafe_hash
end
return unless attrs.is_a?(Hash)
attrs = attrs.deep_dup
@ -791,6 +793,18 @@ class Project < ActiveRecord::Base
end
end
# Reject custom fields values not visible by the user
if attrs['custom_field_values'].present?
editable_custom_field_ids = editable_custom_field_values(user).map {|v| v.custom_field_id.to_s}
attrs['custom_field_values'].reject! {|k, v| !editable_custom_field_ids.include?(k.to_s)}
end
# Reject custom fields not visible by the user
if attrs['custom_fields'].present?
editable_custom_field_ids = editable_custom_field_values(user).map {|v| v.custom_field_id.to_s}
attrs['custom_fields'].reject! {|c| !editable_custom_field_ids.include?(c['id'].to_s)}
end
super(attrs, user)
end
@ -818,12 +832,17 @@ class Project < ActiveRecord::Base
def copy(project, options={})
project = project.is_a?(Project) ? project : Project.find(project)
to_be_copied = %w(members wiki versions issue_categories issues queries boards)
to_be_copied = %w(members wiki versions issue_categories issues queries boards documents)
to_be_copied = to_be_copied & Array.wrap(options[:only]) unless options[:only].nil?
Project.transaction do
if save
reload
self.attachments = project.attachments.map do |attachment|
attachment.copy(:container => self)
end
to_be_copied.each do |name|
send "copy_#{name}", project
end
@ -835,11 +854,6 @@ class Project < ActiveRecord::Base
end
end
def member_principals
ActiveSupport::Deprecation.warn "Project#member_principals is deprecated and will be removed in Redmine 4.0. Use #memberships.active instead."
memberships.active
end
# Returns a new unsaved Project instance with attributes copied from +project+
def self.copy_from(project)
project = project.is_a?(Project) ? project : Project.find(project)
@ -860,7 +874,8 @@ class Project < ActiveRecord::Base
ancestors = projects.first.ancestors.to_a
end
projects.sort_by(&:lft).each do |project|
while (ancestors.any? && !project.is_descendant_of?(ancestors.last))
while ancestors.any? &&
!project.is_descendant_of?(ancestors.last)
ancestors.pop
end
yield project, ancestors.size
@ -868,14 +883,26 @@ class Project < ActiveRecord::Base
end
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(user)
end
def visible_custom_field_values(user = nil)
user ||= User.current
custom_field_values.select do |value|
value.custom_field.visible_by?(project, user)
end
end
private
def update_inherited_members
if parent
if inherit_members? && !inherit_members_was
if inherit_members? && !inherit_members_before_last_save
remove_inherited_member_roles
add_inherited_member_roles
elsif !inherit_members? && inherit_members_was
elsif !inherit_members? && inherit_members_before_last_save
remove_inherited_member_roles
end
end
@ -932,6 +959,7 @@ class Project < ActiveRecord::Base
new_wiki_page = WikiPage.new(page.attributes.dup.except("id", "wiki_id", "created_on", "parent_id"))
new_wiki_page.content = new_wiki_content
wiki.pages << new_wiki_page
new_wiki_page.attachments = page.attachments.map{|attachement| attachement.copy(:container => new_wiki_page)}
wiki_pages_map[page.id] = new_wiki_page
end
@ -952,6 +980,11 @@ class Project < ActiveRecord::Base
project.versions.each do |version|
new_version = Version.new
new_version.attributes = version.attributes.dup.except("id", "project_id", "created_on", "updated_on")
new_version.attachments = version.attachments.map do |attachment|
attachment.copy(:container => new_version)
end
self.versions << new_version
end
end
@ -1104,6 +1137,21 @@ class Project < ActiveRecord::Base
end
end
# Copies documents from +project+
def copy_documents(project)
project.documents.each do |document|
new_document = Document.new
new_document.attributes = document.attributes.dup.except("id", "project_id")
new_document.project = self
new_document.attachments = document.attachments.map do |attachement|
attachement.copy(:container => new_document)
end
self.documents << new_document
end
end
def allowed_permissions
@allowed_permissions ||= begin
module_names = enabled_modules.loaded? ? enabled_modules.map(&:name) : enabled_modules.pluck(:name)

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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
@ -19,4 +21,13 @@ class ProjectCustomField < CustomField
def type_name
:label_project_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)
project_key ||= "#{Project.table_name}.id"
super(project_key, user, id_column)
end
end

112
app/models/project_query.rb Normal file
View file

@ -0,0 +1,112 @@
# frozen_string_literal: true
# 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 ProjectQuery < Query
self.queried_class = Project
self.view_permission = :search_project
validate do |query|
# project must be blank for ProjectQuery
errors.add(:project_id, :exclusion) if query.project_id.present?
end
self.available_columns = [
QueryColumn.new(:name, :sortable => "#{Project.table_name}.name"),
QueryColumn.new(:status, :sortable => "#{Project.table_name}.status"),
QueryColumn.new(:short_description, :sortable => "#{Project.table_name}.description", :caption => :field_description),
QueryColumn.new(:homepage, :sortable => "#{Project.table_name}.homepage"),
QueryColumn.new(:identifier, :sortable => "#{Project.table_name}.identifier"),
QueryColumn.new(:parent_id, :sortable => "#{Project.table_name}.lft ASC", :default_order => 'desc', :caption => :field_parent),
QueryColumn.new(:is_public, :sortable => "#{Project.table_name}.is_public", :groupable => true),
QueryColumn.new(:created_on, :sortable => "#{Project.table_name}.created_on", :default_order => 'desc')
]
def initialize(attributes=nil, *args)
super attributes
self.filters ||= { 'status' => {:operator => "=", :values => ['1']} }
end
def initialize_available_filters
add_available_filter(
"status",
:type => :list, :values => lambda { project_statuses_values }
)
add_available_filter(
"id",
:type => :list, :values => lambda { project_values }, :label => :field_project
)
add_available_filter "name", :type => :text
add_available_filter "description", :type => :text
add_available_filter(
"parent_id",
:type => :list_subprojects, :values => lambda { project_values }, :label => :field_parent
)
add_available_filter(
"is_public",
:type => :list,
:values => [[l(:general_text_yes), "1"], [l(:general_text_no), "0"]]
)
add_available_filter "created_on", :type => :date_past
add_custom_fields_filters(project_custom_fields)
end
def available_columns
return @available_columns if @available_columns
@available_columns = self.class.available_columns.dup
@available_columns += ProjectCustomField.visible.
map {|cf| QueryCustomFieldColumn.new(cf) }
@available_columns
end
def available_display_types
['board', 'list']
end
def default_columns_names
@default_columns_names = Setting.project_list_defaults.symbolize_keys[:column_names].map(&:to_sym)
end
def default_sort_criteria
[[]]
end
def base_scope
Project.visible.where(statement)
end
def results_scope(options={})
order_option = [group_by_sort_order, (options[:order] || sort_clause)].flatten.reject(&:blank?)
order_option << "#{Project.table_name}.lft ASC"
scope = base_scope.
order(order_option).
joins(joins_for_order_statement(order_option.join(',')))
if has_custom_field_column?
scope = scope.preload(:custom_values)
end
if has_column?(:parent_id)
scope = scope.preload(:parent)
end
scope
end
end

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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
@ -15,8 +17,11 @@
# 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/sort_criteria'
class QueryColumn
attr_accessor :name, :sortable, :groupable, :totalable, :default_order
attr_accessor :name, :groupable, :totalable, :default_order
attr_writer :sortable
include Redmine::I18n
def initialize(name, options={})
@ -69,11 +74,31 @@ class QueryColumn
object.send name
end
# Returns the group that object belongs to when grouping query results
def group_value(object)
value(object)
end
def css_classes
name
end
end
class TimestampQueryColumn < QueryColumn
def groupable
if @groupable
Redmine::Database.timestamp_to_date(sortable, User.current.time_zone)
end
end
def group_value(object)
if time = value(object)
User.current.time_to_date(time)
end
end
end
class QueryAssociationColumn < QueryColumn
def initialize(association, attribute, options={})
@ -101,7 +126,7 @@ class QueryCustomFieldColumn < QueryColumn
self.sortable = custom_field.order_statement || false
self.groupable = custom_field.group_statement || false
self.totalable = options.key?(:totalable) ? !!options[:totalable] : custom_field.totalable?
@inline = true
@inline = custom_field.full_width_layout? ? false : true
@cf = custom_field
end
@ -116,7 +141,7 @@ class QueryCustomFieldColumn < QueryColumn
def value_object(object)
if custom_field.visible_by?(object.project, User.current)
cv = object.custom_values.select {|v| v.custom_field_id == @cf.id}
cv.size > 1 ? cv.sort {|a,b| a.value.to_s <=> b.value.to_s} : cv.first
cv.size > 1 ? cv.sort_by {|e| e.value.to_s} : cv.first
else
nil
end
@ -212,8 +237,6 @@ class Query < ActiveRecord::Base
serialize :sort_criteria, Array
serialize :options, Hash
attr_protected :project_id, :user_id
validates_presence_of :name
validates_length_of :name, :maximum => 255
validates :visibility, :inclusion => { :in => [VISIBILITY_PUBLIC, VISIBILITY_ROLES, VISIBILITY_PRIVATE] }
@ -223,7 +246,7 @@ class Query < ActiveRecord::Base
end
after_save do |query|
if query.visibility_changed? && query.visibility != VISIBILITY_ROLES
if query.saved_change_to_visibility? && query.visibility != VISIBILITY_ROLES
query.roles.clear
end
end
@ -243,11 +266,14 @@ class Query < ActiveRecord::Base
">t+" => :label_in_more_than,
"><t+"=> :label_in_the_next_days,
"t+" => :label_in,
"nd" => :label_tomorrow,
"t" => :label_today,
"ld" => :label_yesterday,
"nw" => :label_next_week,
"w" => :label_this_week,
"lw" => :label_last_week,
"l2w" => [:label_last_n_weeks, {:count => 2}],
"nm" => :label_next_month,
"m" => :label_this_month,
"lm" => :label_last_month,
"y" => :label_this_year,
@ -257,11 +283,13 @@ class Query < ActiveRecord::Base
"t-" => :label_ago,
"~" => :label_contains,
"!~" => :label_not_contains,
"^" => :label_starts_with,
"$" => :label_ends_with,
"=p" => :label_any_issues_in_project,
"=!p" => :label_any_issues_not_in_project,
"!p" => :label_no_issues_in_project,
"*o" => :label_any_open_issues,
"!o" => :label_no_open_issues
"!o" => :label_no_open_issues,
}
class_attribute :operators_by_filter_type
@ -270,13 +298,13 @@ class Query < ActiveRecord::Base
:list_status => [ "o", "=", "!", "c", "*" ],
:list_optional => [ "=", "!", "!*", "*" ],
:list_subprojects => [ "*", "!*", "=", "!" ],
:date => [ "=", ">=", "<=", "><", "<t+", ">t+", "><t+", "t+", "t", "ld", "w", "lw", "l2w", "m", "lm", "y", ">t-", "<t-", "><t-", "t-", "!*", "*" ],
:date => [ "=", ">=", "<=", "><", "<t+", ">t+", "><t+", "t+", "nd", "t", "ld", "nw", "w", "lw", "l2w", "nm", "m", "lm", "y", ">t-", "<t-", "><t-", "t-", "!*", "*" ],
:date_past => [ "=", ">=", "<=", "><", ">t-", "<t-", "><t-", "t-", "t", "ld", "w", "lw", "l2w", "m", "lm", "y", "!*", "*" ],
:string => [ "=", "~", "!", "!~", "!*", "*" ],
:text => [ "~", "!~", "!*", "*" ],
:string => [ "~", "=", "!~", "!", "^", "$", "!*", "*" ],
:text => [ "~", "!~", "^", "$", "!*", "*" ],
:integer => [ "=", ">=", "<=", "><", "!*", "*" ],
:float => [ "=", ">=", "<=", "><", "!*", "*" ],
:relation => ["=", "=p", "=!p", "!p", "*o", "!o", "!*", "*"],
:relation => ["=", "!", "=p", "=!p", "!p", "*o", "!o", "!*", "*"],
:tree => ["=", "~", "!*", "*"]
}
@ -302,7 +330,7 @@ class Query < ActiveRecord::Base
if self == ::Query
# Visibility depends on permissions for each subclass,
# raise an error if the scope is called from Query (eg. Query.visible)
raise Exception.new("Cannot call .visible scope from the base Query class, but from subclasses only.")
raise "Cannot call .visible scope from the base Query class, but from subclasses only."
end
user = args.shift || User.current
@ -313,15 +341,16 @@ class Query < ActiveRecord::Base
if user.admin?
scope.where("#{table_name}.visibility <> ? OR #{table_name}.user_id = ?", VISIBILITY_PRIVATE, user.id)
elsif user.memberships.any?
scope.where("#{table_name}.visibility = ?" +
" OR (#{table_name}.visibility = ? AND #{table_name}.id IN (" +
scope.where(
"#{table_name}.visibility = ?" +
" OR (#{table_name}.visibility = ? AND #{table_name}.id IN (" +
"SELECT DISTINCT q.id FROM #{table_name} q" +
" INNER JOIN #{table_name_prefix}queries_roles#{table_name_suffix} qr on qr.query_id = q.id" +
" INNER JOIN #{MemberRole.table_name} mr ON mr.role_id = qr.role_id" +
" INNER JOIN #{Member.table_name} m ON m.id = mr.member_id AND m.user_id = ?" +
" INNER JOIN #{Project.table_name} p ON p.id = m.project_id AND p.status <> ?" +
" WHERE q.project_id IS NULL OR q.project_id = m.project_id))" +
" OR #{table_name}.user_id = ?",
" OR #{table_name}.user_id = ?",
VISIBILITY_PUBLIC, VISIBILITY_ROLES, user.id, Project::STATUS_ARCHIVED, user.id)
elsif user.logged?
scope.where("#{table_name}.visibility = ? OR #{table_name}.user_id = ?", VISIBILITY_PUBLIC, user.id)
@ -356,29 +385,32 @@ class Query < ActiveRecord::Base
!is_private?
end
# Returns true if the query is available for all projects
def is_global?
new_record? ? project_id.nil? : project_id_in_database.nil?
end
def queried_table_name
@queried_table_name ||= self.class.queried_class.table_name
end
def initialize(attributes=nil, *args)
super attributes
@is_for_all = project.nil?
end
# Builds the query from the given params
def build_from_params(params)
def build_from_params(params, defaults={})
if params[:fields] || params[:f]
self.filters = {}
add_filters(params[:fields] || params[:f], params[:operators] || params[:op], params[:values] || params[:v])
else
available_filters.keys.each do |field|
available_filters.each_key do |field|
add_short_filter(field, params[field]) if params[field]
end
end
self.group_by = params[:group_by] || (params[:query] && params[:query][:group_by])
self.column_names = params[:c] || (params[:query] && params[:query][:column_names])
self.totalable_names = params[:t] || (params[:query] && params[:query][:totalable_names])
self.sort_criteria = params[:sort] || (params[:query] && params[:query][:sort_criteria])
query_params = params[:query] || defaults || {}
self.group_by = params[:group_by] || query_params[:group_by] || self.group_by
self.column_names = params[:c] || query_params[:column_names] || self.column_names
self.totalable_names = params[:t] || query_params[:totalable_names] || self.totalable_names
self.sort_criteria = params[:sort] || query_params[:sort_criteria] || self.sort_criteria
self.display_type = params[:display_type] || query_params[:display_type] || self.display_type
self
end
@ -414,17 +446,17 @@ class Query < ActiveRecord::Base
if values_for(field)
case type_for(field)
when :integer
add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && !v.match(/\A[+-]?\d+(,[+-]?\d+)*\z/) }
add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && !/\A[+-]?\d+(,[+-]?\d+)*\z/.match?(v) }
when :float
add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && !v.match(/\A[+-]?\d+(\.\d*)?\z/) }
add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && !/\A[+-]?\d+(\.\d*)?\z/.match?(v) }
when :date, :date_past
case operator_for(field)
when "=", ">=", "<=", "><"
add_filter_error(field, :invalid) if values_for(field).detect {|v|
v.present? && (!v.match(/\A\d{4}-\d{2}-\d{2}(T\d{2}((:)?\d{2}){0,2}(Z|\d{2}:?\d{2})?)?\z/) || parse_date(v).nil?)
v.present? && (!/\A\d{4}-\d{2}-\d{2}(T\d{2}((:)?\d{2}){0,2}(Z|\d{2}:?\d{2})?)?\z/.match?(v) || parse_date(v).nil?)
}
when ">t-", "<t-", "t-", ">t+", "<t+", "t+", "><t+", "><t-"
add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && !v.match(/^\d+$/) }
add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && !/^\d+$/.match?(v) }
end
end
end
@ -433,7 +465,7 @@ class Query < ActiveRecord::Base
# filter requires one or more values
(values_for(field) and !values_for(field).first.blank?) or
# filter doesn't require any value
["o", "c", "!*", "*", "t", "ld", "w", "lw", "l2w", "m", "lm", "y", "*o", "!o"].include? operator_for(field)
["o", "c", "!*", "*", "nd", "t", "ld", "nw", "w", "lw", "l2w", "nm", "m", "lm", "y", "*o", "!o"].include? operator_for(field)
end if filters
end
@ -447,7 +479,7 @@ class Query < ActiveRecord::Base
# Admin can edit them all and regular users can edit their private queries
return true if user.admin? || (is_private? && self.user_id == user.id)
# Members can not edit public queries that are for all project (only admin is allowed to)
is_public? && !@is_for_all && user.allowed_to?(:manage_public_queries, project)
is_public? && !is_global? && user.allowed_to?(:manage_public_queries, project)
end
def trackers
@ -497,8 +529,9 @@ class Query < ActiveRecord::Base
def project_values
project_values = []
if User.current.logged? && User.current.memberships.any?
project_values << ["<< #{l(:label_my_projects).downcase} >>", "mine"]
if User.current.logged?
project_values << ["<< #{l(:label_my_projects).downcase} >>", "mine"] if User.current.memberships.any?
project_values << ["<< #{l(:label_my_bookmarks).downcase} >>", "bookmarks"] if User.current.bookmarked_project_ids.any?
end
project_values += all_projects_values
project_values
@ -512,7 +545,7 @@ class Query < ActiveRecord::Base
@principal ||= begin
principals = []
if project
principals += project.principals.visible
principals += Principal.member_of(project).visible
unless project.leaf?
principals += Principal.member_of(project.descendants.visible).visible
end
@ -533,14 +566,15 @@ class Query < ActiveRecord::Base
def author_values
author_values = []
author_values << ["<< #{l(:label_me)} >>", "me"] if User.current.logged?
author_values += users.collect{|s| [s.name, s.id.to_s] }
author_values += users.sort_by(&:status).collect{|s| [s.name, s.id.to_s, l("status_#{User::LABEL_BY_STATUS[s.status]}")] }
author_values << [l(:label_user_anonymous), User.anonymous.id.to_s]
author_values
end
def assigned_to_values
assigned_to_values = []
assigned_to_values << ["<< #{l(:label_me)} >>", "me"] if User.current.logged?
assigned_to_values += (Setting.issue_group_assignment? ? principals : users).collect{|s| [s.name, s.id.to_s] }
assigned_to_values += (Setting.issue_group_assignment? ? principals : users).sort_by(&:status).collect{|s| [s.name, s.id.to_s, l("status_#{User::LABEL_BY_STATUS[s.status]}")] }
assigned_to_values
end
@ -549,7 +583,7 @@ class Query < ActiveRecord::Base
if project
versions = project.shared_versions.to_a
else
versions = Version.visible.where(:sharing => 'system').to_a
versions = Version.visible.to_a
end
Version.sort_by_status(versions).collect{|s| ["#{s.project.name} - #{s.name}", s.id.to_s, l("version_status_#{s.status}")] }
end
@ -564,6 +598,12 @@ class Query < ActiveRecord::Base
statuses.collect{|s| [s.name, s.id.to_s]}
end
def watcher_values
watcher_values = [["<< #{l(:label_me)} >>", "me"]]
watcher_values += users.sort_by(&:status).collect{|s| [s.name, s.id.to_s, l("status_#{User::LABEL_BY_STATUS[s.status]}")] } if User.current.allowed_to?(:view_issue_watchers, self.project)
watcher_values
end
# Returns a scope of issue custom fields that are available as columns or filters
def issue_custom_fields
if project
@ -573,6 +613,19 @@ class Query < ActiveRecord::Base
end
end
# Returns a scope of project custom fields that are available as columns or filters
def project_custom_fields
ProjectCustomField.all
end
# Returns a scope of project statuses that are available as columns or filters
def project_statuses_values
[
[l(:project_status_active), "#{Project::STATUS_ACTIVE}"],
[l(:project_status_closed), "#{Project::STATUS_CLOSED}"]
]
end
# Adds available filters
def initialize_available_filters
# implemented by sub-classes
@ -607,7 +660,6 @@ class Query < ActiveRecord::Base
return unless values.nil? || values.is_a?(Array)
# check if field is defined as an available filter
if available_filters.has_key? field
filter_options = available_filters[field]
filters[field] = {:operator => operator, :values => (values || [''])}
end
end
@ -624,7 +676,7 @@ class Query < ActiveRecord::Base
# Add multiple filters using +add_filter+
def add_filters(fields, operators, values)
if fields.is_a?(Array) && operators.is_a?(Hash) && (values.nil? || values.is_a?(Hash))
if fields.present? && operators.present?
fields.each do |field|
add_filter(field, operators[field], values && values[field])
end
@ -674,6 +726,7 @@ class Query < ActiveRecord::Base
end
def columns
return [] if available_columns.empty?
# preserve the column_names order
cols = (has_default_columns? ? default_columns_names : column_names).collect do |name|
available_columns.find { |col| col.name == name }
@ -780,14 +833,21 @@ class Query < ActiveRecord::Base
end
def sort_clause
sort_criteria.sort_clause(sortable_columns)
if clause = sort_criteria.sort_clause(sortable_columns)
clause.map {|c| Arel.sql c}
end
end
# Returns the SQL sort order that should be prepended for grouping
def group_by_sort_order
if column = group_by_column
order = (sort_criteria.order_for(column.name) || column.default_order || 'asc').try(:upcase)
Array(column.sortable).map {|s| "#{s} #{order}"}
column_sortable = column.sortable
if column.is_a?(TimestampQueryColumn)
column_sortable = Redmine::Database.timestamp_to_date(column.sortable, User.current.time_zone)
end
Array(column_sortable).map {|s| Arel.sql("#{s} #{order}")}
end
end
@ -859,10 +919,13 @@ class Query < ActiveRecord::Base
end
end
if field == 'project_id'
if field == 'project_id' || (self.type == 'ProjectQuery' && %w[id parent_id].include?(field))
if v.delete('mine')
v += User.current.memberships.map(&:project_id).map(&:to_s)
end
if v.delete('bookmarks')
v += User.current.bookmarked_project_ids
end
end
if field =~ /^cf_(\d+)\.cf_(\d+)$/
@ -872,7 +935,7 @@ class Query < ActiveRecord::Base
filters_clauses << sql_for_custom_field(field, operator, v, $1)
elsif field =~ /^cf_(\d+)\.(.+)$/
filters_clauses << sql_for_custom_field_attribute(field, operator, v, $1, $2)
elsif respond_to?(method = "sql_for_#{field.gsub('.','_')}_field")
elsif respond_to?(method = "sql_for_#{field.tr('.','_')}_field")
# specific statement
filters_clauses << send(method, field, operator, v)
else
@ -932,17 +995,27 @@ class Query < ActiveRecord::Base
end
end
def display_type
options[:display_type] || self.available_display_types.first
end
def display_type=(type)
unless type || self.available_display_types.include?(type)
type = self.available_display_types.first
end
options[:display_type] = type
end
def available_display_types
['list']
end
private
def grouped_query(&block)
r = nil
if grouped?
begin
# Rails3 will raise an (unexpected) RecordNotFound if there's only a nil group value
r = yield base_group_scope
rescue ActiveRecord::RecordNotFound
r = {nil => yield(base_scope)}
end
r = yield base_group_scope
c = group_by_column
if c.is_a?(QueryCustomFieldColumn)
r = r.keys.inject({}) {|h, k| h[c.custom_field.cast_value(k)] = r[k]; h}
@ -986,7 +1059,7 @@ class Query < ActiveRecord::Base
def map_total(total, &block)
if total.is_a?(Hash)
total.keys.each {|k| total[k] = yield total[k]}
total.each_key {|k| total[k] = yield total[k]}
else
total = yield total
end
@ -1018,7 +1091,7 @@ class Query < ActiveRecord::Base
raise "Unknown #{queried_class.name} association #{assoc}" unless customized_class
end
where = sql_for_field(field, operator, value, db_table, db_field, true)
if operator =~ /[<>]/
if /[<>]/.match?(operator)
where = "(#{where}) AND #{db_table}.#{db_field} <> ''"
end
"#{queried_table_name}.#{customized_key} #{not_in} IN (" +
@ -1109,10 +1182,10 @@ class Query < ActiveRecord::Base
end
when "!*"
sql = "#{db_table}.#{db_field} IS NULL"
sql << " OR #{db_table}.#{db_field} = ''" if (is_custom_filter || [:text, :string].include?(type_for(field)))
sql += " OR #{db_table}.#{db_field} = ''" if is_custom_filter || [:text, :string].include?(type_for(field))
when "*"
sql = "#{db_table}.#{db_field} IS NOT NULL"
sql << " AND #{db_table}.#{db_field} <> ''" if is_custom_filter
sql += " AND #{db_table}.#{db_field} <> ''" if is_custom_filter
when ">="
if [:date, :date_past].include?(type_for(field))
sql = date_clause(db_table, db_field, parse_date(value.first), nil, is_custom_filter)
@ -1177,6 +1250,9 @@ class Query < ActiveRecord::Base
when "ld"
# = yesterday
sql = relative_date_clause(db_table, db_field, -1, -1, is_custom_filter)
when "nd"
# = tomorrow
sql = relative_date_clause(db_table, db_field, 1, 1, is_custom_filter)
when "w"
# = this week
first_day_of_week = l(:general_first_day_of_week).to_i
@ -1195,6 +1271,12 @@ class Query < ActiveRecord::Base
day_of_week = User.current.today.cwday
days_ago = (day_of_week >= first_day_of_week ? day_of_week - first_day_of_week : day_of_week + 7 - first_day_of_week)
sql = relative_date_clause(db_table, db_field, - days_ago - 14, - days_ago - 1, is_custom_filter)
when "nw"
# = next week
first_day_of_week = l(:general_first_day_of_week).to_i
day_of_week = User.current.today.cwday
from = -(day_of_week >= first_day_of_week ? day_of_week - first_day_of_week : day_of_week + 7 - first_day_of_week) + 7
sql = relative_date_clause(db_table, db_field, from, from + 6, is_custom_filter)
when "m"
# = this month
date = User.current.today
@ -1203,6 +1285,10 @@ class Query < ActiveRecord::Base
# = last month
date = User.current.today.prev_month
sql = date_clause(db_table, db_field, date.beginning_of_month, date.end_of_month, is_custom_filter)
when "nm"
# = next month
date = User.current.today.next_month
sql = date_clause(db_table, db_field, date.beginning_of_month, date.end_of_month, is_custom_filter)
when "y"
# = this year
date = User.current.today
@ -1210,7 +1296,11 @@ class Query < ActiveRecord::Base
when "~"
sql = sql_contains("#{db_table}.#{db_field}", value.first)
when "!~"
sql = sql_contains("#{db_table}.#{db_field}", value.first, false)
sql = sql_contains("#{db_table}.#{db_field}", value.first, :match => false)
when "^"
sql = sql_contains("#{db_table}.#{db_field}", value.first, :starts_with => true)
when "$"
sql = sql_contains("#{db_table}.#{db_field}", value.first, :ends_with => true)
else
raise "Unknown query operator #{operator}"
end
@ -1219,9 +1309,16 @@ class Query < ActiveRecord::Base
end
# Returns a SQL LIKE statement with wildcards
def sql_contains(db_field, value, match=true)
queried_class.send :sanitize_sql_for_conditions,
[Redmine::Database.like(db_field, '?', :match => match), "%#{value}%"]
def sql_contains(db_field, value, options={})
options = {} unless options.is_a?(Hash)
options.symbolize_keys!
prefix = suffix = nil
prefix = '%' if options[:ends_with]
suffix = '%' if options[:starts_with]
prefix = suffix = '%' if prefix.nil? && suffix.nil?
queried_class.send(
:sanitize_sql_for_conditions,
[Redmine::Database.like(db_field, '?', :match => options[:match]), "#{prefix}#{value}#{suffix}"])
end
# Adds a filter for the given custom field
@ -1267,18 +1364,18 @@ class Query < ActiveRecord::Base
add_custom_field_filter(field, assoc)
if assoc.nil?
add_chained_custom_field_filters(field)
if field.format.target_class && field.format.target_class == Version
add_available_filter "cf_#{field.id}.due_date",
add_available_filter(
"cf_#{field.id}.due_date",
:type => :date,
:field => field,
:name => l(:label_attribute_of_object, :name => l(:field_effective_date), :object_name => field.name)
add_available_filter "cf_#{field.id}.status",
:name => l(:label_attribute_of_object, :name => l(:field_effective_date), :object_name => field.name))
add_available_filter(
"cf_#{field.id}.status",
:type => :list,
:field => field,
:name => l(:label_attribute_of_object, :name => l(:field_status), :object_name => field.name),
:values => Version::VERSION_STATUSES.map{|s| [l("version_status_#{s}"), s] }
:values => Version::VERSION_STATUSES.map{|s| [l("version_status_#{s}"), s]})
end
end
end
@ -1350,7 +1447,7 @@ class Query < ActiveRecord::Base
# Returns a Date or Time from the given filter value
def parse_date(arg)
if arg.to_s =~ /\A\d{4}-\d{2}-\d{2}T/
if /\A\d{4}-\d{2}-\d{2}T/.match?(arg.to_s)
Time.parse(arg) rescue nil
else
Date.parse(arg) rescue nil

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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
@ -15,7 +17,7 @@
# 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 ScmFetchError < StandardError; end
class Repository < ActiveRecord::Base
include Redmine::Ciphering
@ -48,17 +50,18 @@ class Repository < ActiveRecord::Base
# 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',
safe_attributes(
'identifier',
'login',
'password',
'path_encoding',
'log_encoding',
'is_default'
'is_default')
safe_attributes 'url',
:if => lambda {|repository, user| repository.new_record?}
safe_attributes(
'url',
:if => lambda {|repository, user| repository.new_record?})
def repo_create_validation
unless Setting.enabled_scm.include?(self.class.name.demodulize)
@ -130,9 +133,7 @@ class Repository < ActiveRecord::Base
end
def identifier_param
if is_default?
nil
elsif identifier.present?
if identifier.present?
identifier
else
id.to_s
@ -150,7 +151,7 @@ class Repository < ActiveRecord::Base
end
def self.find_by_identifier_param(param)
if param.to_s =~ /^\d+$/
if /^\d+$/.match?(param.to_s)
find_by_id(param)
else
find_by_identifier(param)
@ -235,8 +236,8 @@ class Repository < ActiveRecord::Base
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 += cs_to.format_identifier + sep if cs_to
text += cs.format_identifier if cs
text
end
@ -249,8 +250,8 @@ class Repository < ActiveRecord::Base
def find_changeset_by_name(name)
return nil if name.blank?
s = name.to_s
if s.match(/^\d*$/)
changesets.where("revision = ?", s).first
if /^\d*$/.match?(s)
changesets.find_by(:revision => s)
else
changesets.where("revision LIKE ?", s + '%').first
end
@ -383,7 +384,7 @@ class Repository < ActiveRecord::Base
ret = ""
begin
ret = self.scm_adapter_class.client_command if self.scm_adapter_class
rescue Exception => e
rescue => e
logger.error "scm: error during get command: #{e.message}"
end
ret
@ -393,7 +394,7 @@ class Repository < ActiveRecord::Base
ret = ""
begin
ret = self.scm_adapter_class.client_version_string if self.scm_adapter_class
rescue Exception => e
rescue => e
logger.error "scm: error during get version string: #{e.message}"
end
ret
@ -403,7 +404,7 @@ class Repository < ActiveRecord::Base
ret = false
begin
ret = self.scm_adapter_class.client_available if self.scm_adapter_class
rescue Exception => e
rescue => e
logger.error "scm: error during get scm available: #{e.message}"
end
ret
@ -422,19 +423,17 @@ class Repository < ActiveRecord::Base
# 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")
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]
@ -470,7 +469,7 @@ class Repository < ActiveRecord::Base
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"))
unless Regexp.new("\\A#{regexp}\\z").match?(send(attribute).to_s)
errors.add(attribute, :invalid)
end
end

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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
@ -18,7 +20,6 @@
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)
@ -95,7 +96,7 @@ class Repository::Bazaar < Repository
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)
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)

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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
@ -21,8 +23,9 @@ 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?}
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
@ -55,7 +58,7 @@ class Repository::Cvs < Repository
end
entries = scm.entries(path, rev.nil? ? nil : rev.committed_on)
if entries
entries.each() do |entry|
entries.each do |entry|
if ( ! entry.lastrev.nil? ) && ( ! entry.lastrev.revision.nil? )
change = filechanges.where(
:revision => entry.lastrev.revision,
@ -98,7 +101,7 @@ class Repository::Cvs < Repository
if rev_to.to_i > 0
changeset_to = changesets.find_by_revision(rev_to)
end
changeset_from.filechanges.each() do |change_from|
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))
@ -106,7 +109,7 @@ class Repository::Cvs < Repository
end
if revision_from
if changeset_to
changeset_to.filechanges.each() do |change_to|
changeset_to.filechanges.each do |change_to|
revision_to = change_to.revision if change_to.path == change_from.path
end
end
@ -144,7 +147,7 @@ class Repository::Cvs < Repository
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,
:committed_on => (tmp_time - time_delta)..(tmp_time + time_delta),
:committer => author_utf8,
:comments => cmt
).first
@ -188,7 +191,7 @@ class Repository::Cvs < Repository
each do |changeset|
changeset.update_attribute :revision, next_revision_number
end
end # transaction
end
@current_revision_number = nil
end
@ -205,8 +208,8 @@ class Repository::Cvs < Repository
# 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%'"
sql = "SELECT revision FROM #{Changeset.table_name} " \
"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

View file

@ -1,114 +0,0 @@
# 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

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 Jean-Philippe Lang
#
# FileSystem adapter
# File written by Paul Rivier, at Demotera.
@ -21,7 +23,6 @@
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)

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 Jean-Philippe Lang
# Copyright (C) 2007 Patrick Aljord patcito@ŋmail.com
#
# This program is free software; you can redistribute it and/or
@ -19,7 +21,6 @@
require 'redmine/scm/adapters/git_adapter'
class Repository::Git < Repository
attr_protected :root_url
validates_presence_of :url
safe_attributes 'report_last_commit'
@ -46,7 +47,7 @@ class Repository::Git < Repository
return false if v.nil?
v.to_s != '0'
end
def report_last_commit=(arg)
merge_extra_info "extra_report_last_commit" => arg
end
@ -83,14 +84,14 @@ class Repository::Git < Repository
def default_branch
scm.default_branch
rescue Exception => e
rescue => 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.find_by(:revision => name.to_s) ||
changesets.where('scmid LIKE ?', "#{name}%").first
end
end

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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
@ -23,7 +25,6 @@ class Repository::Mercurial < Repository
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
@ -97,10 +98,10 @@ class Repository::Mercurial < Repository
def find_changeset_by_name(name)
return nil if name.blank?
s = name.to_s
if /[^\d]/ =~ s or s.size > 8
if /[^\d]/.match?(s) || s.size > 8
cs = changesets.where(:scmid => s).first
else
cs = changesets.where(:revision => s).first
cs = changesets.find_by(:revision => s)
end
return cs if cs
changesets.where('scmid LIKE ?', "#{s}%").first
@ -158,7 +159,7 @@ class Repository::Mercurial < Repository
# But, it is very heavy.
# Mercurial does not treat directory.
# So, "hg log DIR" is very heavy.
branch_limit = path.blank? ? limit : ( limit * 5 )
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 <= ?"

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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
@ -18,7 +20,6 @@
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

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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
@ -59,12 +61,7 @@ class Role < ActiveRecord::Base
}
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_many :workflow_rules, :dependent => :delete_all
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',
@ -77,22 +74,25 @@ class Role < ActiveRecord::Base
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,
validates_length_of :name, :maximum => 255
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,
: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,
: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?}
:if => lambda {|role| role.respond_to?(:time_entries_visibility) && role.time_entries_visibility_changed?})
safe_attributes 'name',
safe_attributes(
'name',
'assignable',
'position',
'issues_visibility',
@ -102,7 +102,7 @@ class Role < ActiveRecord::Base
'managed_role_ids',
'permissions',
'permissions_all_trackers',
'permissions_tracker_ids'
'permissions_tracker_ids')
# Copies attributes from another role, arg can be an id or a Role
def copy_from(arg, options={})
@ -164,9 +164,10 @@ class Role < ActiveRecord::Base
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)
when 1 then l(:label_role_non_member, :default => read_attribute(:name))
when 2 then l(:label_role_anonymous, :default => read_attribute(:name))
else
read_attribute(:name)
end
end
@ -283,14 +284,17 @@ class Role < ActiveRecord::Base
find_or_create_system_role(BUILTIN_ANONYMOUS, 'Anonymous')
end
private
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
@actions_allowed ||=
allowed_permissions.inject([]) {|actions, permission|
actions += Redmine::AccessControl.allowed_actions(permission)
}.flatten
end
def check_deletable
@ -299,7 +303,7 @@ private
end
def self.find_or_create_system_role(builtin, name)
role = unscoped.where(:builtin => builtin).first
role = unscoped.find_by(:builtin => builtin)
if role.nil?
role = unscoped.create(:name => name) do |r|
r.builtin = builtin
@ -308,4 +312,5 @@ private
end
role
end
private_class_method :find_or_create_system_role
end

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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
@ -17,6 +19,13 @@
class Setting < ActiveRecord::Base
PASSWORD_CHAR_CLASSES = {
'uppercase' => /[A-Z]/,
'lowercase' => /[a-z]/,
'digits' => /[0-9]/,
'special_chars' => /[[:ascii:]&&[:graph:]&&[:^alnum:]]/
}
DATE_FORMATS = [
'%Y-%m-%d',
'%d/%m/%Y',
@ -34,7 +43,7 @@ class Setting < ActiveRecord::Base
'%I:%M %p'
]
ENCODINGS = %w(US-ASCII
ENCODINGS = %w(US-ASCII
windows-1250
windows-1251
windows-1252
@ -82,7 +91,6 @@ class Setting < ActiveRecord::Base
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 = {}
@ -106,19 +114,18 @@ class Setting < ActiveRecord::Base
# 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)
@cached_settings[name] ||= find_or_default(name).value
end
def self.[]=(name, v)
setting = find_or_default(name)
setting.value = (v ? v : "")
setting.value = v || ''
@cached_settings[name] = nil
setting.save
setting.value
end
# Updates multiple settings from params and sends a security notification if needed
# 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
@ -136,30 +143,41 @@ class Setting < ActiveRecord::Base
end
end
if changes.any?
Mailer.security_settings_updated(changes)
Mailer.deliver_settings_updated(User.current, 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})"]
[
[:mail_handler_enable_regex_delimiters, :mail_handler_body_delimiters, /[\r\n]+/],
[:mail_handler_enable_regex_excluded_filenames, :mail_handler_excluded_filenames, /\s*,\s*/]
].each do |enable_regex, regex_field, delimiter|
if settings.key?(regex_field) || settings.key?(enable_regex)
regexp = Setting.send("#{enable_regex}?")
if settings.key?(enable_regex)
regexp = settings[enable_regex].to_s != '0'
end
if regexp
settings[regex_field].to_s.split(delimiter).each do |value|
begin
Regexp.new(value)
rescue RegexpError => e
messages << [regex_field, "#{l('activerecord.errors.messages.not_a_regexp')} (#{e.message})"]
end
end
end
end
end
if settings.key?(:mail_from)
begin
mail_from = Mail::Address.new(settings[:mail_from])
raise unless mail_from.address =~ EmailAddress::EMAIL_REGEXP
rescue
messages << [:mail_from, l('activerecord.errors.messages.invalid')]
end
end
messages
end
@ -254,19 +272,19 @@ class Setting < ActiveRecord::Base
def self.define_setting(name, options={})
available_settings[name.to_s] = options
src = <<-END_SRC
def self.#{name}
self[:#{name}]
end
src = <<~END_SRC
def self.#{name}
self[:#{name}]
end
def self.#{name}?
self[:#{name}].to_i > 0
end
def self.#{name}?
self[:#{name}].to_i > 0
end
def self.#{name}=(value)
self[:#{name}] = value
end
END_SRC
def self.#{name}=(value)
self[:#{name}] = value
end
END_SRC
class_eval src, __FILE__, __LINE__
end
@ -285,7 +303,7 @@ END_SRC
load_available_settings
load_plugin_settings
private
private
def force_utf8_strings(arg)
if arg.is_a?(String)
@ -318,4 +336,5 @@ private
end
setting
end
private_class_method :find_or_default
end

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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
@ -22,16 +24,16 @@ class TimeEntry < ActiveRecord::Base
belongs_to :project
belongs_to :issue
belongs_to :user
belongs_to :author, :class_name => '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})"
},
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,
@ -41,13 +43,15 @@ class TimeEntry < ActiveRecord::Base
:author_key => :user_id,
:scope => joins(:project).preload(:project)
validates_presence_of :user_id, :activity_id, :project_id, :hours, :spent_on
validates_presence_of :author_id, :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
#TODO: remove this, author should be always explicitly set
before_validation :set_author_if_nil
validate :validate_time_entry
scope :visible, lambda {|*args|
@ -62,7 +66,7 @@ class TimeEntry < ActiveRecord::Base
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'
safe_attributes 'user_id', '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={})
@ -113,6 +117,11 @@ class TimeEntry < ActiveRecord::Base
@invalid_issue_id = issue_id
end
end
if user_id_changed? && user_id != author_id && !user.allowed_to?(:log_time_for_other_users, project)
@invalid_user_id = user_id
else
@invalid_user_id = nil
end
end
attrs
end
@ -121,11 +130,36 @@ class TimeEntry < ActiveRecord::Base
self.project = issue.project if issue && project.nil?
end
def set_author_if_nil
self.author = User.current if author.nil?
end
def validate_time_entry
errors.add :hours, :invalid if hours && (hours < 0 || hours >= 1000)
if hours
errors.add :hours, :invalid if hours < 0
errors.add :hours, :invalid if hours == 0.0 && hours_changed? && !Setting.timelog_accept_0_hours?
max_hours = Setting.timelog_max_hours_per_day.to_f
if hours_changed? && max_hours > 0.0
logged_hours = other_hours_with_same_user_and_day
if logged_hours + hours > max_hours
errors.add(
:base,
I18n.t(:error_exceeds_maximum_hours_per_day,
:logged_hours => format_hours(logged_hours),
:max_hours => format_hours(max_hours)))
end
end
end
errors.add :project_id, :invalid if project.nil?
if @invalid_user_id || (user_id_changed? && user_id != author_id && !self.assignable_users.map(&:id).include?(user_id))
errors.add :user_id, :invalid
end
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)
if spent_on_changed? && user
errors.add :base, I18n.t(:error_spent_on_future_date) if !Setting.timelog_accept_future_dates? && (spent_on > user.today)
end
end
def hours=(h)
@ -166,4 +200,35 @@ class TimeEntry < ActiveRecord::Base
def editable_custom_fields(user=nil)
editable_custom_field_values(user).map(&:custom_field).uniq
end
def visible_custom_field_values(user = nil)
user ||= User.current
custom_field_values.select do |value|
value.custom_field.visible_by?(project, user)
end
end
def assignable_users
users = []
if project
users = project.members.active.preload(:user)
users = users.map(&:user).select{ |u| u.allowed_to?(:log_time, project) }
end
users << User.current if User.current.logged? && !users.include?(User.current)
users
end
private
# Returns the hours that were logged in other time entries for the same user and the same day
def other_hours_with_same_user_and_day
if user_id && spent_on
TimeEntry.
where(:user_id => user_id, :spent_on => spent_on).
where.not(:id => id).
sum(:hours).to_f
else
0.0
end
end
end

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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
@ -19,5 +21,13 @@ class TimeEntryCustomField < CustomField
def type_name
:label_spent_time
end
end
def visible_by?(project, user=User.current)
super || (roles & user.roles_for_project(project)).present?
end
def validate_custom_field
super
errors.add(:base, l(:label_role_plural) + ' ' + l('activerecord.errors.messages.blank')) unless visible? || roles.present?
end
end

View file

@ -0,0 +1,127 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2019 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 TimeEntryImport < Import
def self.menu_item
:time_entries
end
def self.authorized?(user)
user.allowed_to?(:import_time_entries, nil, :global => true)
end
# Returns the objects that were imported
def saved_objects
TimeEntry.where(:id => saved_items.pluck(:obj_id)).order(:id).preload(:activity, :project, :issue => [:tracker, :priority, :status])
end
def mappable_custom_fields
TimeEntryCustomField.all
end
def allowed_target_projects
Project.allowed_to(user, :log_time).order(:lft)
end
def allowed_target_activities
project.activities
end
def allowed_target_users
users = []
if project
users = project.members.active.preload(:user)
users = users.map(&:user).select{ |u| u.allowed_to?(:log_time, project) }
end
users << User.current if User.current.logged? && !users.include?(User.current)
users
end
def project
project_id = mapping['project_id'].to_i
allowed_target_projects.find_by_id(project_id) || allowed_target_projects.first
end
def activity
if mapping['activity'].to_s =~ /\Avalue:(\d+)\z/
activity_id = $1.to_i
allowed_target_activities.find_by_id(activity_id)
end
end
def user_value
if mapping['user_id'].to_s =~ /\Avalue:(\d+)\z/
$1.to_i
end
end
private
def build_object(row, item)
object = TimeEntry.new
object.author = user
activity_id = nil
if activity
activity_id = activity.id
elsif activity_name = row_value(row, 'activity')
activity_id = allowed_target_activities.named(activity_name).first.try(:id)
end
user_id = nil
if user.allowed_to?(:log_time_for_other_users, project)
if user_value
user_id = user_value
elsif user_name = row_value(row, 'user_id')
user_id = Principal.detect_by_keyword(allowed_target_users, user_name).try(:id)
end
else
user_id = user.id
end
attributes = {
:project_id => project.id,
:activity_id => activity_id,
:author_id => user.id,
:user_id => user_id,
:issue_id => row_value(row, 'issue_id'),
:spent_on => row_date(row, 'spent_on'),
:hours => row_value(row, 'hours'),
:comments => row_value(row, 'comments')
}
attributes['custom_field_values'] = object.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, object)
end
h
end
object.send(:safe_attributes=, attributes, user)
object
end
end

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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
@ -23,12 +25,16 @@ class TimeEntryQuery < Query
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),
TimestampQueryColumn.new(:created_on, :sortable => "#{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(:author, :sortable => lambda {User.fields_for_order_statement}),
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"),
QueryAssociationColumn.new(:issue, :category, :caption => :field_category, :sortable => "#{IssueCategory.table_name}.name"),
QueryAssociationColumn.new(:issue, :fixed_version, :caption => :field_fixed_version, :sortable => Version.fields_for_order_statement),
QueryColumn.new(:comments),
QueryColumn.new(:hours, :sortable => "#{TimeEntry.table_name}.hours", :totalable => true),
]
@ -40,39 +46,57 @@ class TimeEntryQuery < Query
def initialize_available_filters
add_available_filter "spent_on", :type => :date_past
add_available_filter("project_id",
add_available_filter(
"project_id",
:type => :list, :values => lambda { project_values }
) if project.nil?
if project && !project.leaf?
add_available_filter "subproject_id",
add_available_filter(
"subproject_id",
:type => :list_subprojects,
:values => lambda { subproject_values }
:values => lambda { subproject_values })
end
add_available_filter("issue_id", :type => :tree, :label => :label_issue)
add_available_filter("issue.tracker_id",
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",
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",
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",
add_available_filter(
"issue.category_id",
:type => :list_optional,
:name => l("label_attribute_of_issue", :name => l(:field_category)),
:values => lambda { project.issue_categories.collect{|s| [s.name, s.id.to_s] } }
) if project
add_available_filter(
"user_id",
:type => :list_optional, :values => lambda { author_values }
)
add_available_filter(
"author_id",
:type => :list_optional, :values => lambda { author_values }
)
activities = (project ? project.activities : TimeEntryActivity.shared)
add_available_filter("activity_id",
add_available_filter(
"activity_id",
:type => :list, :values => activities.map {|a| [a.name, a.id.to_s]}
)
add_available_filter(
"project.status",
:type => :list,
:name => l(:label_attribute_of_project, :name => l(:field_status)),
:values => lambda { project_statuses_values }
) if project.nil? || !project.leaf?
add_available_filter "comments", :type => :text
add_available_filter "hours", :type => :float
@ -97,14 +121,13 @@ class TimeEntryQuery < Query
def default_columns_names
@default_columns_names ||= begin
default_columns = [:spent_on, :user, :activity, :issue, :comments, :hours]
default_columns = Setting.time_entry_list_defaults.symbolize_keys[:column_names].map(&:to_sym)
project.present? ? default_columns : [:project] | default_columns
end
end
def default_totalable_names
[:hours]
Setting.time_entry_list_defaults.symbolize_keys[:totalable_names].map(&:to_sym)
end
def default_sort_criteria
@ -130,6 +153,7 @@ class TimeEntryQuery < Query
def results_scope(options={})
order_option = [group_by_sort_order, (options[:order] || sort_clause)].flatten.reject(&:blank?)
order_option << "#{TimeEntry.table_name}.id ASC"
base_scope.
order(order_option).
joins(joins_for_order_statement(order_option.join(',')))
@ -177,8 +201,6 @@ class TimeEntryQuery < Query
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 == '='
@ -196,8 +218,16 @@ class TimeEntryQuery < Query
sql_for_field("status_id", operator, value, Issue.table_name, "status_id")
end
def sql_for_issue_category_id_field(field, operator, value)
sql_for_field("category_id", operator, value, Issue.table_name, "category_id")
end
def sql_for_project_status_field(field, operator, value, options={})
sql_for_field(field, operator, value, Project.table_name, "status")
end
# Accepts :from/:to params as shortcut filters
def build_from_params(params)
def build_from_params(params, defaults={})
super
if params[:from].present? && params[:to].present?
add_filter('spent_on', '><', [params[:from], params[:to]])
@ -219,6 +249,12 @@ class TimeEntryQuery < Query
if order_options.include?('trackers')
joins << "LEFT OUTER JOIN #{Tracker.table_name} ON #{Tracker.table_name}.id = #{Issue.table_name}.tracker_id"
end
if order_options.include?('issue_categories')
joins << "LEFT OUTER JOIN #{IssueCategory.table_name} ON #{IssueCategory.table_name}.id = #{Issue.table_name}.category_id"
end
if order_options.include?('versions')
joins << "LEFT OUTER JOIN #{Version.table_name} ON #{Version.table_name}.id = #{Issue.table_name}.fixed_version_id"
end
end
joins.compact!

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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
@ -18,7 +20,6 @@
class Token < ActiveRecord::Base
belongs_to :user
validates_uniqueness_of :value
attr_protected :id
before_create :delete_previous_tokens, :generate_new_token
@ -111,9 +112,9 @@ class Token < ActiveRecord::Base
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
return nil unless action.present? && /\A[a-z0-9]+\z/i.match?(key)
token = Token.where(:action => action, :value => key).first
token = Token.find_by(:action => action, :value => key)
if token && (token.action == action) && (token.value == key) && token.user
if validity_days.nil? || (token.created_on > validity_days.days.ago)
token

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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
@ -27,22 +29,16 @@ class Tracker < ActiveRecord::Base
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_many :workflow_rules, :dependent => :delete_all
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
validates_length_of :description, :maximum => 255
scope :sorted, lambda { order(:position) }
scope :named, lambda {|arg| where("LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip)}
@ -70,13 +66,15 @@ class Tracker < ActiveRecord::Base
joins(:projects).where(condition).distinct
}
safe_attributes 'name',
safe_attributes(
'name',
'default_status_id',
'is_in_roadmap',
'core_fields',
'position',
'custom_field_ids',
'project_ids'
'project_ids',
'description')
def to_s; name end
@ -143,8 +141,9 @@ class Tracker < ActiveRecord::Base
end
end
private
private
def check_integrity
raise Exception.new("Cannot delete tracker") if Issue.where(:tracker_id => self.id).any?
raise "Cannot delete tracker" if Issue.where(:tracker_id => self.id).any?
end
end

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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
@ -99,9 +101,6 @@ class User < Principal
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
@ -113,6 +112,9 @@ class User < Principal
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
Setting::PASSWORD_CHAR_CLASSES.each do |k, v|
validates_format_of :password, :with => v, :message => :"must_contain_#{k}", :allow_blank => true, :if => Proc.new {Setting.password_required_char_classes.include?(k)}
end
validate :validate_password_length
validate do
if password_confirmation && password != password_confirmation
@ -242,7 +244,7 @@ class User < Principal
end
end
end
user.update_column(:last_login_on, Time.now) if user && !user.new_record? && user.active?
user.update_last_login_on! if user && !user.new_record? && user.active?
user
rescue => text
raise text
@ -252,7 +254,7 @@ class User < Principal
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.update_last_login_on!
user
end
end
@ -318,6 +320,12 @@ class User < Principal
update_attribute(:status, STATUS_LOCKED)
end
def update_last_login_on!
return if last_login_on.present? && last_login_on >= 1.minute.ago
update_column(:last_login_on, Time.now)
end
# Returns true if +clear_password+ is the correct user's password, otherwise false
def check_password?(clear_password)
if auth_source_id.present?
@ -337,8 +345,7 @@ class User < Principal
# 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?
auth_source.nil? ? true : auth_source.allow_password_changes?
end
# Returns true if the user password has expired
@ -357,15 +364,27 @@ class User < Principal
end
def generate_password?
generate_password == '1' || generate_password == true
ActiveRecord::Type::Boolean.new.deserialize(generate_password)
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)] }
chars_list = [('A'..'Z').to_a, ('a'..'z').to_a, ('0'..'9').to_a]
# auto-generated passwords contain special characters only when admins
# require users to use passwords which contains special characters
if Setting.password_required_char_classes.include?('special_chars')
chars_list << ("\x20".."\x7e").to_a.select {|c| c =~ Setting::PASSWORD_CHAR_CLASSES['special_chars']}
end
chars_list.each {|v| v.reject! {|c| %(0O1l|'"`*).include?(c)}}
password = +''
chars_list.each do |chars|
password << chars[SecureRandom.random_number(chars.size)]
length -= 1
end
chars = chars_list.flatten
length.times { password << chars[SecureRandom.random_number(chars.size)] }
password = password.split('').shuffle(random: SecureRandom).join
self.password = password
self.password_confirmation = password
self
@ -489,7 +508,7 @@ class User < Principal
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
user = find_by("LOWER(login) = ?", login.downcase)
end
user
end
@ -517,7 +536,7 @@ class User < Principal
name
end
CSS_CLASS_BY_STATUS = {
LABEL_BY_STATUS = {
STATUS_ANONYMOUS => 'anon',
STATUS_ACTIVE => 'active',
STATUS_REGISTERED => 'registered',
@ -525,7 +544,7 @@ class User < Principal
}
def css_classes
"user #{CSS_CLASS_BY_STATUS[status]}"
"user #{LABEL_BY_STATUS[status]}"
end
# Returns the current day according to user's time zone
@ -539,10 +558,14 @@ class User < Principal
# Returns the day of +time+ according to user's time zone
def time_to_date(time)
if time_zone.nil?
time.to_date
self.convert_time_to_user_timezone(time).to_date
end
def convert_time_to_user_timezone(time)
if self.time_zone
time.in_time_zone(self.time_zone)
else
time.in_time_zone(time_zone).to_date
time.utc? ? time.localtime : time
end
end
@ -607,24 +630,24 @@ class User < Principal
# 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
@ -732,7 +755,8 @@ class User < Principal
(!admin? || User.active.admin.where("id <> ?", id).exists?)
end
safe_attributes 'firstname',
safe_attributes(
'firstname',
'lastname',
'mail',
'mail_notification',
@ -740,21 +764,21 @@ class User < Principal
'language',
'custom_field_values',
'custom_fields',
'identity_url'
safe_attributes 'login',
:if => lambda {|user, current_user| user.new_record?}
safe_attributes 'status',
'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?}
: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.
@ -771,9 +795,9 @@ class User < Principal
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)
object.author == self || is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.previous_assignee)
when 'only_assigned'
is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.assigned_to_was)
is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.previous_assignee)
when 'only_owner'
object.author == self
end
@ -795,7 +819,7 @@ class User < Principal
# 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
anonymous_user = AnonymousUser.unscoped.find_by(:lastname => 'Anonymous')
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?
@ -817,6 +841,13 @@ class User < Principal
end
end
def bookmarked_project_ids
project_ids = []
bookmarked_project_ids = self.pref[:bookmarked_project_ids]
project_ids = bookmarked_project_ids.split(',') unless bookmarked_project_ids.nil?
project_ids.map(&:to_i)
end
protected
def validate_password_length
@ -845,7 +876,7 @@ class User < Principal
# 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?)
if saved_change_to_hashed_password? || (saved_change_to_status? && !active?)
tokens = ['recovery', 'autologin', 'session']
Token.where(:user_id => id, :action => tokens).delete_all
end
@ -880,14 +911,17 @@ class User < Principal
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
# Singleton class method is public
class << self
# Return password digest
def 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)
# Returns a 128bits random salt as a hex string (32 chars long)
def generate_salt
Redmine::Utils.random_hex(16)
end
end
# Send a security notification to all admins if the user has gained/lost admin privileges
@ -900,16 +934,16 @@ class User < Principal
}
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
if (admin? && saved_change_to_id? && active?) || # newly created admin
(admin? && saved_change_to_admin? && active?) || # regular user became admin
(admin? && saved_change_to_status? && 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
(!admin? && saved_change_to_admin? && active?) || # admin is no longer admin
(admin? && saved_change_to_status? && !active?) # admin was locked
deliver = true
options[:message] = :mail_body_security_notification_remove
@ -917,7 +951,7 @@ class User < Principal
if deliver
users = User.active.where(admin: true).to_a
Mailer.security_notification(users, options).deliver
Mailer.deliver_security_notification(users, User.current, options)
end
end
end

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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
@ -20,4 +22,3 @@ class UserCustomField < CustomField
:label_user_plural
end
end

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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
@ -15,22 +17,25 @@
# 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/my_page'
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',
safe_attributes(
'hide_mail',
'time_zone',
'comments_sorting',
'warn_on_leaving_unsaved',
'no_self_notified',
'textarea_font'
'textarea_font',
'recently_used_projects',
'history_default_tab')
TEXTAREA_FONT_OPTIONS = ['monospace', 'proportional']
@ -88,6 +93,11 @@ class UserPreference < ActiveRecord::Base
def textarea_font; self[:textarea_font] end
def textarea_font=(value); self[:textarea_font]=value; end
def recently_used_projects; (self[:recently_used_projects] || 3).to_i; end
def recently_used_projects=(value); self[:recently_used_projects] = value.to_i; end
def history_default_tab; self[:history_default_tab]; end
def history_default_tab=(value); self[:history_default_tab]=value; end
# Returns the names of groups that are displayed on user's page
# Example:
# preferences.my_page_groups
@ -122,7 +132,7 @@ class UserPreference < ActiveRecord::Base
# preferences.remove_block('news')
def remove_block(block)
block = block.to_s.underscore
my_page_layout.keys.each do |group|
my_page_layout.each_key do |group|
my_page_layout[group].delete(block)
end
my_page_layout

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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
@ -15,6 +17,97 @@
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
module FixedIssuesExtension
# Returns the total estimated time for this version
# (sum of leaves estimated_hours)
def estimated_hours
@estimated_hours ||= sum(:estimated_hours).to_f
end
# Returns the total amount of open issues for this version.
def open_count
load_counts
@open_count
end
# Returns the total amount of closed issues for this version.
def closed_count
load_counts
@closed_count
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 count == 0
0
elsif open_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 count == 0
0
else
issues_progress(false)
end
end
private
def load_counts
unless @open_count
@open_count = 0
@closed_count = 0
self.group(:status).count.each do |status, count|
if status.is_closed?
@closed_count += count
else
@open_count += count
end
end
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 = 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 count > 0
ratio = open ? 'done_ratio' : 100
done = open(open).sum("COALESCE(estimated_hours, #{estimated_average}) * #{ratio}").to_f
progress = done / (estimated_average * count)
end
progress
end
end
end
class Version < ActiveRecord::Base
include Redmine::SafeAttributes
@ -23,7 +116,8 @@ class Version < ActiveRecord::Base
before_destroy :nullify_projects_default_version
belongs_to :project
has_many :fixed_issues, :class_name => 'Issue', :foreign_key => 'fixed_version_id', :dependent => :nullify
has_many :fixed_issues, :class_name => 'Issue', :foreign_key => 'fixed_version_id', :dependent => :nullify, :extend => FixedIssuesExtension
acts_as_customizable
acts_as_attachable :view_permission => :view_files,
:edit_permission => :manage_files,
@ -39,7 +133,6 @@ class Version < ActiveRecord::Base
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|
@ -60,21 +153,56 @@ class Version < ActiveRecord::Base
}
safe_attributes 'name',
'description',
'effective_date',
'due_date',
'wiki_page_title',
'status',
'sharing',
'default_project_version',
'custom_field_values',
'custom_fields'
'description',
'effective_date',
'due_date',
'wiki_page_title',
'status',
'sharing',
'default_project_version',
'custom_field_values',
'custom_fields'
def safe_attributes=(attrs, user=User.current)
if attrs.respond_to?(:to_unsafe_hash)
attrs = attrs.to_unsafe_hash
end
return unless attrs.is_a?(Hash)
attrs = attrs.deep_dup
# Reject custom fields values not visible by the user
if attrs['custom_field_values'].present?
editable_custom_field_ids = editable_custom_field_values(user).map {|v| v.custom_field_id.to_s}
attrs['custom_field_values'].reject! {|k, v| !editable_custom_field_ids.include?(k.to_s)}
end
# Reject custom fields not visible by the user
if attrs['custom_fields'].present?
editable_custom_field_ids = editable_custom_field_values(user).map {|v| v.custom_field_id.to_s}
attrs['custom_fields'].reject! {|c| !editable_custom_field_ids.include?(c['id'].to_s)}
end
super(attrs, user)
end
# 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
# Returns the custom_field_values that can be edited by the given user
def editable_custom_field_values(user=nil)
visible_custom_field_values(user)
end
def visible_custom_field_values(user = nil)
user ||= User.current
custom_field_values.select do |value|
value.custom_field.visible_by?(project, user)
end
end
# Version files have same visibility as project files
def attachments_visible?(*args)
project.present? && project.attachments_visible?(*args)
@ -87,6 +215,7 @@ class Version < ActiveRecord::Base
alias :base_reload :reload
def reload(*args)
@default_project_version = nil
@visible_fixed_issues = nil
base_reload(*args)
end
@ -105,7 +234,7 @@ class Version < ActiveRecord::Base
# 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
fixed_issues.estimated_hours
end
# Returns the total reported time for this version
@ -140,22 +269,12 @@ class Version < ActiveRecord::Base
# 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
fixed_issues.completed_percent
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
fixed_issues.closed_percent
end
# Returns true if the version is overdue: due date reached and some open issues
@ -165,20 +284,21 @@ class Version < ActiveRecord::Base
# Returns assigned issues count
def issues_count
load_issue_counts
@issue_count
fixed_issues.count
end
# Returns the total amount of open issues for this version.
def open_issues_count
load_issue_counts
@open_issues_count
fixed_issues.open_count
end
# Returns the total amount of closed issues for this version.
def closed_issues_count
load_issue_counts
@closed_issues_count
fixed_issues.closed_count
end
def visible_fixed_issues
@visible_fixed_issues ||= fixed_issues.visible
end
def wiki_page
@ -236,7 +356,7 @@ class Version < ActiveRecord::Base
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"]
[Arel.sql("(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) }
@ -285,27 +405,12 @@ class Version < ActiveRecord::Base
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? ||
if saved_change_to_sharing?
if VERSION_SHARINGS.index(sharing_before_last_save).nil? ||
VERSION_SHARINGS.index(sharing).nil? ||
VERSION_SHARINGS.index(sharing_was) > VERSION_SHARINGS.index(sharing)
VERSION_SHARINGS.index(sharing_before_last_save) > VERSION_SHARINGS.index(sharing)
Issue.update_versions_from_sharing_change self
end
end
@ -317,40 +422,6 @@ class Version < ActiveRecord::Base
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?

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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
@ -19,4 +21,8 @@ class VersionCustomField < CustomField
def type_name
:label_version_plural
end
def visible_by?(project, user=User.current)
super || (roles & user.roles_for_project(project)).present?
end
end

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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
@ -22,7 +24,6 @@ class Watcher < ActiveRecord::Base
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)
@ -56,8 +57,6 @@ class Watcher < ActiveRecord::Base
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
@ -78,4 +77,5 @@ class Watcher < ActiveRecord::Base
end
pruned
end
private_class_method :prune_single_user
end

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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
@ -26,7 +28,6 @@ class Wiki < ActiveRecord::Base
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
@ -54,7 +55,7 @@ class Wiki < ActiveRecord::Base
@page_found_with_redirect = false
title = start_page if title.blank?
title = Wiki.titleize(title)
page = pages.where("LOWER(title) = LOWER(?)", title).first
page = pages.find_by("LOWER(title) = LOWER(?)", title)
if page.nil? && options[:with_redirect] != false
# search for a redirect
redirect = redirects.where("LOWER(title) = LOWER(?)", title).first

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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
@ -21,16 +23,23 @@ class WikiContent < ActiveRecord::Base
self.locking_column = 'version'
belongs_to :page, :class_name => 'WikiPage'
belongs_to :author, :class_name => 'User'
has_many :versions, :class_name => 'WikiContentVersion', :dependent => :delete_all
validates_presence_of :text
validates_length_of :comments, :maximum => 1024, :allow_nil => true
attr_protected :id
acts_as_versioned
after_save :send_notification
after_save :create_version
after_create_commit :send_notification_create
after_update_commit :send_notification_update
scope :without_text, lambda {select(:id, :page_id, :version, :updated_on)}
def initialize(*args)
super
if new_record?
self.version = 1
end
end
def visible?(user=User.current)
page.visible?(user)
end
@ -57,118 +66,40 @@ class WikiContent < ActiveRecord::Base
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
# Reverts the record to a previous version
def revert_to!(version)
if version.wiki_content_id == id
update_columns(
:author_id => version.author_id,
:text => version.text,
:comments => version.comments,
:version => version.version,
:updated_on => version.updated_on
) && reload
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
def create_version
versions << WikiContentVersion.new(attributes.except("id"))
end
# Notifies users that a wiki page was created
def send_notification_create
if Setting.notified_events.include?('wiki_content_added')
Mailer.deliver_wiki_content_added(self)
end
end
# Notifies users that a wiki page was updated
def send_notification_update
if Setting.notified_events.include?('wiki_content_updated') && saved_change_to_text?
Mailer.deliver_wiki_content_updated(self)
end
end
# For backward compatibility
# TODO: remove it in Redmine 5
Version = WikiContentVersion
end

View file

@ -0,0 +1,119 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2019 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 WikiContentVersion < ActiveRecord::Base
belongs_to :page, :class_name => 'WikiPage'
belongs_to :author, :class_name => 'User'
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 => "#{table_name}.updated_on",
:author_key => "#{table_name}.author_id",
:permission => :view_wiki_edits,
:scope => select("#{table_name}.updated_on, #{table_name}.comments, " +
"#{table_name}.version, #{WikiPage.table_name}.title, " +
"#{table_name}.page_id, #{table_name}.author_id, " +
"#{table_name}.id").
joins("LEFT JOIN #{WikiPage.table_name} ON #{WikiPage.table_name}.id = #{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 ||= WikiContentVersion.
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 ||= WikiContentVersion.
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(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

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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
@ -16,7 +18,6 @@
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
require 'diff'
require 'enumerator'
class WikiPage < ActiveRecord::Base
include Redmine::SafeAttributes
@ -41,18 +42,18 @@ class WikiPage < ActiveRecord::Base
:project_key => "#{Wiki.table_name}.project_id"
attr_accessor :redirect_existing_links
attr_writer :deleted_attachment_ids
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
before_save :handle_rename_or_move, :update_wiki_start_page
after_save :handle_children_move, :delete_selected_attachments
# eager load information about last updates, without loading text
scope :with_updated_on, lambda { preload(:content_without_text) }
@ -61,7 +62,13 @@ class WikiPage < ActiveRecord::Base
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)}
:if => lambda {|page, user| page.new_record? || user.allowed_to?(:rename_wiki_pages, page.project)}
safe_attributes 'is_start_page',
:if => lambda {|page, user| user.allowed_to?(:manage_wiki, page.project)}
safe_attributes 'deleted_attachment_ids',
:if => lambda {|page, user| page.attachments_deletable?(user)}
def initialize(attributes=nil, *args)
super
@ -80,6 +87,10 @@ class WikiPage < ActiveRecord::Base
end
def safe_attributes=(attrs, user=User.current)
if attrs.respond_to?(:to_unsafe_hash)
attrs = attrs.to_unsafe_hash
end
return unless attrs.is_a?(Hash)
attrs = attrs.deep_dup
@ -122,7 +133,7 @@ class WikiPage < ActiveRecord::Base
# Moves child pages if page was moved
def handle_children_move
if !new_record? && wiki_id_changed?
if !new_record? && saved_change_to_wiki_id?
children.each do |child|
child.wiki_id = wiki_id
child.redirect_existing_links = redirect_existing_links
@ -209,6 +220,24 @@ class WikiPage < ActiveRecord::Base
self.parent = parent_page
end
def is_start_page
if @is_start_page.nil?
@is_start_page = wiki.try(:start_page) == title_was
end
@is_start_page
end
def is_start_page=(arg)
@is_start_page = arg == '1' || arg == true
end
def update_wiki_start_page
if is_start_page
wiki.update_attribute :start_page, title
end
end
private :update_wiki_start_page
# Saves the page and its content if text was changed
# Return true if the page was saved
def save_with_content(content)
@ -218,7 +247,6 @@ class WikiPage < ActiveRecord::Base
if content.text_changed?
begin
self.content = content
ret = ret && content.changed?
rescue ActiveRecord::RecordNotSaved
ret = false
end
@ -228,6 +256,17 @@ class WikiPage < ActiveRecord::Base
ret
end
def deleted_attachment_ids
Array(@deleted_attachment_ids).map(&:to_i)
end
def delete_selected_attachments
if deleted_attachment_ids.present?
objects = attachments.where(:id => deleted_attachment_ids.map(&:to_i))
attachments.delete(objects)
end
end
protected
def validate_parent_title
@ -265,7 +304,7 @@ class WikiAnnotate
@lines = current_lines.collect {|t| [nil, nil, t]}
positions = []
current_lines.size.times {|i| positions << i}
while (current.previous)
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]

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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
@ -20,7 +22,6 @@ class WikiRedirect < ActiveRecord::Base
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

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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
@ -62,7 +64,7 @@ class WorkflowPermission < WorkflowRule
protected
def validate_field_name
unless Tracker::CORE_FIELDS_ALL.include?(field_name) || field_name.to_s.match(/^\d+$/)
unless Tracker::CORE_FIELDS_ALL.include?(field_name) || /^\d+$/.match?(field_name.to_s)
errors.add :field_name, :invalid
end
end

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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
@ -24,7 +26,6 @@ class WorkflowRule < ActiveRecord::Base
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)
@ -41,9 +42,9 @@ class WorkflowRule < ActiveRecord::Base
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)
source_role || target_role,
target_tracker,
target_role)
end
end
end

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2006-2019 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