Redmine 4.1.1
This commit is contained in:
parent
33e7b881a5
commit
3d976f1b3b
1593 changed files with 36180 additions and 19489 deletions
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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).
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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" +
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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|
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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|
|
||||
|
|
|
@ -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'
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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).
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
112
app/models/project_query.rb
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 <= ?"
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
127
app/models/time_entry_import.rb
Normal file
127
app/models/time_entry_import.rb
Normal 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
|
|
@ -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!
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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?
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
119
app/models/wiki_content_version.rb
Normal file
119
app/models/wiki_content_version.rb
Normal 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
|
|
@ -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]
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue