##// END OF EJS Templates
Removed after_initialize methods....
Jean-Philippe Lang -
r8168:9e83ee2ddf3c
parent child
Show More
@@ -1,130 +1,131
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 require 'net/ldap'
18 require 'net/ldap'
19 require 'iconv'
19 require 'iconv'
20
20
21 class AuthSourceLdap < AuthSource
21 class AuthSourceLdap < AuthSource
22 validates_presence_of :host, :port, :attr_login
22 validates_presence_of :host, :port, :attr_login
23 validates_length_of :name, :host, :maximum => 60, :allow_nil => true
23 validates_length_of :name, :host, :maximum => 60, :allow_nil => true
24 validates_length_of :account, :account_password, :base_dn, :maximum => 255, :allow_nil => true
24 validates_length_of :account, :account_password, :base_dn, :maximum => 255, :allow_nil => true
25 validates_length_of :attr_login, :attr_firstname, :attr_lastname, :attr_mail, :maximum => 30, :allow_nil => true
25 validates_length_of :attr_login, :attr_firstname, :attr_lastname, :attr_mail, :maximum => 30, :allow_nil => true
26 validates_numericality_of :port, :only_integer => true
26 validates_numericality_of :port, :only_integer => true
27
27
28 before_validation :strip_ldap_attributes
28 before_validation :strip_ldap_attributes
29
29
30 def after_initialize
30 def initialize(attributes=nil, *args)
31 super
31 self.port = 389 if self.port == 0
32 self.port = 389 if self.port == 0
32 end
33 end
33
34
34 def authenticate(login, password)
35 def authenticate(login, password)
35 return nil if login.blank? || password.blank?
36 return nil if login.blank? || password.blank?
36 attrs = get_user_dn(login)
37 attrs = get_user_dn(login)
37
38
38 if attrs && attrs[:dn] && authenticate_dn(attrs[:dn], password)
39 if attrs && attrs[:dn] && authenticate_dn(attrs[:dn], password)
39 logger.debug "Authentication successful for '#{login}'" if logger && logger.debug?
40 logger.debug "Authentication successful for '#{login}'" if logger && logger.debug?
40 return attrs.except(:dn)
41 return attrs.except(:dn)
41 end
42 end
42 rescue Net::LDAP::LdapError => text
43 rescue Net::LDAP::LdapError => text
43 raise "LdapError: " + text
44 raise "LdapError: " + text
44 end
45 end
45
46
46 # test the connection to the LDAP
47 # test the connection to the LDAP
47 def test_connection
48 def test_connection
48 ldap_con = initialize_ldap_con(self.account, self.account_password)
49 ldap_con = initialize_ldap_con(self.account, self.account_password)
49 ldap_con.open { }
50 ldap_con.open { }
50 rescue Net::LDAP::LdapError => text
51 rescue Net::LDAP::LdapError => text
51 raise "LdapError: " + text
52 raise "LdapError: " + text
52 end
53 end
53
54
54 def auth_method_name
55 def auth_method_name
55 "LDAP"
56 "LDAP"
56 end
57 end
57
58
58 private
59 private
59
60
60 def strip_ldap_attributes
61 def strip_ldap_attributes
61 [:attr_login, :attr_firstname, :attr_lastname, :attr_mail].each do |attr|
62 [:attr_login, :attr_firstname, :attr_lastname, :attr_mail].each do |attr|
62 write_attribute(attr, read_attribute(attr).strip) unless read_attribute(attr).nil?
63 write_attribute(attr, read_attribute(attr).strip) unless read_attribute(attr).nil?
63 end
64 end
64 end
65 end
65
66
66 def initialize_ldap_con(ldap_user, ldap_password)
67 def initialize_ldap_con(ldap_user, ldap_password)
67 options = { :host => self.host,
68 options = { :host => self.host,
68 :port => self.port,
69 :port => self.port,
69 :encryption => (self.tls ? :simple_tls : nil)
70 :encryption => (self.tls ? :simple_tls : nil)
70 }
71 }
71 options.merge!(:auth => { :method => :simple, :username => ldap_user, :password => ldap_password }) unless ldap_user.blank? && ldap_password.blank?
72 options.merge!(:auth => { :method => :simple, :username => ldap_user, :password => ldap_password }) unless ldap_user.blank? && ldap_password.blank?
72 Net::LDAP.new options
73 Net::LDAP.new options
73 end
74 end
74
75
75 def get_user_attributes_from_ldap_entry(entry)
76 def get_user_attributes_from_ldap_entry(entry)
76 {
77 {
77 :dn => entry.dn,
78 :dn => entry.dn,
78 :firstname => AuthSourceLdap.get_attr(entry, self.attr_firstname),
79 :firstname => AuthSourceLdap.get_attr(entry, self.attr_firstname),
79 :lastname => AuthSourceLdap.get_attr(entry, self.attr_lastname),
80 :lastname => AuthSourceLdap.get_attr(entry, self.attr_lastname),
80 :mail => AuthSourceLdap.get_attr(entry, self.attr_mail),
81 :mail => AuthSourceLdap.get_attr(entry, self.attr_mail),
81 :auth_source_id => self.id
82 :auth_source_id => self.id
82 }
83 }
83 end
84 end
84
85
85 # Return the attributes needed for the LDAP search. It will only
86 # Return the attributes needed for the LDAP search. It will only
86 # include the user attributes if on-the-fly registration is enabled
87 # include the user attributes if on-the-fly registration is enabled
87 def search_attributes
88 def search_attributes
88 if onthefly_register?
89 if onthefly_register?
89 ['dn', self.attr_firstname, self.attr_lastname, self.attr_mail]
90 ['dn', self.attr_firstname, self.attr_lastname, self.attr_mail]
90 else
91 else
91 ['dn']
92 ['dn']
92 end
93 end
93 end
94 end
94
95
95 # Check if a DN (user record) authenticates with the password
96 # Check if a DN (user record) authenticates with the password
96 def authenticate_dn(dn, password)
97 def authenticate_dn(dn, password)
97 if dn.present? && password.present?
98 if dn.present? && password.present?
98 initialize_ldap_con(dn, password).bind
99 initialize_ldap_con(dn, password).bind
99 end
100 end
100 end
101 end
101
102
102 # Get the user's dn and any attributes for them, given their login
103 # Get the user's dn and any attributes for them, given their login
103 def get_user_dn(login)
104 def get_user_dn(login)
104 ldap_con = initialize_ldap_con(self.account, self.account_password)
105 ldap_con = initialize_ldap_con(self.account, self.account_password)
105 login_filter = Net::LDAP::Filter.eq( self.attr_login, login )
106 login_filter = Net::LDAP::Filter.eq( self.attr_login, login )
106 object_filter = Net::LDAP::Filter.eq( "objectClass", "*" )
107 object_filter = Net::LDAP::Filter.eq( "objectClass", "*" )
107 attrs = {}
108 attrs = {}
108
109
109 ldap_con.search( :base => self.base_dn,
110 ldap_con.search( :base => self.base_dn,
110 :filter => object_filter & login_filter,
111 :filter => object_filter & login_filter,
111 :attributes=> search_attributes) do |entry|
112 :attributes=> search_attributes) do |entry|
112
113
113 if onthefly_register?
114 if onthefly_register?
114 attrs = get_user_attributes_from_ldap_entry(entry)
115 attrs = get_user_attributes_from_ldap_entry(entry)
115 else
116 else
116 attrs = {:dn => entry.dn}
117 attrs = {:dn => entry.dn}
117 end
118 end
118
119
119 logger.debug "DN found for #{login}: #{attrs[:dn]}" if logger && logger.debug?
120 logger.debug "DN found for #{login}: #{attrs[:dn]}" if logger && logger.debug?
120 end
121 end
121
122
122 attrs
123 attrs
123 end
124 end
124
125
125 def self.get_attr(entry, attr_name)
126 def self.get_attr(entry, attr_name)
126 if !attr_name.blank?
127 if !attr_name.blank?
127 entry[attr_name].is_a?(Array) ? entry[attr_name].first : entry[attr_name]
128 entry[attr_name].is_a?(Array) ? entry[attr_name].first : entry[attr_name]
128 end
129 end
129 end
130 end
130 end
131 end
@@ -1,73 +1,74
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class CustomValue < ActiveRecord::Base
18 class CustomValue < ActiveRecord::Base
19 belongs_to :custom_field
19 belongs_to :custom_field
20 belongs_to :customized, :polymorphic => true
20 belongs_to :customized, :polymorphic => true
21
21
22 validate :validate_custom_value
22 validate :validate_custom_value
23
23
24 def after_initialize
24 def initialize(attributes=nil, *args)
25 super
25 if new_record? && custom_field && (customized_type.blank? || (customized && customized.new_record?))
26 if new_record? && custom_field && (customized_type.blank? || (customized && customized.new_record?))
26 self.value ||= custom_field.default_value
27 self.value ||= custom_field.default_value
27 end
28 end
28 end
29 end
29
30
30 # Returns true if the boolean custom value is true
31 # Returns true if the boolean custom value is true
31 def true?
32 def true?
32 self.value == '1'
33 self.value == '1'
33 end
34 end
34
35
35 def editable?
36 def editable?
36 custom_field.editable?
37 custom_field.editable?
37 end
38 end
38
39
39 def visible?
40 def visible?
40 custom_field.visible?
41 custom_field.visible?
41 end
42 end
42
43
43 def required?
44 def required?
44 custom_field.is_required?
45 custom_field.is_required?
45 end
46 end
46
47
47 def to_s
48 def to_s
48 value.to_s
49 value.to_s
49 end
50 end
50
51
51 protected
52 protected
52 def validate_custom_value
53 def validate_custom_value
53 if value.blank?
54 if value.blank?
54 errors.add(:value, :blank) if custom_field.is_required? and value.blank?
55 errors.add(:value, :blank) if custom_field.is_required? and value.blank?
55 else
56 else
56 errors.add(:value, :invalid) unless custom_field.regexp.blank? or value =~ Regexp.new(custom_field.regexp)
57 errors.add(:value, :invalid) unless custom_field.regexp.blank? or value =~ Regexp.new(custom_field.regexp)
57 errors.add(:value, :too_short, :count => custom_field.min_length) if custom_field.min_length > 0 and value.length < custom_field.min_length
58 errors.add(:value, :too_short, :count => custom_field.min_length) if custom_field.min_length > 0 and value.length < custom_field.min_length
58 errors.add(:value, :too_long, :count => custom_field.max_length) if custom_field.max_length > 0 and value.length > custom_field.max_length
59 errors.add(:value, :too_long, :count => custom_field.max_length) if custom_field.max_length > 0 and value.length > custom_field.max_length
59
60
60 # Format specific validations
61 # Format specific validations
61 case custom_field.field_format
62 case custom_field.field_format
62 when 'int'
63 when 'int'
63 errors.add(:value, :not_a_number) unless value =~ /^[+-]?\d+$/
64 errors.add(:value, :not_a_number) unless value =~ /^[+-]?\d+$/
64 when 'float'
65 when 'float'
65 begin; Kernel.Float(value); rescue; errors.add(:value, :invalid) end
66 begin; Kernel.Float(value); rescue; errors.add(:value, :invalid) end
66 when 'date'
67 when 'date'
67 errors.add(:value, :not_a_date) unless value =~ /^\d{4}-\d{2}-\d{2}$/ && begin; value.to_date; rescue; false end
68 errors.add(:value, :not_a_date) unless value =~ /^\d{4}-\d{2}-\d{2}$/ && begin; value.to_date; rescue; false end
68 when 'list'
69 when 'list'
69 errors.add(:value, :inclusion) unless custom_field.possible_values.include?(value)
70 errors.add(:value, :inclusion) unless custom_field.possible_values.include?(value)
70 end
71 end
71 end
72 end
72 end
73 end
73 end
74 end
@@ -1,52 +1,53
1 # RedMine - project management software
1 # RedMine - project management software
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class Document < ActiveRecord::Base
18 class Document < ActiveRecord::Base
19 belongs_to :project
19 belongs_to :project
20 belongs_to :category, :class_name => "DocumentCategory", :foreign_key => "category_id"
20 belongs_to :category, :class_name => "DocumentCategory", :foreign_key => "category_id"
21 acts_as_attachable :delete_permission => :manage_documents
21 acts_as_attachable :delete_permission => :manage_documents
22
22
23 acts_as_searchable :columns => ['title', "#{table_name}.description"], :include => :project
23 acts_as_searchable :columns => ['title', "#{table_name}.description"], :include => :project
24 acts_as_event :title => Proc.new {|o| "#{l(:label_document)}: #{o.title}"},
24 acts_as_event :title => Proc.new {|o| "#{l(:label_document)}: #{o.title}"},
25 :author => Proc.new {|o| (a = o.attachments.find(:first, :order => "#{Attachment.table_name}.created_on ASC")) ? a.author : nil },
25 :author => Proc.new {|o| (a = o.attachments.find(:first, :order => "#{Attachment.table_name}.created_on ASC")) ? a.author : nil },
26 :url => Proc.new {|o| {:controller => 'documents', :action => 'show', :id => o.id}}
26 :url => Proc.new {|o| {:controller => 'documents', :action => 'show', :id => o.id}}
27 acts_as_activity_provider :find_options => {:include => :project}
27 acts_as_activity_provider :find_options => {:include => :project}
28
28
29 validates_presence_of :project, :title, :category
29 validates_presence_of :project, :title, :category
30 validates_length_of :title, :maximum => 60
30 validates_length_of :title, :maximum => 60
31
31
32 named_scope :visible, lambda {|*args| { :include => :project,
32 named_scope :visible, lambda {|*args| { :include => :project,
33 :conditions => Project.allowed_to_condition(args.shift || User.current, :view_documents, *args) } }
33 :conditions => Project.allowed_to_condition(args.shift || User.current, :view_documents, *args) } }
34
34
35 def visible?(user=User.current)
35 def visible?(user=User.current)
36 !user.nil? && user.allowed_to?(:view_documents, project)
36 !user.nil? && user.allowed_to?(:view_documents, project)
37 end
37 end
38
38
39 def after_initialize
39 def initialize(attributes=nil, *args)
40 super
40 if new_record?
41 if new_record?
41 self.category ||= DocumentCategory.default
42 self.category ||= DocumentCategory.default
42 end
43 end
43 end
44 end
44
45
45 def updated_on
46 def updated_on
46 unless @updated_on
47 unless @updated_on
47 a = attachments.find(:first, :order => 'created_on DESC')
48 a = attachments.find(:first, :order => 'created_on DESC')
48 @updated_on = (a && a.created_on) || created_on
49 @updated_on = (a && a.created_on) || created_on
49 end
50 end
50 @updated_on
51 @updated_on
51 end
52 end
52 end
53 end
@@ -1,991 +1,992
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class Issue < ActiveRecord::Base
18 class Issue < ActiveRecord::Base
19 include Redmine::SafeAttributes
19 include Redmine::SafeAttributes
20
20
21 belongs_to :project
21 belongs_to :project
22 belongs_to :tracker
22 belongs_to :tracker
23 belongs_to :status, :class_name => 'IssueStatus', :foreign_key => 'status_id'
23 belongs_to :status, :class_name => 'IssueStatus', :foreign_key => 'status_id'
24 belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
24 belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
25 belongs_to :assigned_to, :class_name => 'Principal', :foreign_key => 'assigned_to_id'
25 belongs_to :assigned_to, :class_name => 'Principal', :foreign_key => 'assigned_to_id'
26 belongs_to :fixed_version, :class_name => 'Version', :foreign_key => 'fixed_version_id'
26 belongs_to :fixed_version, :class_name => 'Version', :foreign_key => 'fixed_version_id'
27 belongs_to :priority, :class_name => 'IssuePriority', :foreign_key => 'priority_id'
27 belongs_to :priority, :class_name => 'IssuePriority', :foreign_key => 'priority_id'
28 belongs_to :category, :class_name => 'IssueCategory', :foreign_key => 'category_id'
28 belongs_to :category, :class_name => 'IssueCategory', :foreign_key => 'category_id'
29
29
30 has_many :journals, :as => :journalized, :dependent => :destroy
30 has_many :journals, :as => :journalized, :dependent => :destroy
31 has_many :time_entries, :dependent => :delete_all
31 has_many :time_entries, :dependent => :delete_all
32 has_and_belongs_to_many :changesets, :order => "#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC"
32 has_and_belongs_to_many :changesets, :order => "#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC"
33
33
34 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
34 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
35 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
35 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
36
36
37 acts_as_nested_set :scope => 'root_id', :dependent => :destroy
37 acts_as_nested_set :scope => 'root_id', :dependent => :destroy
38 acts_as_attachable :after_add => :attachment_added, :after_remove => :attachment_removed
38 acts_as_attachable :after_add => :attachment_added, :after_remove => :attachment_removed
39 acts_as_customizable
39 acts_as_customizable
40 acts_as_watchable
40 acts_as_watchable
41 acts_as_searchable :columns => ['subject', "#{table_name}.description", "#{Journal.table_name}.notes"],
41 acts_as_searchable :columns => ['subject', "#{table_name}.description", "#{Journal.table_name}.notes"],
42 :include => [:project, :journals],
42 :include => [:project, :journals],
43 # sort by id so that limited eager loading doesn't break with postgresql
43 # sort by id so that limited eager loading doesn't break with postgresql
44 :order_column => "#{table_name}.id"
44 :order_column => "#{table_name}.id"
45 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
45 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
46 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
46 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
47 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
47 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
48
48
49 acts_as_activity_provider :find_options => {:include => [:project, :author, :tracker]},
49 acts_as_activity_provider :find_options => {:include => [:project, :author, :tracker]},
50 :author_key => :author_id
50 :author_key => :author_id
51
51
52 DONE_RATIO_OPTIONS = %w(issue_field issue_status)
52 DONE_RATIO_OPTIONS = %w(issue_field issue_status)
53
53
54 attr_reader :current_journal
54 attr_reader :current_journal
55
55
56 validates_presence_of :subject, :priority, :project, :tracker, :author, :status
56 validates_presence_of :subject, :priority, :project, :tracker, :author, :status
57
57
58 validates_length_of :subject, :maximum => 255
58 validates_length_of :subject, :maximum => 255
59 validates_inclusion_of :done_ratio, :in => 0..100
59 validates_inclusion_of :done_ratio, :in => 0..100
60 validates_numericality_of :estimated_hours, :allow_nil => true
60 validates_numericality_of :estimated_hours, :allow_nil => true
61 validate :validate_issue
61 validate :validate_issue
62
62
63 named_scope :visible, lambda {|*args| { :include => :project,
63 named_scope :visible, lambda {|*args| { :include => :project,
64 :conditions => Issue.visible_condition(args.shift || User.current, *args) } }
64 :conditions => Issue.visible_condition(args.shift || User.current, *args) } }
65
65
66 named_scope :open, lambda {|*args|
66 named_scope :open, lambda {|*args|
67 is_closed = args.size > 0 ? !args.first : false
67 is_closed = args.size > 0 ? !args.first : false
68 {:conditions => ["#{IssueStatus.table_name}.is_closed = ?", is_closed], :include => :status}
68 {:conditions => ["#{IssueStatus.table_name}.is_closed = ?", is_closed], :include => :status}
69 }
69 }
70
70
71 named_scope :recently_updated, :order => "#{Issue.table_name}.updated_on DESC"
71 named_scope :recently_updated, :order => "#{Issue.table_name}.updated_on DESC"
72 named_scope :with_limit, lambda { |limit| { :limit => limit} }
72 named_scope :with_limit, lambda { |limit| { :limit => limit} }
73 named_scope :on_active_project, :include => [:status, :project, :tracker],
73 named_scope :on_active_project, :include => [:status, :project, :tracker],
74 :conditions => ["#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"]
74 :conditions => ["#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"]
75
75
76 named_scope :without_version, lambda {
76 named_scope :without_version, lambda {
77 {
77 {
78 :conditions => { :fixed_version_id => nil}
78 :conditions => { :fixed_version_id => nil}
79 }
79 }
80 }
80 }
81
81
82 named_scope :with_query, lambda {|query|
82 named_scope :with_query, lambda {|query|
83 {
83 {
84 :conditions => Query.merge_conditions(query.statement)
84 :conditions => Query.merge_conditions(query.statement)
85 }
85 }
86 }
86 }
87
87
88 before_create :default_assign
88 before_create :default_assign
89 before_save :close_duplicates, :update_done_ratio_from_issue_status
89 before_save :close_duplicates, :update_done_ratio_from_issue_status
90 after_save :reschedule_following_issues, :update_nested_set_attributes, :update_parent_attributes, :create_journal
90 after_save :reschedule_following_issues, :update_nested_set_attributes, :update_parent_attributes, :create_journal
91 after_destroy :update_parent_attributes
91 after_destroy :update_parent_attributes
92
92
93 # Returns a SQL conditions string used to find all issues visible by the specified user
93 # Returns a SQL conditions string used to find all issues visible by the specified user
94 def self.visible_condition(user, options={})
94 def self.visible_condition(user, options={})
95 Project.allowed_to_condition(user, :view_issues, options) do |role, user|
95 Project.allowed_to_condition(user, :view_issues, options) do |role, user|
96 case role.issues_visibility
96 case role.issues_visibility
97 when 'all'
97 when 'all'
98 nil
98 nil
99 when 'default'
99 when 'default'
100 user_ids = [user.id] + user.groups.map(&:id)
100 user_ids = [user.id] + user.groups.map(&:id)
101 "(#{table_name}.is_private = #{connection.quoted_false} OR #{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
101 "(#{table_name}.is_private = #{connection.quoted_false} OR #{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
102 when 'own'
102 when 'own'
103 user_ids = [user.id] + user.groups.map(&:id)
103 user_ids = [user.id] + user.groups.map(&:id)
104 "(#{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
104 "(#{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
105 else
105 else
106 '1=0'
106 '1=0'
107 end
107 end
108 end
108 end
109 end
109 end
110
110
111 # Returns true if usr or current user is allowed to view the issue
111 # Returns true if usr or current user is allowed to view the issue
112 def visible?(usr=nil)
112 def visible?(usr=nil)
113 (usr || User.current).allowed_to?(:view_issues, self.project) do |role, user|
113 (usr || User.current).allowed_to?(:view_issues, self.project) do |role, user|
114 case role.issues_visibility
114 case role.issues_visibility
115 when 'all'
115 when 'all'
116 true
116 true
117 when 'default'
117 when 'default'
118 !self.is_private? || self.author == user || user.is_or_belongs_to?(assigned_to)
118 !self.is_private? || self.author == user || user.is_or_belongs_to?(assigned_to)
119 when 'own'
119 when 'own'
120 self.author == user || user.is_or_belongs_to?(assigned_to)
120 self.author == user || user.is_or_belongs_to?(assigned_to)
121 else
121 else
122 false
122 false
123 end
123 end
124 end
124 end
125 end
125 end
126
126
127 def after_initialize
127 def initialize(attributes=nil, *args)
128 super
128 if new_record?
129 if new_record?
129 # set default values for new records only
130 # set default values for new records only
130 self.status ||= IssueStatus.default
131 self.status ||= IssueStatus.default
131 self.priority ||= IssuePriority.default
132 self.priority ||= IssuePriority.default
132 end
133 end
133 end
134 end
134
135
135 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
136 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
136 def available_custom_fields
137 def available_custom_fields
137 (project && tracker) ? (project.all_issue_custom_fields & tracker.custom_fields.all) : []
138 (project && tracker) ? (project.all_issue_custom_fields & tracker.custom_fields.all) : []
138 end
139 end
139
140
140 def copy_from(arg)
141 def copy_from(arg)
141 issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg)
142 issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg)
142 self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
143 self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
143 self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
144 self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
144 self.status = issue.status
145 self.status = issue.status
145 self
146 self
146 end
147 end
147
148
148 # Moves/copies an issue to a new project and tracker
149 # Moves/copies an issue to a new project and tracker
149 # Returns the moved/copied issue on success, false on failure
150 # Returns the moved/copied issue on success, false on failure
150 def move_to_project(*args)
151 def move_to_project(*args)
151 ret = Issue.transaction do
152 ret = Issue.transaction do
152 move_to_project_without_transaction(*args) || raise(ActiveRecord::Rollback)
153 move_to_project_without_transaction(*args) || raise(ActiveRecord::Rollback)
153 end || false
154 end || false
154 end
155 end
155
156
156 def move_to_project_without_transaction(new_project, new_tracker = nil, options = {})
157 def move_to_project_without_transaction(new_project, new_tracker = nil, options = {})
157 options ||= {}
158 options ||= {}
158
159
159 if options[:copy]
160 if options[:copy]
160 issue = self.class.new.copy_from(self)
161 issue = self.class.new.copy_from(self)
161 else
162 else
162 issue = self
163 issue = self
163 issue.init_journal(User.current, options[:notes])
164 issue.init_journal(User.current, options[:notes])
164 end
165 end
165
166
166 if new_project && issue.project_id != new_project.id
167 if new_project && issue.project_id != new_project.id
167 # delete issue relations
168 # delete issue relations
168 unless Setting.cross_project_issue_relations?
169 unless Setting.cross_project_issue_relations?
169 issue.relations_from.clear
170 issue.relations_from.clear
170 issue.relations_to.clear
171 issue.relations_to.clear
171 end
172 end
172 # issue is moved to another project
173 # issue is moved to another project
173 # reassign to the category with same name if any
174 # reassign to the category with same name if any
174 new_category = issue.category.nil? ? nil : new_project.issue_categories.find_by_name(issue.category.name)
175 new_category = issue.category.nil? ? nil : new_project.issue_categories.find_by_name(issue.category.name)
175 issue.category = new_category
176 issue.category = new_category
176 # Keep the fixed_version if it's still valid in the new_project
177 # Keep the fixed_version if it's still valid in the new_project
177 unless new_project.shared_versions.include?(issue.fixed_version)
178 unless new_project.shared_versions.include?(issue.fixed_version)
178 issue.fixed_version = nil
179 issue.fixed_version = nil
179 end
180 end
180 issue.project = new_project
181 issue.project = new_project
181 if issue.parent && issue.parent.project_id != issue.project_id
182 if issue.parent && issue.parent.project_id != issue.project_id
182 issue.parent_issue_id = nil
183 issue.parent_issue_id = nil
183 end
184 end
184 end
185 end
185 if new_tracker
186 if new_tracker
186 issue.tracker = new_tracker
187 issue.tracker = new_tracker
187 issue.reset_custom_values!
188 issue.reset_custom_values!
188 end
189 end
189 if options[:copy]
190 if options[:copy]
190 issue.author = User.current
191 issue.author = User.current
191 issue.custom_field_values = self.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
192 issue.custom_field_values = self.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
192 issue.status = if options[:attributes] && options[:attributes][:status_id]
193 issue.status = if options[:attributes] && options[:attributes][:status_id]
193 IssueStatus.find_by_id(options[:attributes][:status_id])
194 IssueStatus.find_by_id(options[:attributes][:status_id])
194 else
195 else
195 self.status
196 self.status
196 end
197 end
197 end
198 end
198 # Allow bulk setting of attributes on the issue
199 # Allow bulk setting of attributes on the issue
199 if options[:attributes]
200 if options[:attributes]
200 issue.attributes = options[:attributes]
201 issue.attributes = options[:attributes]
201 end
202 end
202 if options[:copy] && options[:notes].present?
203 if options[:copy] && options[:notes].present?
203 issue.init_journal(User.current, options[:notes])
204 issue.init_journal(User.current, options[:notes])
204 issue.current_journal.notify = false
205 issue.current_journal.notify = false
205 end
206 end
206 if issue.save
207 if issue.save
207 unless options[:copy]
208 unless options[:copy]
208 # Manually update project_id on related time entries
209 # Manually update project_id on related time entries
209 TimeEntry.update_all("project_id = #{new_project.id}", {:issue_id => id})
210 TimeEntry.update_all("project_id = #{new_project.id}", {:issue_id => id})
210
211
211 issue.children.each do |child|
212 issue.children.each do |child|
212 unless child.move_to_project_without_transaction(new_project)
213 unless child.move_to_project_without_transaction(new_project)
213 # Move failed and transaction was rollback'd
214 # Move failed and transaction was rollback'd
214 return false
215 return false
215 end
216 end
216 end
217 end
217 end
218 end
218 else
219 else
219 return false
220 return false
220 end
221 end
221 issue
222 issue
222 end
223 end
223
224
224 def status_id=(sid)
225 def status_id=(sid)
225 self.status = nil
226 self.status = nil
226 write_attribute(:status_id, sid)
227 write_attribute(:status_id, sid)
227 end
228 end
228
229
229 def priority_id=(pid)
230 def priority_id=(pid)
230 self.priority = nil
231 self.priority = nil
231 write_attribute(:priority_id, pid)
232 write_attribute(:priority_id, pid)
232 end
233 end
233
234
234 def tracker_id=(tid)
235 def tracker_id=(tid)
235 self.tracker = nil
236 self.tracker = nil
236 result = write_attribute(:tracker_id, tid)
237 result = write_attribute(:tracker_id, tid)
237 @custom_field_values = nil
238 @custom_field_values = nil
238 result
239 result
239 end
240 end
240
241
241 def description=(arg)
242 def description=(arg)
242 if arg.is_a?(String)
243 if arg.is_a?(String)
243 arg = arg.gsub(/(\r\n|\n|\r)/, "\r\n")
244 arg = arg.gsub(/(\r\n|\n|\r)/, "\r\n")
244 end
245 end
245 write_attribute(:description, arg)
246 write_attribute(:description, arg)
246 end
247 end
247
248
248 # Overrides attributes= so that project and tracker get assigned first
249 # Overrides attributes= so that project and tracker get assigned first
249 def attributes_with_project_and_tracker_first=(new_attributes, *args)
250 def attributes_with_project_and_tracker_first=(new_attributes, *args)
250 return if new_attributes.nil?
251 return if new_attributes.nil?
251 attrs = new_attributes.dup
252 attrs = new_attributes.dup
252 attrs.stringify_keys!
253 attrs.stringify_keys!
253
254
254 %w(project project_id tracker tracker_id).each do |attr|
255 %w(project project_id tracker tracker_id).each do |attr|
255 if attrs.has_key?(attr)
256 if attrs.has_key?(attr)
256 send "#{attr}=", attrs.delete(attr)
257 send "#{attr}=", attrs.delete(attr)
257 end
258 end
258 end
259 end
259 send :attributes_without_project_and_tracker_first=, attrs, *args
260 send :attributes_without_project_and_tracker_first=, attrs, *args
260 end
261 end
261 # Do not redefine alias chain on reload (see #4838)
262 # Do not redefine alias chain on reload (see #4838)
262 alias_method_chain(:attributes=, :project_and_tracker_first) unless method_defined?(:attributes_without_project_and_tracker_first=)
263 alias_method_chain(:attributes=, :project_and_tracker_first) unless method_defined?(:attributes_without_project_and_tracker_first=)
263
264
264 def estimated_hours=(h)
265 def estimated_hours=(h)
265 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
266 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
266 end
267 end
267
268
268 safe_attributes 'tracker_id',
269 safe_attributes 'tracker_id',
269 'status_id',
270 'status_id',
270 'category_id',
271 'category_id',
271 'assigned_to_id',
272 'assigned_to_id',
272 'priority_id',
273 'priority_id',
273 'fixed_version_id',
274 'fixed_version_id',
274 'subject',
275 'subject',
275 'description',
276 'description',
276 'start_date',
277 'start_date',
277 'due_date',
278 'due_date',
278 'done_ratio',
279 'done_ratio',
279 'estimated_hours',
280 'estimated_hours',
280 'custom_field_values',
281 'custom_field_values',
281 'custom_fields',
282 'custom_fields',
282 'lock_version',
283 'lock_version',
283 :if => lambda {|issue, user| issue.new_record? || user.allowed_to?(:edit_issues, issue.project) }
284 :if => lambda {|issue, user| issue.new_record? || user.allowed_to?(:edit_issues, issue.project) }
284
285
285 safe_attributes 'status_id',
286 safe_attributes 'status_id',
286 'assigned_to_id',
287 'assigned_to_id',
287 'fixed_version_id',
288 'fixed_version_id',
288 'done_ratio',
289 'done_ratio',
289 'lock_version',
290 'lock_version',
290 :if => lambda {|issue, user| issue.new_statuses_allowed_to(user).any? }
291 :if => lambda {|issue, user| issue.new_statuses_allowed_to(user).any? }
291
292
292 safe_attributes 'watcher_user_ids',
293 safe_attributes 'watcher_user_ids',
293 :if => lambda {|issue, user| issue.new_record? && user.allowed_to?(:add_issue_watchers, issue.project)}
294 :if => lambda {|issue, user| issue.new_record? && user.allowed_to?(:add_issue_watchers, issue.project)}
294
295
295 safe_attributes 'is_private',
296 safe_attributes 'is_private',
296 :if => lambda {|issue, user|
297 :if => lambda {|issue, user|
297 user.allowed_to?(:set_issues_private, issue.project) ||
298 user.allowed_to?(:set_issues_private, issue.project) ||
298 (issue.author == user && user.allowed_to?(:set_own_issues_private, issue.project))
299 (issue.author == user && user.allowed_to?(:set_own_issues_private, issue.project))
299 }
300 }
300
301
301 safe_attributes 'parent_issue_id',
302 safe_attributes 'parent_issue_id',
302 :if => lambda {|issue, user| (issue.new_record? || user.allowed_to?(:edit_issues, issue.project)) &&
303 :if => lambda {|issue, user| (issue.new_record? || user.allowed_to?(:edit_issues, issue.project)) &&
303 user.allowed_to?(:manage_subtasks, issue.project)}
304 user.allowed_to?(:manage_subtasks, issue.project)}
304
305
305 # Safely sets attributes
306 # Safely sets attributes
306 # Should be called from controllers instead of #attributes=
307 # Should be called from controllers instead of #attributes=
307 # attr_accessible is too rough because we still want things like
308 # attr_accessible is too rough because we still want things like
308 # Issue.new(:project => foo) to work
309 # Issue.new(:project => foo) to work
309 # TODO: move workflow/permission checks from controllers to here
310 # TODO: move workflow/permission checks from controllers to here
310 def safe_attributes=(attrs, user=User.current)
311 def safe_attributes=(attrs, user=User.current)
311 return unless attrs.is_a?(Hash)
312 return unless attrs.is_a?(Hash)
312
313
313 # User can change issue attributes only if he has :edit permission or if a workflow transition is allowed
314 # User can change issue attributes only if he has :edit permission or if a workflow transition is allowed
314 attrs = delete_unsafe_attributes(attrs, user)
315 attrs = delete_unsafe_attributes(attrs, user)
315 return if attrs.empty?
316 return if attrs.empty?
316
317
317 # Tracker must be set before since new_statuses_allowed_to depends on it.
318 # Tracker must be set before since new_statuses_allowed_to depends on it.
318 if t = attrs.delete('tracker_id')
319 if t = attrs.delete('tracker_id')
319 self.tracker_id = t
320 self.tracker_id = t
320 end
321 end
321
322
322 if attrs['status_id']
323 if attrs['status_id']
323 unless new_statuses_allowed_to(user).collect(&:id).include?(attrs['status_id'].to_i)
324 unless new_statuses_allowed_to(user).collect(&:id).include?(attrs['status_id'].to_i)
324 attrs.delete('status_id')
325 attrs.delete('status_id')
325 end
326 end
326 end
327 end
327
328
328 unless leaf?
329 unless leaf?
329 attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
330 attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
330 end
331 end
331
332
332 if attrs['parent_issue_id'].present?
333 if attrs['parent_issue_id'].present?
333 attrs.delete('parent_issue_id') unless Issue.visible(user).exists?(attrs['parent_issue_id'].to_i)
334 attrs.delete('parent_issue_id') unless Issue.visible(user).exists?(attrs['parent_issue_id'].to_i)
334 end
335 end
335
336
336 # mass-assignment security bypass
337 # mass-assignment security bypass
337 self.send :attributes=, attrs, false
338 self.send :attributes=, attrs, false
338 end
339 end
339
340
340 def done_ratio
341 def done_ratio
341 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
342 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
342 status.default_done_ratio
343 status.default_done_ratio
343 else
344 else
344 read_attribute(:done_ratio)
345 read_attribute(:done_ratio)
345 end
346 end
346 end
347 end
347
348
348 def self.use_status_for_done_ratio?
349 def self.use_status_for_done_ratio?
349 Setting.issue_done_ratio == 'issue_status'
350 Setting.issue_done_ratio == 'issue_status'
350 end
351 end
351
352
352 def self.use_field_for_done_ratio?
353 def self.use_field_for_done_ratio?
353 Setting.issue_done_ratio == 'issue_field'
354 Setting.issue_done_ratio == 'issue_field'
354 end
355 end
355
356
356 def validate_issue
357 def validate_issue
357 if self.due_date.nil? && @attributes['due_date'] && !@attributes['due_date'].empty?
358 if self.due_date.nil? && @attributes['due_date'] && !@attributes['due_date'].empty?
358 errors.add :due_date, :not_a_date
359 errors.add :due_date, :not_a_date
359 end
360 end
360
361
361 if self.due_date and self.start_date and self.due_date < self.start_date
362 if self.due_date and self.start_date and self.due_date < self.start_date
362 errors.add :due_date, :greater_than_start_date
363 errors.add :due_date, :greater_than_start_date
363 end
364 end
364
365
365 if start_date && soonest_start && start_date < soonest_start
366 if start_date && soonest_start && start_date < soonest_start
366 errors.add :start_date, :invalid
367 errors.add :start_date, :invalid
367 end
368 end
368
369
369 if fixed_version
370 if fixed_version
370 if !assignable_versions.include?(fixed_version)
371 if !assignable_versions.include?(fixed_version)
371 errors.add :fixed_version_id, :inclusion
372 errors.add :fixed_version_id, :inclusion
372 elsif reopened? && fixed_version.closed?
373 elsif reopened? && fixed_version.closed?
373 errors.add :base, I18n.t(:error_can_not_reopen_issue_on_closed_version)
374 errors.add :base, I18n.t(:error_can_not_reopen_issue_on_closed_version)
374 end
375 end
375 end
376 end
376
377
377 # Checks that the issue can not be added/moved to a disabled tracker
378 # Checks that the issue can not be added/moved to a disabled tracker
378 if project && (tracker_id_changed? || project_id_changed?)
379 if project && (tracker_id_changed? || project_id_changed?)
379 unless project.trackers.include?(tracker)
380 unless project.trackers.include?(tracker)
380 errors.add :tracker_id, :inclusion
381 errors.add :tracker_id, :inclusion
381 end
382 end
382 end
383 end
383
384
384 # Checks parent issue assignment
385 # Checks parent issue assignment
385 if @parent_issue
386 if @parent_issue
386 if @parent_issue.project_id != project_id
387 if @parent_issue.project_id != project_id
387 errors.add :parent_issue_id, :not_same_project
388 errors.add :parent_issue_id, :not_same_project
388 elsif !new_record?
389 elsif !new_record?
389 # moving an existing issue
390 # moving an existing issue
390 if @parent_issue.root_id != root_id
391 if @parent_issue.root_id != root_id
391 # we can always move to another tree
392 # we can always move to another tree
392 elsif move_possible?(@parent_issue)
393 elsif move_possible?(@parent_issue)
393 # move accepted inside tree
394 # move accepted inside tree
394 else
395 else
395 errors.add :parent_issue_id, :not_a_valid_parent
396 errors.add :parent_issue_id, :not_a_valid_parent
396 end
397 end
397 end
398 end
398 end
399 end
399 end
400 end
400
401
401 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
402 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
402 # even if the user turns off the setting later
403 # even if the user turns off the setting later
403 def update_done_ratio_from_issue_status
404 def update_done_ratio_from_issue_status
404 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
405 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
405 self.done_ratio = status.default_done_ratio
406 self.done_ratio = status.default_done_ratio
406 end
407 end
407 end
408 end
408
409
409 def init_journal(user, notes = "")
410 def init_journal(user, notes = "")
410 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
411 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
411 @issue_before_change = self.clone
412 @issue_before_change = self.clone
412 @issue_before_change.status = self.status
413 @issue_before_change.status = self.status
413 @custom_values_before_change = {}
414 @custom_values_before_change = {}
414 self.custom_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
415 self.custom_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
415 # Make sure updated_on is updated when adding a note.
416 # Make sure updated_on is updated when adding a note.
416 updated_on_will_change!
417 updated_on_will_change!
417 @current_journal
418 @current_journal
418 end
419 end
419
420
420 # Return true if the issue is closed, otherwise false
421 # Return true if the issue is closed, otherwise false
421 def closed?
422 def closed?
422 self.status.is_closed?
423 self.status.is_closed?
423 end
424 end
424
425
425 # Return true if the issue is being reopened
426 # Return true if the issue is being reopened
426 def reopened?
427 def reopened?
427 if !new_record? && status_id_changed?
428 if !new_record? && status_id_changed?
428 status_was = IssueStatus.find_by_id(status_id_was)
429 status_was = IssueStatus.find_by_id(status_id_was)
429 status_new = IssueStatus.find_by_id(status_id)
430 status_new = IssueStatus.find_by_id(status_id)
430 if status_was && status_new && status_was.is_closed? && !status_new.is_closed?
431 if status_was && status_new && status_was.is_closed? && !status_new.is_closed?
431 return true
432 return true
432 end
433 end
433 end
434 end
434 false
435 false
435 end
436 end
436
437
437 # Return true if the issue is being closed
438 # Return true if the issue is being closed
438 def closing?
439 def closing?
439 if !new_record? && status_id_changed?
440 if !new_record? && status_id_changed?
440 status_was = IssueStatus.find_by_id(status_id_was)
441 status_was = IssueStatus.find_by_id(status_id_was)
441 status_new = IssueStatus.find_by_id(status_id)
442 status_new = IssueStatus.find_by_id(status_id)
442 if status_was && status_new && !status_was.is_closed? && status_new.is_closed?
443 if status_was && status_new && !status_was.is_closed? && status_new.is_closed?
443 return true
444 return true
444 end
445 end
445 end
446 end
446 false
447 false
447 end
448 end
448
449
449 # Returns true if the issue is overdue
450 # Returns true if the issue is overdue
450 def overdue?
451 def overdue?
451 !due_date.nil? && (due_date < Date.today) && !status.is_closed?
452 !due_date.nil? && (due_date < Date.today) && !status.is_closed?
452 end
453 end
453
454
454 # Is the amount of work done less than it should for the due date
455 # Is the amount of work done less than it should for the due date
455 def behind_schedule?
456 def behind_schedule?
456 return false if start_date.nil? || due_date.nil?
457 return false if start_date.nil? || due_date.nil?
457 done_date = start_date + ((due_date - start_date+1)* done_ratio/100).floor
458 done_date = start_date + ((due_date - start_date+1)* done_ratio/100).floor
458 return done_date <= Date.today
459 return done_date <= Date.today
459 end
460 end
460
461
461 # Does this issue have children?
462 # Does this issue have children?
462 def children?
463 def children?
463 !leaf?
464 !leaf?
464 end
465 end
465
466
466 # Users the issue can be assigned to
467 # Users the issue can be assigned to
467 def assignable_users
468 def assignable_users
468 users = project.assignable_users
469 users = project.assignable_users
469 users << author if author
470 users << author if author
470 users << assigned_to if assigned_to
471 users << assigned_to if assigned_to
471 users.uniq.sort
472 users.uniq.sort
472 end
473 end
473
474
474 # Versions that the issue can be assigned to
475 # Versions that the issue can be assigned to
475 def assignable_versions
476 def assignable_versions
476 @assignable_versions ||= (project.shared_versions.open + [Version.find_by_id(fixed_version_id_was)]).compact.uniq.sort
477 @assignable_versions ||= (project.shared_versions.open + [Version.find_by_id(fixed_version_id_was)]).compact.uniq.sort
477 end
478 end
478
479
479 # Returns true if this issue is blocked by another issue that is still open
480 # Returns true if this issue is blocked by another issue that is still open
480 def blocked?
481 def blocked?
481 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
482 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
482 end
483 end
483
484
484 # Returns an array of status that user is able to apply
485 # Returns an array of status that user is able to apply
485 def new_statuses_allowed_to(user, include_default=false)
486 def new_statuses_allowed_to(user, include_default=false)
486 statuses = status.find_new_statuses_allowed_to(
487 statuses = status.find_new_statuses_allowed_to(
487 user.roles_for_project(project),
488 user.roles_for_project(project),
488 tracker,
489 tracker,
489 author == user,
490 author == user,
490 assigned_to_id_changed? ? assigned_to_id_was == user.id : assigned_to_id == user.id
491 assigned_to_id_changed? ? assigned_to_id_was == user.id : assigned_to_id == user.id
491 )
492 )
492 statuses << status unless statuses.empty?
493 statuses << status unless statuses.empty?
493 statuses << IssueStatus.default if include_default
494 statuses << IssueStatus.default if include_default
494 statuses = statuses.uniq.sort
495 statuses = statuses.uniq.sort
495 blocked? ? statuses.reject {|s| s.is_closed?} : statuses
496 blocked? ? statuses.reject {|s| s.is_closed?} : statuses
496 end
497 end
497
498
498 # Returns the mail adresses of users that should be notified
499 # Returns the mail adresses of users that should be notified
499 def recipients
500 def recipients
500 notified = project.notified_users
501 notified = project.notified_users
501 # Author and assignee are always notified unless they have been
502 # Author and assignee are always notified unless they have been
502 # locked or don't want to be notified
503 # locked or don't want to be notified
503 notified << author if author && author.active? && author.notify_about?(self)
504 notified << author if author && author.active? && author.notify_about?(self)
504 if assigned_to
505 if assigned_to
505 if assigned_to.is_a?(Group)
506 if assigned_to.is_a?(Group)
506 notified += assigned_to.users.select {|u| u.active? && u.notify_about?(self)}
507 notified += assigned_to.users.select {|u| u.active? && u.notify_about?(self)}
507 else
508 else
508 notified << assigned_to if assigned_to.active? && assigned_to.notify_about?(self)
509 notified << assigned_to if assigned_to.active? && assigned_to.notify_about?(self)
509 end
510 end
510 end
511 end
511 notified.uniq!
512 notified.uniq!
512 # Remove users that can not view the issue
513 # Remove users that can not view the issue
513 notified.reject! {|user| !visible?(user)}
514 notified.reject! {|user| !visible?(user)}
514 notified.collect(&:mail)
515 notified.collect(&:mail)
515 end
516 end
516
517
517 # Returns the number of hours spent on this issue
518 # Returns the number of hours spent on this issue
518 def spent_hours
519 def spent_hours
519 @spent_hours ||= time_entries.sum(:hours) || 0
520 @spent_hours ||= time_entries.sum(:hours) || 0
520 end
521 end
521
522
522 # Returns the total number of hours spent on this issue and its descendants
523 # Returns the total number of hours spent on this issue and its descendants
523 #
524 #
524 # Example:
525 # Example:
525 # spent_hours => 0.0
526 # spent_hours => 0.0
526 # spent_hours => 50.2
527 # spent_hours => 50.2
527 def total_spent_hours
528 def total_spent_hours
528 @total_spent_hours ||= self_and_descendants.sum("#{TimeEntry.table_name}.hours",
529 @total_spent_hours ||= self_and_descendants.sum("#{TimeEntry.table_name}.hours",
529 :joins => "LEFT JOIN #{TimeEntry.table_name} ON #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id").to_f || 0.0
530 :joins => "LEFT JOIN #{TimeEntry.table_name} ON #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id").to_f || 0.0
530 end
531 end
531
532
532 def relations
533 def relations
533 @relations ||= (relations_from + relations_to).sort
534 @relations ||= (relations_from + relations_to).sort
534 end
535 end
535
536
536 # Preloads relations for a collection of issues
537 # Preloads relations for a collection of issues
537 def self.load_relations(issues)
538 def self.load_relations(issues)
538 if issues.any?
539 if issues.any?
539 relations = IssueRelation.all(:conditions => ["issue_from_id IN (:ids) OR issue_to_id IN (:ids)", {:ids => issues.map(&:id)}])
540 relations = IssueRelation.all(:conditions => ["issue_from_id IN (:ids) OR issue_to_id IN (:ids)", {:ids => issues.map(&:id)}])
540 issues.each do |issue|
541 issues.each do |issue|
541 issue.instance_variable_set "@relations", relations.select {|r| r.issue_from_id == issue.id || r.issue_to_id == issue.id}
542 issue.instance_variable_set "@relations", relations.select {|r| r.issue_from_id == issue.id || r.issue_to_id == issue.id}
542 end
543 end
543 end
544 end
544 end
545 end
545
546
546 # Preloads visible spent time for a collection of issues
547 # Preloads visible spent time for a collection of issues
547 def self.load_visible_spent_hours(issues, user=User.current)
548 def self.load_visible_spent_hours(issues, user=User.current)
548 if issues.any?
549 if issues.any?
549 hours_by_issue_id = TimeEntry.visible(user).sum(:hours, :group => :issue_id)
550 hours_by_issue_id = TimeEntry.visible(user).sum(:hours, :group => :issue_id)
550 issues.each do |issue|
551 issues.each do |issue|
551 issue.instance_variable_set "@spent_hours", (hours_by_issue_id[issue.id] || 0)
552 issue.instance_variable_set "@spent_hours", (hours_by_issue_id[issue.id] || 0)
552 end
553 end
553 end
554 end
554 end
555 end
555
556
556 # Finds an issue relation given its id.
557 # Finds an issue relation given its id.
557 def find_relation(relation_id)
558 def find_relation(relation_id)
558 IssueRelation.find(relation_id, :conditions => ["issue_to_id = ? OR issue_from_id = ?", id, id])
559 IssueRelation.find(relation_id, :conditions => ["issue_to_id = ? OR issue_from_id = ?", id, id])
559 end
560 end
560
561
561 def all_dependent_issues(except=[])
562 def all_dependent_issues(except=[])
562 except << self
563 except << self
563 dependencies = []
564 dependencies = []
564 relations_from.each do |relation|
565 relations_from.each do |relation|
565 if relation.issue_to && !except.include?(relation.issue_to)
566 if relation.issue_to && !except.include?(relation.issue_to)
566 dependencies << relation.issue_to
567 dependencies << relation.issue_to
567 dependencies += relation.issue_to.all_dependent_issues(except)
568 dependencies += relation.issue_to.all_dependent_issues(except)
568 end
569 end
569 end
570 end
570 dependencies
571 dependencies
571 end
572 end
572
573
573 # Returns an array of issues that duplicate this one
574 # Returns an array of issues that duplicate this one
574 def duplicates
575 def duplicates
575 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
576 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
576 end
577 end
577
578
578 # Returns the due date or the target due date if any
579 # Returns the due date or the target due date if any
579 # Used on gantt chart
580 # Used on gantt chart
580 def due_before
581 def due_before
581 due_date || (fixed_version ? fixed_version.effective_date : nil)
582 due_date || (fixed_version ? fixed_version.effective_date : nil)
582 end
583 end
583
584
584 # Returns the time scheduled for this issue.
585 # Returns the time scheduled for this issue.
585 #
586 #
586 # Example:
587 # Example:
587 # Start Date: 2/26/09, End Date: 3/04/09
588 # Start Date: 2/26/09, End Date: 3/04/09
588 # duration => 6
589 # duration => 6
589 def duration
590 def duration
590 (start_date && due_date) ? due_date - start_date : 0
591 (start_date && due_date) ? due_date - start_date : 0
591 end
592 end
592
593
593 def soonest_start
594 def soonest_start
594 @soonest_start ||= (
595 @soonest_start ||= (
595 relations_to.collect{|relation| relation.successor_soonest_start} +
596 relations_to.collect{|relation| relation.successor_soonest_start} +
596 ancestors.collect(&:soonest_start)
597 ancestors.collect(&:soonest_start)
597 ).compact.max
598 ).compact.max
598 end
599 end
599
600
600 def reschedule_after(date)
601 def reschedule_after(date)
601 return if date.nil?
602 return if date.nil?
602 if leaf?
603 if leaf?
603 if start_date.nil? || start_date < date
604 if start_date.nil? || start_date < date
604 self.start_date, self.due_date = date, date + duration
605 self.start_date, self.due_date = date, date + duration
605 save
606 save
606 end
607 end
607 else
608 else
608 leaves.each do |leaf|
609 leaves.each do |leaf|
609 leaf.reschedule_after(date)
610 leaf.reschedule_after(date)
610 end
611 end
611 end
612 end
612 end
613 end
613
614
614 def <=>(issue)
615 def <=>(issue)
615 if issue.nil?
616 if issue.nil?
616 -1
617 -1
617 elsif root_id != issue.root_id
618 elsif root_id != issue.root_id
618 (root_id || 0) <=> (issue.root_id || 0)
619 (root_id || 0) <=> (issue.root_id || 0)
619 else
620 else
620 (lft || 0) <=> (issue.lft || 0)
621 (lft || 0) <=> (issue.lft || 0)
621 end
622 end
622 end
623 end
623
624
624 def to_s
625 def to_s
625 "#{tracker} ##{id}: #{subject}"
626 "#{tracker} ##{id}: #{subject}"
626 end
627 end
627
628
628 # Returns a string of css classes that apply to the issue
629 # Returns a string of css classes that apply to the issue
629 def css_classes
630 def css_classes
630 s = "issue status-#{status.position} priority-#{priority.position}"
631 s = "issue status-#{status.position} priority-#{priority.position}"
631 s << ' closed' if closed?
632 s << ' closed' if closed?
632 s << ' overdue' if overdue?
633 s << ' overdue' if overdue?
633 s << ' child' if child?
634 s << ' child' if child?
634 s << ' parent' unless leaf?
635 s << ' parent' unless leaf?
635 s << ' private' if is_private?
636 s << ' private' if is_private?
636 s << ' created-by-me' if User.current.logged? && author_id == User.current.id
637 s << ' created-by-me' if User.current.logged? && author_id == User.current.id
637 s << ' assigned-to-me' if User.current.logged? && assigned_to_id == User.current.id
638 s << ' assigned-to-me' if User.current.logged? && assigned_to_id == User.current.id
638 s
639 s
639 end
640 end
640
641
641 # Saves an issue, time_entry, attachments, and a journal from the parameters
642 # Saves an issue, time_entry, attachments, and a journal from the parameters
642 # Returns false if save fails
643 # Returns false if save fails
643 def save_issue_with_child_records(params, existing_time_entry=nil)
644 def save_issue_with_child_records(params, existing_time_entry=nil)
644 Issue.transaction do
645 Issue.transaction do
645 if params[:time_entry] && (params[:time_entry][:hours].present? || params[:time_entry][:comments].present?) && User.current.allowed_to?(:log_time, project)
646 if params[:time_entry] && (params[:time_entry][:hours].present? || params[:time_entry][:comments].present?) && User.current.allowed_to?(:log_time, project)
646 @time_entry = existing_time_entry || TimeEntry.new
647 @time_entry = existing_time_entry || TimeEntry.new
647 @time_entry.project = project
648 @time_entry.project = project
648 @time_entry.issue = self
649 @time_entry.issue = self
649 @time_entry.user = User.current
650 @time_entry.user = User.current
650 @time_entry.spent_on = User.current.today
651 @time_entry.spent_on = User.current.today
651 @time_entry.attributes = params[:time_entry]
652 @time_entry.attributes = params[:time_entry]
652 self.time_entries << @time_entry
653 self.time_entries << @time_entry
653 end
654 end
654
655
655 if valid?
656 if valid?
656 attachments = Attachment.attach_files(self, params[:attachments])
657 attachments = Attachment.attach_files(self, params[:attachments])
657 # TODO: Rename hook
658 # TODO: Rename hook
658 Redmine::Hook.call_hook(:controller_issues_edit_before_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
659 Redmine::Hook.call_hook(:controller_issues_edit_before_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
659 begin
660 begin
660 if save
661 if save
661 # TODO: Rename hook
662 # TODO: Rename hook
662 Redmine::Hook.call_hook(:controller_issues_edit_after_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
663 Redmine::Hook.call_hook(:controller_issues_edit_after_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
663 else
664 else
664 raise ActiveRecord::Rollback
665 raise ActiveRecord::Rollback
665 end
666 end
666 rescue ActiveRecord::StaleObjectError
667 rescue ActiveRecord::StaleObjectError
667 attachments[:files].each(&:destroy)
668 attachments[:files].each(&:destroy)
668 errors.add :base, l(:notice_locking_conflict)
669 errors.add :base, l(:notice_locking_conflict)
669 raise ActiveRecord::Rollback
670 raise ActiveRecord::Rollback
670 end
671 end
671 end
672 end
672 end
673 end
673 end
674 end
674
675
675 # Unassigns issues from +version+ if it's no longer shared with issue's project
676 # Unassigns issues from +version+ if it's no longer shared with issue's project
676 def self.update_versions_from_sharing_change(version)
677 def self.update_versions_from_sharing_change(version)
677 # Update issues assigned to the version
678 # Update issues assigned to the version
678 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
679 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
679 end
680 end
680
681
681 # Unassigns issues from versions that are no longer shared
682 # Unassigns issues from versions that are no longer shared
682 # after +project+ was moved
683 # after +project+ was moved
683 def self.update_versions_from_hierarchy_change(project)
684 def self.update_versions_from_hierarchy_change(project)
684 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
685 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
685 # Update issues of the moved projects and issues assigned to a version of a moved project
686 # Update issues of the moved projects and issues assigned to a version of a moved project
686 Issue.update_versions(["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)", moved_project_ids, moved_project_ids])
687 Issue.update_versions(["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)", moved_project_ids, moved_project_ids])
687 end
688 end
688
689
689 def parent_issue_id=(arg)
690 def parent_issue_id=(arg)
690 parent_issue_id = arg.blank? ? nil : arg.to_i
691 parent_issue_id = arg.blank? ? nil : arg.to_i
691 if parent_issue_id && @parent_issue = Issue.find_by_id(parent_issue_id)
692 if parent_issue_id && @parent_issue = Issue.find_by_id(parent_issue_id)
692 @parent_issue.id
693 @parent_issue.id
693 else
694 else
694 @parent_issue = nil
695 @parent_issue = nil
695 nil
696 nil
696 end
697 end
697 end
698 end
698
699
699 def parent_issue_id
700 def parent_issue_id
700 if instance_variable_defined? :@parent_issue
701 if instance_variable_defined? :@parent_issue
701 @parent_issue.nil? ? nil : @parent_issue.id
702 @parent_issue.nil? ? nil : @parent_issue.id
702 else
703 else
703 parent_id
704 parent_id
704 end
705 end
705 end
706 end
706
707
707 # Extracted from the ReportsController.
708 # Extracted from the ReportsController.
708 def self.by_tracker(project)
709 def self.by_tracker(project)
709 count_and_group_by(:project => project,
710 count_and_group_by(:project => project,
710 :field => 'tracker_id',
711 :field => 'tracker_id',
711 :joins => Tracker.table_name)
712 :joins => Tracker.table_name)
712 end
713 end
713
714
714 def self.by_version(project)
715 def self.by_version(project)
715 count_and_group_by(:project => project,
716 count_and_group_by(:project => project,
716 :field => 'fixed_version_id',
717 :field => 'fixed_version_id',
717 :joins => Version.table_name)
718 :joins => Version.table_name)
718 end
719 end
719
720
720 def self.by_priority(project)
721 def self.by_priority(project)
721 count_and_group_by(:project => project,
722 count_and_group_by(:project => project,
722 :field => 'priority_id',
723 :field => 'priority_id',
723 :joins => IssuePriority.table_name)
724 :joins => IssuePriority.table_name)
724 end
725 end
725
726
726 def self.by_category(project)
727 def self.by_category(project)
727 count_and_group_by(:project => project,
728 count_and_group_by(:project => project,
728 :field => 'category_id',
729 :field => 'category_id',
729 :joins => IssueCategory.table_name)
730 :joins => IssueCategory.table_name)
730 end
731 end
731
732
732 def self.by_assigned_to(project)
733 def self.by_assigned_to(project)
733 count_and_group_by(:project => project,
734 count_and_group_by(:project => project,
734 :field => 'assigned_to_id',
735 :field => 'assigned_to_id',
735 :joins => User.table_name)
736 :joins => User.table_name)
736 end
737 end
737
738
738 def self.by_author(project)
739 def self.by_author(project)
739 count_and_group_by(:project => project,
740 count_and_group_by(:project => project,
740 :field => 'author_id',
741 :field => 'author_id',
741 :joins => User.table_name)
742 :joins => User.table_name)
742 end
743 end
743
744
744 def self.by_subproject(project)
745 def self.by_subproject(project)
745 ActiveRecord::Base.connection.select_all("select s.id as status_id,
746 ActiveRecord::Base.connection.select_all("select s.id as status_id,
746 s.is_closed as closed,
747 s.is_closed as closed,
747 #{Issue.table_name}.project_id as project_id,
748 #{Issue.table_name}.project_id as project_id,
748 count(#{Issue.table_name}.id) as total
749 count(#{Issue.table_name}.id) as total
749 from
750 from
750 #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s
751 #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s
751 where
752 where
752 #{Issue.table_name}.status_id=s.id
753 #{Issue.table_name}.status_id=s.id
753 and #{Issue.table_name}.project_id = #{Project.table_name}.id
754 and #{Issue.table_name}.project_id = #{Project.table_name}.id
754 and #{visible_condition(User.current, :project => project, :with_subprojects => true)}
755 and #{visible_condition(User.current, :project => project, :with_subprojects => true)}
755 and #{Issue.table_name}.project_id <> #{project.id}
756 and #{Issue.table_name}.project_id <> #{project.id}
756 group by s.id, s.is_closed, #{Issue.table_name}.project_id") if project.descendants.active.any?
757 group by s.id, s.is_closed, #{Issue.table_name}.project_id") if project.descendants.active.any?
757 end
758 end
758 # End ReportsController extraction
759 # End ReportsController extraction
759
760
760 # Returns an array of projects that current user can move issues to
761 # Returns an array of projects that current user can move issues to
761 def self.allowed_target_projects_on_move
762 def self.allowed_target_projects_on_move
762 projects = []
763 projects = []
763 if User.current.admin?
764 if User.current.admin?
764 # admin is allowed to move issues to any active (visible) project
765 # admin is allowed to move issues to any active (visible) project
765 projects = Project.visible.all
766 projects = Project.visible.all
766 elsif User.current.logged?
767 elsif User.current.logged?
767 if Role.non_member.allowed_to?(:move_issues)
768 if Role.non_member.allowed_to?(:move_issues)
768 projects = Project.visible.all
769 projects = Project.visible.all
769 else
770 else
770 User.current.memberships.each {|m| projects << m.project if m.roles.detect {|r| r.allowed_to?(:move_issues)}}
771 User.current.memberships.each {|m| projects << m.project if m.roles.detect {|r| r.allowed_to?(:move_issues)}}
771 end
772 end
772 end
773 end
773 projects
774 projects
774 end
775 end
775
776
776 private
777 private
777
778
778 def update_nested_set_attributes
779 def update_nested_set_attributes
779 if root_id.nil?
780 if root_id.nil?
780 # issue was just created
781 # issue was just created
781 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
782 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
782 set_default_left_and_right
783 set_default_left_and_right
783 Issue.update_all("root_id = #{root_id}, lft = #{lft}, rgt = #{rgt}", ["id = ?", id])
784 Issue.update_all("root_id = #{root_id}, lft = #{lft}, rgt = #{rgt}", ["id = ?", id])
784 if @parent_issue
785 if @parent_issue
785 move_to_child_of(@parent_issue)
786 move_to_child_of(@parent_issue)
786 end
787 end
787 reload
788 reload
788 elsif parent_issue_id != parent_id
789 elsif parent_issue_id != parent_id
789 former_parent_id = parent_id
790 former_parent_id = parent_id
790 # moving an existing issue
791 # moving an existing issue
791 if @parent_issue && @parent_issue.root_id == root_id
792 if @parent_issue && @parent_issue.root_id == root_id
792 # inside the same tree
793 # inside the same tree
793 move_to_child_of(@parent_issue)
794 move_to_child_of(@parent_issue)
794 else
795 else
795 # to another tree
796 # to another tree
796 unless root?
797 unless root?
797 move_to_right_of(root)
798 move_to_right_of(root)
798 reload
799 reload
799 end
800 end
800 old_root_id = root_id
801 old_root_id = root_id
801 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id )
802 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id )
802 target_maxright = nested_set_scope.maximum(right_column_name) || 0
803 target_maxright = nested_set_scope.maximum(right_column_name) || 0
803 offset = target_maxright + 1 - lft
804 offset = target_maxright + 1 - lft
804 Issue.update_all("root_id = #{root_id}, lft = lft + #{offset}, rgt = rgt + #{offset}",
805 Issue.update_all("root_id = #{root_id}, lft = lft + #{offset}, rgt = rgt + #{offset}",
805 ["root_id = ? AND lft >= ? AND rgt <= ? ", old_root_id, lft, rgt])
806 ["root_id = ? AND lft >= ? AND rgt <= ? ", old_root_id, lft, rgt])
806 self[left_column_name] = lft + offset
807 self[left_column_name] = lft + offset
807 self[right_column_name] = rgt + offset
808 self[right_column_name] = rgt + offset
808 if @parent_issue
809 if @parent_issue
809 move_to_child_of(@parent_issue)
810 move_to_child_of(@parent_issue)
810 end
811 end
811 end
812 end
812 reload
813 reload
813 # delete invalid relations of all descendants
814 # delete invalid relations of all descendants
814 self_and_descendants.each do |issue|
815 self_and_descendants.each do |issue|
815 issue.relations.each do |relation|
816 issue.relations.each do |relation|
816 relation.destroy unless relation.valid?
817 relation.destroy unless relation.valid?
817 end
818 end
818 end
819 end
819 # update former parent
820 # update former parent
820 recalculate_attributes_for(former_parent_id) if former_parent_id
821 recalculate_attributes_for(former_parent_id) if former_parent_id
821 end
822 end
822 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
823 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
823 end
824 end
824
825
825 def update_parent_attributes
826 def update_parent_attributes
826 recalculate_attributes_for(parent_id) if parent_id
827 recalculate_attributes_for(parent_id) if parent_id
827 end
828 end
828
829
829 def recalculate_attributes_for(issue_id)
830 def recalculate_attributes_for(issue_id)
830 if issue_id && p = Issue.find_by_id(issue_id)
831 if issue_id && p = Issue.find_by_id(issue_id)
831 # priority = highest priority of children
832 # priority = highest priority of children
832 if priority_position = p.children.maximum("#{IssuePriority.table_name}.position", :include => :priority)
833 if priority_position = p.children.maximum("#{IssuePriority.table_name}.position", :include => :priority)
833 p.priority = IssuePriority.find_by_position(priority_position)
834 p.priority = IssuePriority.find_by_position(priority_position)
834 end
835 end
835
836
836 # start/due dates = lowest/highest dates of children
837 # start/due dates = lowest/highest dates of children
837 p.start_date = p.children.minimum(:start_date)
838 p.start_date = p.children.minimum(:start_date)
838 p.due_date = p.children.maximum(:due_date)
839 p.due_date = p.children.maximum(:due_date)
839 if p.start_date && p.due_date && p.due_date < p.start_date
840 if p.start_date && p.due_date && p.due_date < p.start_date
840 p.start_date, p.due_date = p.due_date, p.start_date
841 p.start_date, p.due_date = p.due_date, p.start_date
841 end
842 end
842
843
843 # done ratio = weighted average ratio of leaves
844 # done ratio = weighted average ratio of leaves
844 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
845 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
845 leaves_count = p.leaves.count
846 leaves_count = p.leaves.count
846 if leaves_count > 0
847 if leaves_count > 0
847 average = p.leaves.average(:estimated_hours).to_f
848 average = p.leaves.average(:estimated_hours).to_f
848 if average == 0
849 if average == 0
849 average = 1
850 average = 1
850 end
851 end
851 done = p.leaves.sum("COALESCE(estimated_hours, #{average}) * (CASE WHEN is_closed = #{connection.quoted_true} THEN 100 ELSE COALESCE(done_ratio, 0) END)", :include => :status).to_f
852 done = p.leaves.sum("COALESCE(estimated_hours, #{average}) * (CASE WHEN is_closed = #{connection.quoted_true} THEN 100 ELSE COALESCE(done_ratio, 0) END)", :include => :status).to_f
852 progress = done / (average * leaves_count)
853 progress = done / (average * leaves_count)
853 p.done_ratio = progress.round
854 p.done_ratio = progress.round
854 end
855 end
855 end
856 end
856
857
857 # estimate = sum of leaves estimates
858 # estimate = sum of leaves estimates
858 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
859 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
859 p.estimated_hours = nil if p.estimated_hours == 0.0
860 p.estimated_hours = nil if p.estimated_hours == 0.0
860
861
861 # ancestors will be recursively updated
862 # ancestors will be recursively updated
862 p.save(false)
863 p.save(false)
863 end
864 end
864 end
865 end
865
866
866 # Update issues so their versions are not pointing to a
867 # Update issues so their versions are not pointing to a
867 # fixed_version that is not shared with the issue's project
868 # fixed_version that is not shared with the issue's project
868 def self.update_versions(conditions=nil)
869 def self.update_versions(conditions=nil)
869 # Only need to update issues with a fixed_version from
870 # Only need to update issues with a fixed_version from
870 # a different project and that is not systemwide shared
871 # a different project and that is not systemwide shared
871 Issue.all(:conditions => merge_conditions("#{Issue.table_name}.fixed_version_id IS NOT NULL" +
872 Issue.all(:conditions => merge_conditions("#{Issue.table_name}.fixed_version_id IS NOT NULL" +
872 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
873 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
873 " AND #{Version.table_name}.sharing <> 'system'",
874 " AND #{Version.table_name}.sharing <> 'system'",
874 conditions),
875 conditions),
875 :include => [:project, :fixed_version]
876 :include => [:project, :fixed_version]
876 ).each do |issue|
877 ).each do |issue|
877 next if issue.project.nil? || issue.fixed_version.nil?
878 next if issue.project.nil? || issue.fixed_version.nil?
878 unless issue.project.shared_versions.include?(issue.fixed_version)
879 unless issue.project.shared_versions.include?(issue.fixed_version)
879 issue.init_journal(User.current)
880 issue.init_journal(User.current)
880 issue.fixed_version = nil
881 issue.fixed_version = nil
881 issue.save
882 issue.save
882 end
883 end
883 end
884 end
884 end
885 end
885
886
886 # Callback on attachment deletion
887 # Callback on attachment deletion
887 def attachment_added(obj)
888 def attachment_added(obj)
888 if @current_journal && !obj.new_record?
889 if @current_journal && !obj.new_record?
889 @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => obj.id, :value => obj.filename)
890 @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => obj.id, :value => obj.filename)
890 end
891 end
891 end
892 end
892
893
893 # Callback on attachment deletion
894 # Callback on attachment deletion
894 def attachment_removed(obj)
895 def attachment_removed(obj)
895 journal = init_journal(User.current)
896 journal = init_journal(User.current)
896 journal.details << JournalDetail.new(:property => 'attachment',
897 journal.details << JournalDetail.new(:property => 'attachment',
897 :prop_key => obj.id,
898 :prop_key => obj.id,
898 :old_value => obj.filename)
899 :old_value => obj.filename)
899 journal.save
900 journal.save
900 end
901 end
901
902
902 # Default assignment based on category
903 # Default assignment based on category
903 def default_assign
904 def default_assign
904 if assigned_to.nil? && category && category.assigned_to
905 if assigned_to.nil? && category && category.assigned_to
905 self.assigned_to = category.assigned_to
906 self.assigned_to = category.assigned_to
906 end
907 end
907 end
908 end
908
909
909 # Updates start/due dates of following issues
910 # Updates start/due dates of following issues
910 def reschedule_following_issues
911 def reschedule_following_issues
911 if start_date_changed? || due_date_changed?
912 if start_date_changed? || due_date_changed?
912 relations_from.each do |relation|
913 relations_from.each do |relation|
913 relation.set_issue_to_dates
914 relation.set_issue_to_dates
914 end
915 end
915 end
916 end
916 end
917 end
917
918
918 # Closes duplicates if the issue is being closed
919 # Closes duplicates if the issue is being closed
919 def close_duplicates
920 def close_duplicates
920 if closing?
921 if closing?
921 duplicates.each do |duplicate|
922 duplicates.each do |duplicate|
922 # Reload is need in case the duplicate was updated by a previous duplicate
923 # Reload is need in case the duplicate was updated by a previous duplicate
923 duplicate.reload
924 duplicate.reload
924 # Don't re-close it if it's already closed
925 # Don't re-close it if it's already closed
925 next if duplicate.closed?
926 next if duplicate.closed?
926 # Same user and notes
927 # Same user and notes
927 if @current_journal
928 if @current_journal
928 duplicate.init_journal(@current_journal.user, @current_journal.notes)
929 duplicate.init_journal(@current_journal.user, @current_journal.notes)
929 end
930 end
930 duplicate.update_attribute :status, self.status
931 duplicate.update_attribute :status, self.status
931 end
932 end
932 end
933 end
933 end
934 end
934
935
935 # Saves the changes in a Journal
936 # Saves the changes in a Journal
936 # Called after_save
937 # Called after_save
937 def create_journal
938 def create_journal
938 if @current_journal
939 if @current_journal
939 # attributes changes
940 # attributes changes
940 (Issue.column_names - %w(id root_id lft rgt lock_version created_on updated_on)).each {|c|
941 (Issue.column_names - %w(id root_id lft rgt lock_version created_on updated_on)).each {|c|
941 before = @issue_before_change.send(c)
942 before = @issue_before_change.send(c)
942 after = send(c)
943 after = send(c)
943 next if before == after || (before.blank? && after.blank?)
944 next if before == after || (before.blank? && after.blank?)
944 @current_journal.details << JournalDetail.new(:property => 'attr',
945 @current_journal.details << JournalDetail.new(:property => 'attr',
945 :prop_key => c,
946 :prop_key => c,
946 :old_value => @issue_before_change.send(c),
947 :old_value => @issue_before_change.send(c),
947 :value => send(c))
948 :value => send(c))
948 }
949 }
949 # custom fields changes
950 # custom fields changes
950 custom_values.each {|c|
951 custom_values.each {|c|
951 next if (@custom_values_before_change[c.custom_field_id]==c.value ||
952 next if (@custom_values_before_change[c.custom_field_id]==c.value ||
952 (@custom_values_before_change[c.custom_field_id].blank? && c.value.blank?))
953 (@custom_values_before_change[c.custom_field_id].blank? && c.value.blank?))
953 @current_journal.details << JournalDetail.new(:property => 'cf',
954 @current_journal.details << JournalDetail.new(:property => 'cf',
954 :prop_key => c.custom_field_id,
955 :prop_key => c.custom_field_id,
955 :old_value => @custom_values_before_change[c.custom_field_id],
956 :old_value => @custom_values_before_change[c.custom_field_id],
956 :value => c.value)
957 :value => c.value)
957 }
958 }
958 @current_journal.save
959 @current_journal.save
959 # reset current journal
960 # reset current journal
960 init_journal @current_journal.user, @current_journal.notes
961 init_journal @current_journal.user, @current_journal.notes
961 end
962 end
962 end
963 end
963
964
964 # Query generator for selecting groups of issue counts for a project
965 # Query generator for selecting groups of issue counts for a project
965 # based on specific criteria
966 # based on specific criteria
966 #
967 #
967 # Options
968 # Options
968 # * project - Project to search in.
969 # * project - Project to search in.
969 # * field - String. Issue field to key off of in the grouping.
970 # * field - String. Issue field to key off of in the grouping.
970 # * joins - String. The table name to join against.
971 # * joins - String. The table name to join against.
971 def self.count_and_group_by(options)
972 def self.count_and_group_by(options)
972 project = options.delete(:project)
973 project = options.delete(:project)
973 select_field = options.delete(:field)
974 select_field = options.delete(:field)
974 joins = options.delete(:joins)
975 joins = options.delete(:joins)
975
976
976 where = "#{Issue.table_name}.#{select_field}=j.id"
977 where = "#{Issue.table_name}.#{select_field}=j.id"
977
978
978 ActiveRecord::Base.connection.select_all("select s.id as status_id,
979 ActiveRecord::Base.connection.select_all("select s.id as status_id,
979 s.is_closed as closed,
980 s.is_closed as closed,
980 j.id as #{select_field},
981 j.id as #{select_field},
981 count(#{Issue.table_name}.id) as total
982 count(#{Issue.table_name}.id) as total
982 from
983 from
983 #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s, #{joins} j
984 #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s, #{joins} j
984 where
985 where
985 #{Issue.table_name}.status_id=s.id
986 #{Issue.table_name}.status_id=s.id
986 and #{where}
987 and #{where}
987 and #{Issue.table_name}.project_id=#{Project.table_name}.id
988 and #{Issue.table_name}.project_id=#{Project.table_name}.id
988 and #{visible_condition(User.current, :project => project)}
989 and #{visible_condition(User.current, :project => project)}
989 group by s.id, s.is_closed, j.id")
990 group by s.id, s.is_closed, j.id")
990 end
991 end
991 end
992 end
@@ -1,142 +1,143
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class IssueRelation < ActiveRecord::Base
18 class IssueRelation < ActiveRecord::Base
19 belongs_to :issue_from, :class_name => 'Issue', :foreign_key => 'issue_from_id'
19 belongs_to :issue_from, :class_name => 'Issue', :foreign_key => 'issue_from_id'
20 belongs_to :issue_to, :class_name => 'Issue', :foreign_key => 'issue_to_id'
20 belongs_to :issue_to, :class_name => 'Issue', :foreign_key => 'issue_to_id'
21
21
22 TYPE_RELATES = "relates"
22 TYPE_RELATES = "relates"
23 TYPE_DUPLICATES = "duplicates"
23 TYPE_DUPLICATES = "duplicates"
24 TYPE_DUPLICATED = "duplicated"
24 TYPE_DUPLICATED = "duplicated"
25 TYPE_BLOCKS = "blocks"
25 TYPE_BLOCKS = "blocks"
26 TYPE_BLOCKED = "blocked"
26 TYPE_BLOCKED = "blocked"
27 TYPE_PRECEDES = "precedes"
27 TYPE_PRECEDES = "precedes"
28 TYPE_FOLLOWS = "follows"
28 TYPE_FOLLOWS = "follows"
29
29
30 TYPES = { TYPE_RELATES => { :name => :label_relates_to, :sym_name => :label_relates_to, :order => 1, :sym => TYPE_RELATES },
30 TYPES = { TYPE_RELATES => { :name => :label_relates_to, :sym_name => :label_relates_to, :order => 1, :sym => TYPE_RELATES },
31 TYPE_DUPLICATES => { :name => :label_duplicates, :sym_name => :label_duplicated_by, :order => 2, :sym => TYPE_DUPLICATED },
31 TYPE_DUPLICATES => { :name => :label_duplicates, :sym_name => :label_duplicated_by, :order => 2, :sym => TYPE_DUPLICATED },
32 TYPE_DUPLICATED => { :name => :label_duplicated_by, :sym_name => :label_duplicates, :order => 3, :sym => TYPE_DUPLICATES, :reverse => TYPE_DUPLICATES },
32 TYPE_DUPLICATED => { :name => :label_duplicated_by, :sym_name => :label_duplicates, :order => 3, :sym => TYPE_DUPLICATES, :reverse => TYPE_DUPLICATES },
33 TYPE_BLOCKS => { :name => :label_blocks, :sym_name => :label_blocked_by, :order => 4, :sym => TYPE_BLOCKED },
33 TYPE_BLOCKS => { :name => :label_blocks, :sym_name => :label_blocked_by, :order => 4, :sym => TYPE_BLOCKED },
34 TYPE_BLOCKED => { :name => :label_blocked_by, :sym_name => :label_blocks, :order => 5, :sym => TYPE_BLOCKS, :reverse => TYPE_BLOCKS },
34 TYPE_BLOCKED => { :name => :label_blocked_by, :sym_name => :label_blocks, :order => 5, :sym => TYPE_BLOCKS, :reverse => TYPE_BLOCKS },
35 TYPE_PRECEDES => { :name => :label_precedes, :sym_name => :label_follows, :order => 6, :sym => TYPE_FOLLOWS },
35 TYPE_PRECEDES => { :name => :label_precedes, :sym_name => :label_follows, :order => 6, :sym => TYPE_FOLLOWS },
36 TYPE_FOLLOWS => { :name => :label_follows, :sym_name => :label_precedes, :order => 7, :sym => TYPE_PRECEDES, :reverse => TYPE_PRECEDES }
36 TYPE_FOLLOWS => { :name => :label_follows, :sym_name => :label_precedes, :order => 7, :sym => TYPE_PRECEDES, :reverse => TYPE_PRECEDES }
37 }.freeze
37 }.freeze
38
38
39 validates_presence_of :issue_from, :issue_to, :relation_type
39 validates_presence_of :issue_from, :issue_to, :relation_type
40 validates_inclusion_of :relation_type, :in => TYPES.keys
40 validates_inclusion_of :relation_type, :in => TYPES.keys
41 validates_numericality_of :delay, :allow_nil => true
41 validates_numericality_of :delay, :allow_nil => true
42 validates_uniqueness_of :issue_to_id, :scope => :issue_from_id
42 validates_uniqueness_of :issue_to_id, :scope => :issue_from_id
43
43
44 validate :validate_issue_relation
44 validate :validate_issue_relation
45
45
46 attr_protected :issue_from_id, :issue_to_id
46 attr_protected :issue_from_id, :issue_to_id
47
47
48 before_save :handle_issue_order
48 before_save :handle_issue_order
49
49
50 def visible?(user=User.current)
50 def visible?(user=User.current)
51 (issue_from.nil? || issue_from.visible?(user)) && (issue_to.nil? || issue_to.visible?(user))
51 (issue_from.nil? || issue_from.visible?(user)) && (issue_to.nil? || issue_to.visible?(user))
52 end
52 end
53
53
54 def deletable?(user=User.current)
54 def deletable?(user=User.current)
55 visible?(user) &&
55 visible?(user) &&
56 ((issue_from.nil? || user.allowed_to?(:manage_issue_relations, issue_from.project)) ||
56 ((issue_from.nil? || user.allowed_to?(:manage_issue_relations, issue_from.project)) ||
57 (issue_to.nil? || user.allowed_to?(:manage_issue_relations, issue_to.project)))
57 (issue_to.nil? || user.allowed_to?(:manage_issue_relations, issue_to.project)))
58 end
58 end
59
59
60 def after_initialize
60 def initialize(attributes=nil, *args)
61 super
61 if new_record?
62 if new_record?
62 if relation_type.blank?
63 if relation_type.blank?
63 self.relation_type = IssueRelation::TYPE_RELATES
64 self.relation_type = IssueRelation::TYPE_RELATES
64 end
65 end
65 end
66 end
66 end
67 end
67
68
68 def validate_issue_relation
69 def validate_issue_relation
69 if issue_from && issue_to
70 if issue_from && issue_to
70 errors.add :issue_to_id, :invalid if issue_from_id == issue_to_id
71 errors.add :issue_to_id, :invalid if issue_from_id == issue_to_id
71 errors.add :issue_to_id, :not_same_project unless issue_from.project_id == issue_to.project_id || Setting.cross_project_issue_relations?
72 errors.add :issue_to_id, :not_same_project unless issue_from.project_id == issue_to.project_id || Setting.cross_project_issue_relations?
72 #detect circular dependencies depending wether the relation should be reversed
73 #detect circular dependencies depending wether the relation should be reversed
73 if TYPES.has_key?(relation_type) && TYPES[relation_type][:reverse]
74 if TYPES.has_key?(relation_type) && TYPES[relation_type][:reverse]
74 errors.add :base, :circular_dependency if issue_from.all_dependent_issues.include? issue_to
75 errors.add :base, :circular_dependency if issue_from.all_dependent_issues.include? issue_to
75 else
76 else
76 errors.add :base, :circular_dependency if issue_to.all_dependent_issues.include? issue_from
77 errors.add :base, :circular_dependency if issue_to.all_dependent_issues.include? issue_from
77 end
78 end
78 errors.add :base, :cant_link_an_issue_with_a_descendant if issue_from.is_descendant_of?(issue_to) || issue_from.is_ancestor_of?(issue_to)
79 errors.add :base, :cant_link_an_issue_with_a_descendant if issue_from.is_descendant_of?(issue_to) || issue_from.is_ancestor_of?(issue_to)
79 end
80 end
80 end
81 end
81
82
82 def other_issue(issue)
83 def other_issue(issue)
83 (self.issue_from_id == issue.id) ? issue_to : issue_from
84 (self.issue_from_id == issue.id) ? issue_to : issue_from
84 end
85 end
85
86
86 # Returns the relation type for +issue+
87 # Returns the relation type for +issue+
87 def relation_type_for(issue)
88 def relation_type_for(issue)
88 if TYPES[relation_type]
89 if TYPES[relation_type]
89 if self.issue_from_id == issue.id
90 if self.issue_from_id == issue.id
90 relation_type
91 relation_type
91 else
92 else
92 TYPES[relation_type][:sym]
93 TYPES[relation_type][:sym]
93 end
94 end
94 end
95 end
95 end
96 end
96
97
97 def label_for(issue)
98 def label_for(issue)
98 TYPES[relation_type] ? TYPES[relation_type][(self.issue_from_id == issue.id) ? :name : :sym_name] : :unknow
99 TYPES[relation_type] ? TYPES[relation_type][(self.issue_from_id == issue.id) ? :name : :sym_name] : :unknow
99 end
100 end
100
101
101 def handle_issue_order
102 def handle_issue_order
102 reverse_if_needed
103 reverse_if_needed
103
104
104 if TYPE_PRECEDES == relation_type
105 if TYPE_PRECEDES == relation_type
105 self.delay ||= 0
106 self.delay ||= 0
106 else
107 else
107 self.delay = nil
108 self.delay = nil
108 end
109 end
109 set_issue_to_dates
110 set_issue_to_dates
110 end
111 end
111
112
112 def set_issue_to_dates
113 def set_issue_to_dates
113 soonest_start = self.successor_soonest_start
114 soonest_start = self.successor_soonest_start
114 if soonest_start && issue_to
115 if soonest_start && issue_to
115 issue_to.reschedule_after(soonest_start)
116 issue_to.reschedule_after(soonest_start)
116 end
117 end
117 end
118 end
118
119
119 def successor_soonest_start
120 def successor_soonest_start
120 if (TYPE_PRECEDES == self.relation_type) && delay && issue_from && (issue_from.start_date || issue_from.due_date)
121 if (TYPE_PRECEDES == self.relation_type) && delay && issue_from && (issue_from.start_date || issue_from.due_date)
121 (issue_from.due_date || issue_from.start_date) + 1 + delay
122 (issue_from.due_date || issue_from.start_date) + 1 + delay
122 end
123 end
123 end
124 end
124
125
125 def <=>(relation)
126 def <=>(relation)
126 TYPES[self.relation_type][:order] <=> TYPES[relation.relation_type][:order]
127 TYPES[self.relation_type][:order] <=> TYPES[relation.relation_type][:order]
127 end
128 end
128
129
129 private
130 private
130
131
131 # Reverses the relation if needed so that it gets stored in the proper way
132 # Reverses the relation if needed so that it gets stored in the proper way
132 # Should not be reversed before validation so that it can be displayed back
133 # Should not be reversed before validation so that it can be displayed back
133 # as entered on new relation form
134 # as entered on new relation form
134 def reverse_if_needed
135 def reverse_if_needed
135 if TYPES.has_key?(relation_type) && TYPES[relation_type][:reverse]
136 if TYPES.has_key?(relation_type) && TYPES[relation_type][:reverse]
136 issue_tmp = issue_to
137 issue_tmp = issue_to
137 self.issue_to = issue_from
138 self.issue_to = issue_from
138 self.issue_from = issue_tmp
139 self.issue_from = issue_tmp
139 self.relation_type = TYPES[relation_type][:reverse]
140 self.relation_type = TYPES[relation_type][:reverse]
140 end
141 end
141 end
142 end
142 end
143 end
@@ -1,819 +1,815
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class QueryColumn
18 class QueryColumn
19 attr_accessor :name, :sortable, :groupable, :default_order
19 attr_accessor :name, :sortable, :groupable, :default_order
20 include Redmine::I18n
20 include Redmine::I18n
21
21
22 def initialize(name, options={})
22 def initialize(name, options={})
23 self.name = name
23 self.name = name
24 self.sortable = options[:sortable]
24 self.sortable = options[:sortable]
25 self.groupable = options[:groupable] || false
25 self.groupable = options[:groupable] || false
26 if groupable == true
26 if groupable == true
27 self.groupable = name.to_s
27 self.groupable = name.to_s
28 end
28 end
29 self.default_order = options[:default_order]
29 self.default_order = options[:default_order]
30 @caption_key = options[:caption] || "field_#{name}"
30 @caption_key = options[:caption] || "field_#{name}"
31 end
31 end
32
32
33 def caption
33 def caption
34 l(@caption_key)
34 l(@caption_key)
35 end
35 end
36
36
37 # Returns true if the column is sortable, otherwise false
37 # Returns true if the column is sortable, otherwise false
38 def sortable?
38 def sortable?
39 !@sortable.nil?
39 !@sortable.nil?
40 end
40 end
41
41
42 def sortable
42 def sortable
43 @sortable.is_a?(Proc) ? @sortable.call : @sortable
43 @sortable.is_a?(Proc) ? @sortable.call : @sortable
44 end
44 end
45
45
46 def value(issue)
46 def value(issue)
47 issue.send name
47 issue.send name
48 end
48 end
49
49
50 def css_classes
50 def css_classes
51 name
51 name
52 end
52 end
53 end
53 end
54
54
55 class QueryCustomFieldColumn < QueryColumn
55 class QueryCustomFieldColumn < QueryColumn
56
56
57 def initialize(custom_field)
57 def initialize(custom_field)
58 self.name = "cf_#{custom_field.id}".to_sym
58 self.name = "cf_#{custom_field.id}".to_sym
59 self.sortable = custom_field.order_statement || false
59 self.sortable = custom_field.order_statement || false
60 if %w(list date bool int).include?(custom_field.field_format)
60 if %w(list date bool int).include?(custom_field.field_format)
61 self.groupable = custom_field.order_statement
61 self.groupable = custom_field.order_statement
62 end
62 end
63 self.groupable ||= false
63 self.groupable ||= false
64 @cf = custom_field
64 @cf = custom_field
65 end
65 end
66
66
67 def caption
67 def caption
68 @cf.name
68 @cf.name
69 end
69 end
70
70
71 def custom_field
71 def custom_field
72 @cf
72 @cf
73 end
73 end
74
74
75 def value(issue)
75 def value(issue)
76 cv = issue.custom_values.detect {|v| v.custom_field_id == @cf.id}
76 cv = issue.custom_values.detect {|v| v.custom_field_id == @cf.id}
77 cv && @cf.cast_value(cv.value)
77 cv && @cf.cast_value(cv.value)
78 end
78 end
79
79
80 def css_classes
80 def css_classes
81 @css_classes ||= "#{name} #{@cf.field_format}"
81 @css_classes ||= "#{name} #{@cf.field_format}"
82 end
82 end
83 end
83 end
84
84
85 class Query < ActiveRecord::Base
85 class Query < ActiveRecord::Base
86 class StatementInvalid < ::ActiveRecord::StatementInvalid
86 class StatementInvalid < ::ActiveRecord::StatementInvalid
87 end
87 end
88
88
89 belongs_to :project
89 belongs_to :project
90 belongs_to :user
90 belongs_to :user
91 serialize :filters
91 serialize :filters
92 serialize :column_names
92 serialize :column_names
93 serialize :sort_criteria, Array
93 serialize :sort_criteria, Array
94
94
95 attr_protected :project_id, :user_id
95 attr_protected :project_id, :user_id
96
96
97 validates_presence_of :name, :on => :save
97 validates_presence_of :name, :on => :save
98 validates_length_of :name, :maximum => 255
98 validates_length_of :name, :maximum => 255
99 validate :validate_query_filters
99 validate :validate_query_filters
100
100
101 @@operators = { "=" => :label_equals,
101 @@operators = { "=" => :label_equals,
102 "!" => :label_not_equals,
102 "!" => :label_not_equals,
103 "o" => :label_open_issues,
103 "o" => :label_open_issues,
104 "c" => :label_closed_issues,
104 "c" => :label_closed_issues,
105 "!*" => :label_none,
105 "!*" => :label_none,
106 "*" => :label_all,
106 "*" => :label_all,
107 ">=" => :label_greater_or_equal,
107 ">=" => :label_greater_or_equal,
108 "<=" => :label_less_or_equal,
108 "<=" => :label_less_or_equal,
109 "><" => :label_between,
109 "><" => :label_between,
110 "<t+" => :label_in_less_than,
110 "<t+" => :label_in_less_than,
111 ">t+" => :label_in_more_than,
111 ">t+" => :label_in_more_than,
112 "t+" => :label_in,
112 "t+" => :label_in,
113 "t" => :label_today,
113 "t" => :label_today,
114 "w" => :label_this_week,
114 "w" => :label_this_week,
115 ">t-" => :label_less_than_ago,
115 ">t-" => :label_less_than_ago,
116 "<t-" => :label_more_than_ago,
116 "<t-" => :label_more_than_ago,
117 "t-" => :label_ago,
117 "t-" => :label_ago,
118 "~" => :label_contains,
118 "~" => :label_contains,
119 "!~" => :label_not_contains }
119 "!~" => :label_not_contains }
120
120
121 cattr_reader :operators
121 cattr_reader :operators
122
122
123 @@operators_by_filter_type = { :list => [ "=", "!" ],
123 @@operators_by_filter_type = { :list => [ "=", "!" ],
124 :list_status => [ "o", "=", "!", "c", "*" ],
124 :list_status => [ "o", "=", "!", "c", "*" ],
125 :list_optional => [ "=", "!", "!*", "*" ],
125 :list_optional => [ "=", "!", "!*", "*" ],
126 :list_subprojects => [ "*", "!*", "=" ],
126 :list_subprojects => [ "*", "!*", "=" ],
127 :date => [ "=", ">=", "<=", "><", "<t+", ">t+", "t+", "t", "w", ">t-", "<t-", "t-", "!*", "*" ],
127 :date => [ "=", ">=", "<=", "><", "<t+", ">t+", "t+", "t", "w", ">t-", "<t-", "t-", "!*", "*" ],
128 :date_past => [ "=", ">=", "<=", "><", ">t-", "<t-", "t-", "t", "w", "!*", "*" ],
128 :date_past => [ "=", ">=", "<=", "><", ">t-", "<t-", "t-", "t", "w", "!*", "*" ],
129 :string => [ "=", "~", "!", "!~" ],
129 :string => [ "=", "~", "!", "!~" ],
130 :text => [ "~", "!~" ],
130 :text => [ "~", "!~" ],
131 :integer => [ "=", ">=", "<=", "><", "!*", "*" ],
131 :integer => [ "=", ">=", "<=", "><", "!*", "*" ],
132 :float => [ "=", ">=", "<=", "><", "!*", "*" ] }
132 :float => [ "=", ">=", "<=", "><", "!*", "*" ] }
133
133
134 cattr_reader :operators_by_filter_type
134 cattr_reader :operators_by_filter_type
135
135
136 @@available_columns = [
136 @@available_columns = [
137 QueryColumn.new(:project, :sortable => "#{Project.table_name}.name", :groupable => true),
137 QueryColumn.new(:project, :sortable => "#{Project.table_name}.name", :groupable => true),
138 QueryColumn.new(:tracker, :sortable => "#{Tracker.table_name}.position", :groupable => true),
138 QueryColumn.new(:tracker, :sortable => "#{Tracker.table_name}.position", :groupable => true),
139 QueryColumn.new(:parent, :sortable => ["#{Issue.table_name}.root_id", "#{Issue.table_name}.lft ASC"], :default_order => 'desc', :caption => :field_parent_issue),
139 QueryColumn.new(:parent, :sortable => ["#{Issue.table_name}.root_id", "#{Issue.table_name}.lft ASC"], :default_order => 'desc', :caption => :field_parent_issue),
140 QueryColumn.new(:status, :sortable => "#{IssueStatus.table_name}.position", :groupable => true),
140 QueryColumn.new(:status, :sortable => "#{IssueStatus.table_name}.position", :groupable => true),
141 QueryColumn.new(:priority, :sortable => "#{IssuePriority.table_name}.position", :default_order => 'desc', :groupable => true),
141 QueryColumn.new(:priority, :sortable => "#{IssuePriority.table_name}.position", :default_order => 'desc', :groupable => true),
142 QueryColumn.new(:subject, :sortable => "#{Issue.table_name}.subject"),
142 QueryColumn.new(:subject, :sortable => "#{Issue.table_name}.subject"),
143 QueryColumn.new(:author, :sortable => lambda {User.fields_for_order_statement("authors")}, :groupable => true),
143 QueryColumn.new(:author, :sortable => lambda {User.fields_for_order_statement("authors")}, :groupable => true),
144 QueryColumn.new(:assigned_to, :sortable => lambda {User.fields_for_order_statement}, :groupable => true),
144 QueryColumn.new(:assigned_to, :sortable => lambda {User.fields_for_order_statement}, :groupable => true),
145 QueryColumn.new(:updated_on, :sortable => "#{Issue.table_name}.updated_on", :default_order => 'desc'),
145 QueryColumn.new(:updated_on, :sortable => "#{Issue.table_name}.updated_on", :default_order => 'desc'),
146 QueryColumn.new(:category, :sortable => "#{IssueCategory.table_name}.name", :groupable => true),
146 QueryColumn.new(:category, :sortable => "#{IssueCategory.table_name}.name", :groupable => true),
147 QueryColumn.new(:fixed_version, :sortable => ["#{Version.table_name}.effective_date", "#{Version.table_name}.name"], :default_order => 'desc', :groupable => true),
147 QueryColumn.new(:fixed_version, :sortable => ["#{Version.table_name}.effective_date", "#{Version.table_name}.name"], :default_order => 'desc', :groupable => true),
148 QueryColumn.new(:start_date, :sortable => "#{Issue.table_name}.start_date"),
148 QueryColumn.new(:start_date, :sortable => "#{Issue.table_name}.start_date"),
149 QueryColumn.new(:due_date, :sortable => "#{Issue.table_name}.due_date"),
149 QueryColumn.new(:due_date, :sortable => "#{Issue.table_name}.due_date"),
150 QueryColumn.new(:estimated_hours, :sortable => "#{Issue.table_name}.estimated_hours"),
150 QueryColumn.new(:estimated_hours, :sortable => "#{Issue.table_name}.estimated_hours"),
151 QueryColumn.new(:done_ratio, :sortable => "#{Issue.table_name}.done_ratio", :groupable => true),
151 QueryColumn.new(:done_ratio, :sortable => "#{Issue.table_name}.done_ratio", :groupable => true),
152 QueryColumn.new(:created_on, :sortable => "#{Issue.table_name}.created_on", :default_order => 'desc'),
152 QueryColumn.new(:created_on, :sortable => "#{Issue.table_name}.created_on", :default_order => 'desc'),
153 ]
153 ]
154 cattr_reader :available_columns
154 cattr_reader :available_columns
155
155
156 named_scope :visible, lambda {|*args|
156 named_scope :visible, lambda {|*args|
157 user = args.shift || User.current
157 user = args.shift || User.current
158 base = Project.allowed_to_condition(user, :view_issues, *args)
158 base = Project.allowed_to_condition(user, :view_issues, *args)
159 user_id = user.logged? ? user.id : 0
159 user_id = user.logged? ? user.id : 0
160 {
160 {
161 :conditions => ["(#{table_name}.project_id IS NULL OR (#{base})) AND (#{table_name}.is_public = ? OR #{table_name}.user_id = ?)", true, user_id],
161 :conditions => ["(#{table_name}.project_id IS NULL OR (#{base})) AND (#{table_name}.is_public = ? OR #{table_name}.user_id = ?)", true, user_id],
162 :include => :project
162 :include => :project
163 }
163 }
164 }
164 }
165
165
166 def initialize(attributes=nil, *args)
166 def initialize(attributes=nil, *args)
167 super attributes
167 super attributes
168 self.filters ||= { 'status_id' => {:operator => "o", :values => [""]} }
168 self.filters ||= { 'status_id' => {:operator => "o", :values => [""]} }
169 end
170
171 def after_initialize
172 # Store the fact that project is nil (used in #editable_by?)
173 @is_for_all = project.nil?
169 @is_for_all = project.nil?
174 end
170 end
175
171
176 def validate_query_filters
172 def validate_query_filters
177 filters.each_key do |field|
173 filters.each_key do |field|
178 if values_for(field)
174 if values_for(field)
179 case type_for(field)
175 case type_for(field)
180 when :integer
176 when :integer
181 errors.add(label_for(field), :invalid) if values_for(field).detect {|v| v.present? && !v.match(/^\d+$/) }
177 errors.add(label_for(field), :invalid) if values_for(field).detect {|v| v.present? && !v.match(/^\d+$/) }
182 when :float
178 when :float
183 errors.add(label_for(field), :invalid) if values_for(field).detect {|v| v.present? && !v.match(/^\d+(\.\d*)?$/) }
179 errors.add(label_for(field), :invalid) if values_for(field).detect {|v| v.present? && !v.match(/^\d+(\.\d*)?$/) }
184 when :date, :date_past
180 when :date, :date_past
185 case operator_for(field)
181 case operator_for(field)
186 when "=", ">=", "<=", "><"
182 when "=", ">=", "<=", "><"
187 errors.add(label_for(field), :invalid) if values_for(field).detect {|v| v.present? && (!v.match(/^\d{4}-\d{2}-\d{2}$/) || (Date.parse(v) rescue nil).nil?) }
183 errors.add(label_for(field), :invalid) if values_for(field).detect {|v| v.present? && (!v.match(/^\d{4}-\d{2}-\d{2}$/) || (Date.parse(v) rescue nil).nil?) }
188 when ">t-", "<t-", "t-"
184 when ">t-", "<t-", "t-"
189 errors.add(label_for(field), :invalid) if values_for(field).detect {|v| v.present? && !v.match(/^\d+$/) }
185 errors.add(label_for(field), :invalid) if values_for(field).detect {|v| v.present? && !v.match(/^\d+$/) }
190 end
186 end
191 end
187 end
192 end
188 end
193
189
194 errors.add label_for(field), :blank unless
190 errors.add label_for(field), :blank unless
195 # filter requires one or more values
191 # filter requires one or more values
196 (values_for(field) and !values_for(field).first.blank?) or
192 (values_for(field) and !values_for(field).first.blank?) or
197 # filter doesn't require any value
193 # filter doesn't require any value
198 ["o", "c", "!*", "*", "t", "w"].include? operator_for(field)
194 ["o", "c", "!*", "*", "t", "w"].include? operator_for(field)
199 end if filters
195 end if filters
200 end
196 end
201
197
202 # Returns true if the query is visible to +user+ or the current user.
198 # Returns true if the query is visible to +user+ or the current user.
203 def visible?(user=User.current)
199 def visible?(user=User.current)
204 (project.nil? || user.allowed_to?(:view_issues, project)) && (self.is_public? || self.user_id == user.id)
200 (project.nil? || user.allowed_to?(:view_issues, project)) && (self.is_public? || self.user_id == user.id)
205 end
201 end
206
202
207 def editable_by?(user)
203 def editable_by?(user)
208 return false unless user
204 return false unless user
209 # Admin can edit them all and regular users can edit their private queries
205 # Admin can edit them all and regular users can edit their private queries
210 return true if user.admin? || (!is_public && self.user_id == user.id)
206 return true if user.admin? || (!is_public && self.user_id == user.id)
211 # Members can not edit public queries that are for all project (only admin is allowed to)
207 # Members can not edit public queries that are for all project (only admin is allowed to)
212 is_public && !@is_for_all && user.allowed_to?(:manage_public_queries, project)
208 is_public && !@is_for_all && user.allowed_to?(:manage_public_queries, project)
213 end
209 end
214
210
215 def available_filters
211 def available_filters
216 return @available_filters if @available_filters
212 return @available_filters if @available_filters
217
213
218 trackers = project.nil? ? Tracker.find(:all, :order => 'position') : project.rolled_up_trackers
214 trackers = project.nil? ? Tracker.find(:all, :order => 'position') : project.rolled_up_trackers
219
215
220 @available_filters = { "status_id" => { :type => :list_status, :order => 1, :values => IssueStatus.find(:all, :order => 'position').collect{|s| [s.name, s.id.to_s] } },
216 @available_filters = { "status_id" => { :type => :list_status, :order => 1, :values => IssueStatus.find(:all, :order => 'position').collect{|s| [s.name, s.id.to_s] } },
221 "tracker_id" => { :type => :list, :order => 2, :values => trackers.collect{|s| [s.name, s.id.to_s] } },
217 "tracker_id" => { :type => :list, :order => 2, :values => trackers.collect{|s| [s.name, s.id.to_s] } },
222 "priority_id" => { :type => :list, :order => 3, :values => IssuePriority.all.collect{|s| [s.name, s.id.to_s] } },
218 "priority_id" => { :type => :list, :order => 3, :values => IssuePriority.all.collect{|s| [s.name, s.id.to_s] } },
223 "subject" => { :type => :text, :order => 8 },
219 "subject" => { :type => :text, :order => 8 },
224 "created_on" => { :type => :date_past, :order => 9 },
220 "created_on" => { :type => :date_past, :order => 9 },
225 "updated_on" => { :type => :date_past, :order => 10 },
221 "updated_on" => { :type => :date_past, :order => 10 },
226 "start_date" => { :type => :date, :order => 11 },
222 "start_date" => { :type => :date, :order => 11 },
227 "due_date" => { :type => :date, :order => 12 },
223 "due_date" => { :type => :date, :order => 12 },
228 "estimated_hours" => { :type => :float, :order => 13 },
224 "estimated_hours" => { :type => :float, :order => 13 },
229 "done_ratio" => { :type => :integer, :order => 14 }}
225 "done_ratio" => { :type => :integer, :order => 14 }}
230
226
231 principals = []
227 principals = []
232 if project
228 if project
233 principals += project.principals.sort
229 principals += project.principals.sort
234 else
230 else
235 all_projects = Project.visible.all
231 all_projects = Project.visible.all
236 if all_projects.any?
232 if all_projects.any?
237 # members of visible projects
233 # members of visible projects
238 principals += Principal.active.find(:all, :conditions => ["#{User.table_name}.id IN (SELECT DISTINCT user_id FROM members WHERE project_id IN (?))", all_projects.collect(&:id)]).sort
234 principals += Principal.active.find(:all, :conditions => ["#{User.table_name}.id IN (SELECT DISTINCT user_id FROM members WHERE project_id IN (?))", all_projects.collect(&:id)]).sort
239
235
240 # project filter
236 # project filter
241 project_values = []
237 project_values = []
242 Project.project_tree(all_projects) do |p, level|
238 Project.project_tree(all_projects) do |p, level|
243 prefix = (level > 0 ? ('--' * level + ' ') : '')
239 prefix = (level > 0 ? ('--' * level + ' ') : '')
244 project_values << ["#{prefix}#{p.name}", p.id.to_s]
240 project_values << ["#{prefix}#{p.name}", p.id.to_s]
245 end
241 end
246 @available_filters["project_id"] = { :type => :list, :order => 1, :values => project_values} unless project_values.empty?
242 @available_filters["project_id"] = { :type => :list, :order => 1, :values => project_values} unless project_values.empty?
247 end
243 end
248 end
244 end
249 users = principals.select {|p| p.is_a?(User)}
245 users = principals.select {|p| p.is_a?(User)}
250
246
251 assigned_to_values = []
247 assigned_to_values = []
252 assigned_to_values << ["<< #{l(:label_me)} >>", "me"] if User.current.logged?
248 assigned_to_values << ["<< #{l(:label_me)} >>", "me"] if User.current.logged?
253 assigned_to_values += (Setting.issue_group_assignment? ? principals : users).collect{|s| [s.name, s.id.to_s] }
249 assigned_to_values += (Setting.issue_group_assignment? ? principals : users).collect{|s| [s.name, s.id.to_s] }
254 @available_filters["assigned_to_id"] = { :type => :list_optional, :order => 4, :values => assigned_to_values } unless assigned_to_values.empty?
250 @available_filters["assigned_to_id"] = { :type => :list_optional, :order => 4, :values => assigned_to_values } unless assigned_to_values.empty?
255
251
256 author_values = []
252 author_values = []
257 author_values << ["<< #{l(:label_me)} >>", "me"] if User.current.logged?
253 author_values << ["<< #{l(:label_me)} >>", "me"] if User.current.logged?
258 author_values += users.collect{|s| [s.name, s.id.to_s] }
254 author_values += users.collect{|s| [s.name, s.id.to_s] }
259 @available_filters["author_id"] = { :type => :list, :order => 5, :values => author_values } unless author_values.empty?
255 @available_filters["author_id"] = { :type => :list, :order => 5, :values => author_values } unless author_values.empty?
260
256
261 group_values = Group.all.collect {|g| [g.name, g.id.to_s] }
257 group_values = Group.all.collect {|g| [g.name, g.id.to_s] }
262 @available_filters["member_of_group"] = { :type => :list_optional, :order => 6, :values => group_values } unless group_values.empty?
258 @available_filters["member_of_group"] = { :type => :list_optional, :order => 6, :values => group_values } unless group_values.empty?
263
259
264 role_values = Role.givable.collect {|r| [r.name, r.id.to_s] }
260 role_values = Role.givable.collect {|r| [r.name, r.id.to_s] }
265 @available_filters["assigned_to_role"] = { :type => :list_optional, :order => 7, :values => role_values } unless role_values.empty?
261 @available_filters["assigned_to_role"] = { :type => :list_optional, :order => 7, :values => role_values } unless role_values.empty?
266
262
267 if User.current.logged?
263 if User.current.logged?
268 @available_filters["watcher_id"] = { :type => :list, :order => 15, :values => [["<< #{l(:label_me)} >>", "me"]] }
264 @available_filters["watcher_id"] = { :type => :list, :order => 15, :values => [["<< #{l(:label_me)} >>", "me"]] }
269 end
265 end
270
266
271 if project
267 if project
272 # project specific filters
268 # project specific filters
273 categories = project.issue_categories.all
269 categories = project.issue_categories.all
274 unless categories.empty?
270 unless categories.empty?
275 @available_filters["category_id"] = { :type => :list_optional, :order => 6, :values => categories.collect{|s| [s.name, s.id.to_s] } }
271 @available_filters["category_id"] = { :type => :list_optional, :order => 6, :values => categories.collect{|s| [s.name, s.id.to_s] } }
276 end
272 end
277 versions = project.shared_versions.all
273 versions = project.shared_versions.all
278 unless versions.empty?
274 unless versions.empty?
279 @available_filters["fixed_version_id"] = { :type => :list_optional, :order => 7, :values => versions.sort.collect{|s| ["#{s.project.name} - #{s.name}", s.id.to_s] } }
275 @available_filters["fixed_version_id"] = { :type => :list_optional, :order => 7, :values => versions.sort.collect{|s| ["#{s.project.name} - #{s.name}", s.id.to_s] } }
280 end
276 end
281 unless project.leaf?
277 unless project.leaf?
282 subprojects = project.descendants.visible.all
278 subprojects = project.descendants.visible.all
283 unless subprojects.empty?
279 unless subprojects.empty?
284 @available_filters["subproject_id"] = { :type => :list_subprojects, :order => 13, :values => subprojects.collect{|s| [s.name, s.id.to_s] } }
280 @available_filters["subproject_id"] = { :type => :list_subprojects, :order => 13, :values => subprojects.collect{|s| [s.name, s.id.to_s] } }
285 end
281 end
286 end
282 end
287 add_custom_fields_filters(project.all_issue_custom_fields)
283 add_custom_fields_filters(project.all_issue_custom_fields)
288 else
284 else
289 # global filters for cross project issue list
285 # global filters for cross project issue list
290 system_shared_versions = Version.visible.find_all_by_sharing('system')
286 system_shared_versions = Version.visible.find_all_by_sharing('system')
291 unless system_shared_versions.empty?
287 unless system_shared_versions.empty?
292 @available_filters["fixed_version_id"] = { :type => :list_optional, :order => 7, :values => system_shared_versions.sort.collect{|s| ["#{s.project.name} - #{s.name}", s.id.to_s] } }
288 @available_filters["fixed_version_id"] = { :type => :list_optional, :order => 7, :values => system_shared_versions.sort.collect{|s| ["#{s.project.name} - #{s.name}", s.id.to_s] } }
293 end
289 end
294 add_custom_fields_filters(IssueCustomField.find(:all, :conditions => {:is_filter => true, :is_for_all => true}))
290 add_custom_fields_filters(IssueCustomField.find(:all, :conditions => {:is_filter => true, :is_for_all => true}))
295 end
291 end
296 @available_filters
292 @available_filters
297 end
293 end
298
294
299 def add_filter(field, operator, values)
295 def add_filter(field, operator, values)
300 # values must be an array
296 # values must be an array
301 return unless values.nil? || values.is_a?(Array)
297 return unless values.nil? || values.is_a?(Array)
302 # check if field is defined as an available filter
298 # check if field is defined as an available filter
303 if available_filters.has_key? field
299 if available_filters.has_key? field
304 filter_options = available_filters[field]
300 filter_options = available_filters[field]
305 # check if operator is allowed for that filter
301 # check if operator is allowed for that filter
306 #if @@operators_by_filter_type[filter_options[:type]].include? operator
302 #if @@operators_by_filter_type[filter_options[:type]].include? operator
307 # allowed_values = values & ([""] + (filter_options[:values] || []).collect {|val| val[1]})
303 # allowed_values = values & ([""] + (filter_options[:values] || []).collect {|val| val[1]})
308 # filters[field] = {:operator => operator, :values => allowed_values } if (allowed_values.first and !allowed_values.first.empty?) or ["o", "c", "!*", "*", "t"].include? operator
304 # filters[field] = {:operator => operator, :values => allowed_values } if (allowed_values.first and !allowed_values.first.empty?) or ["o", "c", "!*", "*", "t"].include? operator
309 #end
305 #end
310 filters[field] = {:operator => operator, :values => (values || [''])}
306 filters[field] = {:operator => operator, :values => (values || [''])}
311 end
307 end
312 end
308 end
313
309
314 def add_short_filter(field, expression)
310 def add_short_filter(field, expression)
315 return unless expression && available_filters.has_key?(field)
311 return unless expression && available_filters.has_key?(field)
316 field_type = available_filters[field][:type]
312 field_type = available_filters[field][:type]
317 @@operators_by_filter_type[field_type].sort.reverse.detect do |operator|
313 @@operators_by_filter_type[field_type].sort.reverse.detect do |operator|
318 next unless expression =~ /^#{Regexp.escape(operator)}(.*)$/
314 next unless expression =~ /^#{Regexp.escape(operator)}(.*)$/
319 add_filter field, operator, $1.present? ? $1.split('|') : ['']
315 add_filter field, operator, $1.present? ? $1.split('|') : ['']
320 end || add_filter(field, '=', expression.split('|'))
316 end || add_filter(field, '=', expression.split('|'))
321 end
317 end
322
318
323 # Add multiple filters using +add_filter+
319 # Add multiple filters using +add_filter+
324 def add_filters(fields, operators, values)
320 def add_filters(fields, operators, values)
325 if fields.is_a?(Array) && operators.is_a?(Hash) && (values.nil? || values.is_a?(Hash))
321 if fields.is_a?(Array) && operators.is_a?(Hash) && (values.nil? || values.is_a?(Hash))
326 fields.each do |field|
322 fields.each do |field|
327 add_filter(field, operators[field], values && values[field])
323 add_filter(field, operators[field], values && values[field])
328 end
324 end
329 end
325 end
330 end
326 end
331
327
332 def has_filter?(field)
328 def has_filter?(field)
333 filters and filters[field]
329 filters and filters[field]
334 end
330 end
335
331
336 def type_for(field)
332 def type_for(field)
337 available_filters[field][:type] if available_filters.has_key?(field)
333 available_filters[field][:type] if available_filters.has_key?(field)
338 end
334 end
339
335
340 def operator_for(field)
336 def operator_for(field)
341 has_filter?(field) ? filters[field][:operator] : nil
337 has_filter?(field) ? filters[field][:operator] : nil
342 end
338 end
343
339
344 def values_for(field)
340 def values_for(field)
345 has_filter?(field) ? filters[field][:values] : nil
341 has_filter?(field) ? filters[field][:values] : nil
346 end
342 end
347
343
348 def value_for(field, index=0)
344 def value_for(field, index=0)
349 (values_for(field) || [])[index]
345 (values_for(field) || [])[index]
350 end
346 end
351
347
352 def label_for(field)
348 def label_for(field)
353 label = available_filters[field][:name] if available_filters.has_key?(field)
349 label = available_filters[field][:name] if available_filters.has_key?(field)
354 label ||= field.gsub(/\_id$/, "")
350 label ||= field.gsub(/\_id$/, "")
355 end
351 end
356
352
357 def available_columns
353 def available_columns
358 return @available_columns if @available_columns
354 return @available_columns if @available_columns
359 @available_columns = ::Query.available_columns.dup
355 @available_columns = ::Query.available_columns.dup
360 @available_columns += (project ?
356 @available_columns += (project ?
361 project.all_issue_custom_fields :
357 project.all_issue_custom_fields :
362 IssueCustomField.find(:all)
358 IssueCustomField.find(:all)
363 ).collect {|cf| QueryCustomFieldColumn.new(cf) }
359 ).collect {|cf| QueryCustomFieldColumn.new(cf) }
364
360
365 if User.current.allowed_to?(:view_time_entries, project, :global => true)
361 if User.current.allowed_to?(:view_time_entries, project, :global => true)
366 index = nil
362 index = nil
367 @available_columns.each_with_index {|column, i| index = i if column.name == :estimated_hours}
363 @available_columns.each_with_index {|column, i| index = i if column.name == :estimated_hours}
368 index = (index ? index + 1 : -1)
364 index = (index ? index + 1 : -1)
369 # insert the column after estimated_hours or at the end
365 # insert the column after estimated_hours or at the end
370 @available_columns.insert index, QueryColumn.new(:spent_hours,
366 @available_columns.insert index, QueryColumn.new(:spent_hours,
371 :sortable => "(SELECT COALESCE(SUM(hours), 0) FROM #{TimeEntry.table_name} WHERE #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id)",
367 :sortable => "(SELECT COALESCE(SUM(hours), 0) FROM #{TimeEntry.table_name} WHERE #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id)",
372 :default_order => 'desc',
368 :default_order => 'desc',
373 :caption => :label_spent_time
369 :caption => :label_spent_time
374 )
370 )
375 end
371 end
376 @available_columns
372 @available_columns
377 end
373 end
378
374
379 def self.available_columns=(v)
375 def self.available_columns=(v)
380 self.available_columns = (v)
376 self.available_columns = (v)
381 end
377 end
382
378
383 def self.add_available_column(column)
379 def self.add_available_column(column)
384 self.available_columns << (column) if column.is_a?(QueryColumn)
380 self.available_columns << (column) if column.is_a?(QueryColumn)
385 end
381 end
386
382
387 # Returns an array of columns that can be used to group the results
383 # Returns an array of columns that can be used to group the results
388 def groupable_columns
384 def groupable_columns
389 available_columns.select {|c| c.groupable}
385 available_columns.select {|c| c.groupable}
390 end
386 end
391
387
392 # Returns a Hash of columns and the key for sorting
388 # Returns a Hash of columns and the key for sorting
393 def sortable_columns
389 def sortable_columns
394 {'id' => "#{Issue.table_name}.id"}.merge(available_columns.inject({}) {|h, column|
390 {'id' => "#{Issue.table_name}.id"}.merge(available_columns.inject({}) {|h, column|
395 h[column.name.to_s] = column.sortable
391 h[column.name.to_s] = column.sortable
396 h
392 h
397 })
393 })
398 end
394 end
399
395
400 def columns
396 def columns
401 # preserve the column_names order
397 # preserve the column_names order
402 (has_default_columns? ? default_columns_names : column_names).collect do |name|
398 (has_default_columns? ? default_columns_names : column_names).collect do |name|
403 available_columns.find { |col| col.name == name }
399 available_columns.find { |col| col.name == name }
404 end.compact
400 end.compact
405 end
401 end
406
402
407 def default_columns_names
403 def default_columns_names
408 @default_columns_names ||= begin
404 @default_columns_names ||= begin
409 default_columns = Setting.issue_list_default_columns.map(&:to_sym)
405 default_columns = Setting.issue_list_default_columns.map(&:to_sym)
410
406
411 project.present? ? default_columns : [:project] | default_columns
407 project.present? ? default_columns : [:project] | default_columns
412 end
408 end
413 end
409 end
414
410
415 def column_names=(names)
411 def column_names=(names)
416 if names
412 if names
417 names = names.select {|n| n.is_a?(Symbol) || !n.blank? }
413 names = names.select {|n| n.is_a?(Symbol) || !n.blank? }
418 names = names.collect {|n| n.is_a?(Symbol) ? n : n.to_sym }
414 names = names.collect {|n| n.is_a?(Symbol) ? n : n.to_sym }
419 # Set column_names to nil if default columns
415 # Set column_names to nil if default columns
420 if names == default_columns_names
416 if names == default_columns_names
421 names = nil
417 names = nil
422 end
418 end
423 end
419 end
424 write_attribute(:column_names, names)
420 write_attribute(:column_names, names)
425 end
421 end
426
422
427 def has_column?(column)
423 def has_column?(column)
428 column_names && column_names.include?(column.is_a?(QueryColumn) ? column.name : column)
424 column_names && column_names.include?(column.is_a?(QueryColumn) ? column.name : column)
429 end
425 end
430
426
431 def has_default_columns?
427 def has_default_columns?
432 column_names.nil? || column_names.empty?
428 column_names.nil? || column_names.empty?
433 end
429 end
434
430
435 def sort_criteria=(arg)
431 def sort_criteria=(arg)
436 c = []
432 c = []
437 if arg.is_a?(Hash)
433 if arg.is_a?(Hash)
438 arg = arg.keys.sort.collect {|k| arg[k]}
434 arg = arg.keys.sort.collect {|k| arg[k]}
439 end
435 end
440 c = arg.select {|k,o| !k.to_s.blank?}.slice(0,3).collect {|k,o| [k.to_s, o == 'desc' ? o : 'asc']}
436 c = arg.select {|k,o| !k.to_s.blank?}.slice(0,3).collect {|k,o| [k.to_s, o == 'desc' ? o : 'asc']}
441 write_attribute(:sort_criteria, c)
437 write_attribute(:sort_criteria, c)
442 end
438 end
443
439
444 def sort_criteria
440 def sort_criteria
445 read_attribute(:sort_criteria) || []
441 read_attribute(:sort_criteria) || []
446 end
442 end
447
443
448 def sort_criteria_key(arg)
444 def sort_criteria_key(arg)
449 sort_criteria && sort_criteria[arg] && sort_criteria[arg].first
445 sort_criteria && sort_criteria[arg] && sort_criteria[arg].first
450 end
446 end
451
447
452 def sort_criteria_order(arg)
448 def sort_criteria_order(arg)
453 sort_criteria && sort_criteria[arg] && sort_criteria[arg].last
449 sort_criteria && sort_criteria[arg] && sort_criteria[arg].last
454 end
450 end
455
451
456 # Returns the SQL sort order that should be prepended for grouping
452 # Returns the SQL sort order that should be prepended for grouping
457 def group_by_sort_order
453 def group_by_sort_order
458 if grouped? && (column = group_by_column)
454 if grouped? && (column = group_by_column)
459 column.sortable.is_a?(Array) ?
455 column.sortable.is_a?(Array) ?
460 column.sortable.collect {|s| "#{s} #{column.default_order}"}.join(',') :
456 column.sortable.collect {|s| "#{s} #{column.default_order}"}.join(',') :
461 "#{column.sortable} #{column.default_order}"
457 "#{column.sortable} #{column.default_order}"
462 end
458 end
463 end
459 end
464
460
465 # Returns true if the query is a grouped query
461 # Returns true if the query is a grouped query
466 def grouped?
462 def grouped?
467 !group_by_column.nil?
463 !group_by_column.nil?
468 end
464 end
469
465
470 def group_by_column
466 def group_by_column
471 groupable_columns.detect {|c| c.groupable && c.name.to_s == group_by}
467 groupable_columns.detect {|c| c.groupable && c.name.to_s == group_by}
472 end
468 end
473
469
474 def group_by_statement
470 def group_by_statement
475 group_by_column.try(:groupable)
471 group_by_column.try(:groupable)
476 end
472 end
477
473
478 def project_statement
474 def project_statement
479 project_clauses = []
475 project_clauses = []
480 if project && !project.descendants.active.empty?
476 if project && !project.descendants.active.empty?
481 ids = [project.id]
477 ids = [project.id]
482 if has_filter?("subproject_id")
478 if has_filter?("subproject_id")
483 case operator_for("subproject_id")
479 case operator_for("subproject_id")
484 when '='
480 when '='
485 # include the selected subprojects
481 # include the selected subprojects
486 ids += values_for("subproject_id").each(&:to_i)
482 ids += values_for("subproject_id").each(&:to_i)
487 when '!*'
483 when '!*'
488 # main project only
484 # main project only
489 else
485 else
490 # all subprojects
486 # all subprojects
491 ids += project.descendants.collect(&:id)
487 ids += project.descendants.collect(&:id)
492 end
488 end
493 elsif Setting.display_subprojects_issues?
489 elsif Setting.display_subprojects_issues?
494 ids += project.descendants.collect(&:id)
490 ids += project.descendants.collect(&:id)
495 end
491 end
496 project_clauses << "#{Project.table_name}.id IN (%s)" % ids.join(',')
492 project_clauses << "#{Project.table_name}.id IN (%s)" % ids.join(',')
497 elsif project
493 elsif project
498 project_clauses << "#{Project.table_name}.id = %d" % project.id
494 project_clauses << "#{Project.table_name}.id = %d" % project.id
499 end
495 end
500 project_clauses.any? ? project_clauses.join(' AND ') : nil
496 project_clauses.any? ? project_clauses.join(' AND ') : nil
501 end
497 end
502
498
503 def statement
499 def statement
504 # filters clauses
500 # filters clauses
505 filters_clauses = []
501 filters_clauses = []
506 filters.each_key do |field|
502 filters.each_key do |field|
507 next if field == "subproject_id"
503 next if field == "subproject_id"
508 v = values_for(field).clone
504 v = values_for(field).clone
509 next unless v and !v.empty?
505 next unless v and !v.empty?
510 operator = operator_for(field)
506 operator = operator_for(field)
511
507
512 # "me" value subsitution
508 # "me" value subsitution
513 if %w(assigned_to_id author_id watcher_id).include?(field)
509 if %w(assigned_to_id author_id watcher_id).include?(field)
514 if v.delete("me")
510 if v.delete("me")
515 if User.current.logged?
511 if User.current.logged?
516 v.push(User.current.id.to_s)
512 v.push(User.current.id.to_s)
517 v += User.current.group_ids.map(&:to_s) if field == 'assigned_to_id'
513 v += User.current.group_ids.map(&:to_s) if field == 'assigned_to_id'
518 else
514 else
519 v.push("0")
515 v.push("0")
520 end
516 end
521 end
517 end
522 end
518 end
523
519
524 if field =~ /^cf_(\d+)$/
520 if field =~ /^cf_(\d+)$/
525 # custom field
521 # custom field
526 filters_clauses << sql_for_custom_field(field, operator, v, $1)
522 filters_clauses << sql_for_custom_field(field, operator, v, $1)
527 elsif respond_to?("sql_for_#{field}_field")
523 elsif respond_to?("sql_for_#{field}_field")
528 # specific statement
524 # specific statement
529 filters_clauses << send("sql_for_#{field}_field", field, operator, v)
525 filters_clauses << send("sql_for_#{field}_field", field, operator, v)
530 else
526 else
531 # regular field
527 # regular field
532 filters_clauses << '(' + sql_for_field(field, operator, v, Issue.table_name, field) + ')'
528 filters_clauses << '(' + sql_for_field(field, operator, v, Issue.table_name, field) + ')'
533 end
529 end
534 end if filters and valid?
530 end if filters and valid?
535
531
536 filters_clauses << project_statement
532 filters_clauses << project_statement
537 filters_clauses.reject!(&:blank?)
533 filters_clauses.reject!(&:blank?)
538
534
539 filters_clauses.any? ? filters_clauses.join(' AND ') : nil
535 filters_clauses.any? ? filters_clauses.join(' AND ') : nil
540 end
536 end
541
537
542 # Returns the issue count
538 # Returns the issue count
543 def issue_count
539 def issue_count
544 Issue.visible.count(:include => [:status, :project], :conditions => statement)
540 Issue.visible.count(:include => [:status, :project], :conditions => statement)
545 rescue ::ActiveRecord::StatementInvalid => e
541 rescue ::ActiveRecord::StatementInvalid => e
546 raise StatementInvalid.new(e.message)
542 raise StatementInvalid.new(e.message)
547 end
543 end
548
544
549 # Returns the issue count by group or nil if query is not grouped
545 # Returns the issue count by group or nil if query is not grouped
550 def issue_count_by_group
546 def issue_count_by_group
551 r = nil
547 r = nil
552 if grouped?
548 if grouped?
553 begin
549 begin
554 # Rails will raise an (unexpected) RecordNotFound if there's only a nil group value
550 # Rails will raise an (unexpected) RecordNotFound if there's only a nil group value
555 r = Issue.visible.count(:group => group_by_statement, :include => [:status, :project], :conditions => statement)
551 r = Issue.visible.count(:group => group_by_statement, :include => [:status, :project], :conditions => statement)
556 rescue ActiveRecord::RecordNotFound
552 rescue ActiveRecord::RecordNotFound
557 r = {nil => issue_count}
553 r = {nil => issue_count}
558 end
554 end
559 c = group_by_column
555 c = group_by_column
560 if c.is_a?(QueryCustomFieldColumn)
556 if c.is_a?(QueryCustomFieldColumn)
561 r = r.keys.inject({}) {|h, k| h[c.custom_field.cast_value(k)] = r[k]; h}
557 r = r.keys.inject({}) {|h, k| h[c.custom_field.cast_value(k)] = r[k]; h}
562 end
558 end
563 end
559 end
564 r
560 r
565 rescue ::ActiveRecord::StatementInvalid => e
561 rescue ::ActiveRecord::StatementInvalid => e
566 raise StatementInvalid.new(e.message)
562 raise StatementInvalid.new(e.message)
567 end
563 end
568
564
569 # Returns the issues
565 # Returns the issues
570 # Valid options are :order, :offset, :limit, :include, :conditions
566 # Valid options are :order, :offset, :limit, :include, :conditions
571 def issues(options={})
567 def issues(options={})
572 order_option = [group_by_sort_order, options[:order]].reject {|s| s.blank?}.join(',')
568 order_option = [group_by_sort_order, options[:order]].reject {|s| s.blank?}.join(',')
573 order_option = nil if order_option.blank?
569 order_option = nil if order_option.blank?
574
570
575 joins = (order_option && order_option.include?('authors')) ? "LEFT OUTER JOIN users authors ON authors.id = #{Issue.table_name}.author_id" : nil
571 joins = (order_option && order_option.include?('authors')) ? "LEFT OUTER JOIN users authors ON authors.id = #{Issue.table_name}.author_id" : nil
576
572
577 issues = Issue.visible.scoped(:conditions => options[:conditions]).find :all, :include => ([:status, :project] + (options[:include] || [])).uniq,
573 issues = Issue.visible.scoped(:conditions => options[:conditions]).find :all, :include => ([:status, :project] + (options[:include] || [])).uniq,
578 :conditions => statement,
574 :conditions => statement,
579 :order => order_option,
575 :order => order_option,
580 :joins => joins,
576 :joins => joins,
581 :limit => options[:limit],
577 :limit => options[:limit],
582 :offset => options[:offset]
578 :offset => options[:offset]
583
579
584 if has_column?(:spent_hours)
580 if has_column?(:spent_hours)
585 Issue.load_visible_spent_hours(issues)
581 Issue.load_visible_spent_hours(issues)
586 end
582 end
587 issues
583 issues
588 rescue ::ActiveRecord::StatementInvalid => e
584 rescue ::ActiveRecord::StatementInvalid => e
589 raise StatementInvalid.new(e.message)
585 raise StatementInvalid.new(e.message)
590 end
586 end
591
587
592 # Returns the journals
588 # Returns the journals
593 # Valid options are :order, :offset, :limit
589 # Valid options are :order, :offset, :limit
594 def journals(options={})
590 def journals(options={})
595 Journal.visible.find :all, :include => [:details, :user, {:issue => [:project, :author, :tracker, :status]}],
591 Journal.visible.find :all, :include => [:details, :user, {:issue => [:project, :author, :tracker, :status]}],
596 :conditions => statement,
592 :conditions => statement,
597 :order => options[:order],
593 :order => options[:order],
598 :limit => options[:limit],
594 :limit => options[:limit],
599 :offset => options[:offset]
595 :offset => options[:offset]
600 rescue ::ActiveRecord::StatementInvalid => e
596 rescue ::ActiveRecord::StatementInvalid => e
601 raise StatementInvalid.new(e.message)
597 raise StatementInvalid.new(e.message)
602 end
598 end
603
599
604 # Returns the versions
600 # Returns the versions
605 # Valid options are :conditions
601 # Valid options are :conditions
606 def versions(options={})
602 def versions(options={})
607 Version.visible.scoped(:conditions => options[:conditions]).find :all, :include => :project, :conditions => project_statement
603 Version.visible.scoped(:conditions => options[:conditions]).find :all, :include => :project, :conditions => project_statement
608 rescue ::ActiveRecord::StatementInvalid => e
604 rescue ::ActiveRecord::StatementInvalid => e
609 raise StatementInvalid.new(e.message)
605 raise StatementInvalid.new(e.message)
610 end
606 end
611
607
612 def sql_for_watcher_id_field(field, operator, value)
608 def sql_for_watcher_id_field(field, operator, value)
613 db_table = Watcher.table_name
609 db_table = Watcher.table_name
614 "#{Issue.table_name}.id #{ operator == '=' ? 'IN' : 'NOT IN' } (SELECT #{db_table}.watchable_id FROM #{db_table} WHERE #{db_table}.watchable_type='Issue' AND " +
610 "#{Issue.table_name}.id #{ operator == '=' ? 'IN' : 'NOT IN' } (SELECT #{db_table}.watchable_id FROM #{db_table} WHERE #{db_table}.watchable_type='Issue' AND " +
615 sql_for_field(field, '=', value, db_table, 'user_id') + ')'
611 sql_for_field(field, '=', value, db_table, 'user_id') + ')'
616 end
612 end
617
613
618 def sql_for_member_of_group_field(field, operator, value)
614 def sql_for_member_of_group_field(field, operator, value)
619 if operator == '*' # Any group
615 if operator == '*' # Any group
620 groups = Group.all
616 groups = Group.all
621 operator = '=' # Override the operator since we want to find by assigned_to
617 operator = '=' # Override the operator since we want to find by assigned_to
622 elsif operator == "!*"
618 elsif operator == "!*"
623 groups = Group.all
619 groups = Group.all
624 operator = '!' # Override the operator since we want to find by assigned_to
620 operator = '!' # Override the operator since we want to find by assigned_to
625 else
621 else
626 groups = Group.find_all_by_id(value)
622 groups = Group.find_all_by_id(value)
627 end
623 end
628 groups ||= []
624 groups ||= []
629
625
630 members_of_groups = groups.inject([]) {|user_ids, group|
626 members_of_groups = groups.inject([]) {|user_ids, group|
631 if group && group.user_ids.present?
627 if group && group.user_ids.present?
632 user_ids << group.user_ids
628 user_ids << group.user_ids
633 end
629 end
634 user_ids.flatten.uniq.compact
630 user_ids.flatten.uniq.compact
635 }.sort.collect(&:to_s)
631 }.sort.collect(&:to_s)
636
632
637 '(' + sql_for_field("assigned_to_id", operator, members_of_groups, Issue.table_name, "assigned_to_id", false) + ')'
633 '(' + sql_for_field("assigned_to_id", operator, members_of_groups, Issue.table_name, "assigned_to_id", false) + ')'
638 end
634 end
639
635
640 def sql_for_assigned_to_role_field(field, operator, value)
636 def sql_for_assigned_to_role_field(field, operator, value)
641 case operator
637 case operator
642 when "*", "!*" # Member / Not member
638 when "*", "!*" # Member / Not member
643 sw = operator == "!*" ? 'NOT' : ''
639 sw = operator == "!*" ? 'NOT' : ''
644 nl = operator == "!*" ? "#{Issue.table_name}.assigned_to_id IS NULL OR" : ''
640 nl = operator == "!*" ? "#{Issue.table_name}.assigned_to_id IS NULL OR" : ''
645 "(#{nl} #{Issue.table_name}.assigned_to_id #{sw} IN (SELECT DISTINCT #{Member.table_name}.user_id FROM #{Member.table_name}" +
641 "(#{nl} #{Issue.table_name}.assigned_to_id #{sw} IN (SELECT DISTINCT #{Member.table_name}.user_id FROM #{Member.table_name}" +
646 " WHERE #{Member.table_name}.project_id = #{Issue.table_name}.project_id))"
642 " WHERE #{Member.table_name}.project_id = #{Issue.table_name}.project_id))"
647 when "=", "!"
643 when "=", "!"
648 role_cond = value.any? ?
644 role_cond = value.any? ?
649 "#{MemberRole.table_name}.role_id IN (" + value.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + ")" :
645 "#{MemberRole.table_name}.role_id IN (" + value.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + ")" :
650 "1=0"
646 "1=0"
651
647
652 sw = operator == "!" ? 'NOT' : ''
648 sw = operator == "!" ? 'NOT' : ''
653 nl = operator == "!" ? "#{Issue.table_name}.assigned_to_id IS NULL OR" : ''
649 nl = operator == "!" ? "#{Issue.table_name}.assigned_to_id IS NULL OR" : ''
654 "(#{nl} #{Issue.table_name}.assigned_to_id #{sw} IN (SELECT DISTINCT #{Member.table_name}.user_id FROM #{Member.table_name}, #{MemberRole.table_name}" +
650 "(#{nl} #{Issue.table_name}.assigned_to_id #{sw} IN (SELECT DISTINCT #{Member.table_name}.user_id FROM #{Member.table_name}, #{MemberRole.table_name}" +
655 " WHERE #{Member.table_name}.project_id = #{Issue.table_name}.project_id AND #{Member.table_name}.id = #{MemberRole.table_name}.member_id AND #{role_cond}))"
651 " WHERE #{Member.table_name}.project_id = #{Issue.table_name}.project_id AND #{Member.table_name}.id = #{MemberRole.table_name}.member_id AND #{role_cond}))"
656 end
652 end
657 end
653 end
658
654
659 private
655 private
660
656
661 def sql_for_custom_field(field, operator, value, custom_field_id)
657 def sql_for_custom_field(field, operator, value, custom_field_id)
662 db_table = CustomValue.table_name
658 db_table = CustomValue.table_name
663 db_field = 'value'
659 db_field = 'value'
664 "#{Issue.table_name}.id IN (SELECT #{Issue.table_name}.id FROM #{Issue.table_name} LEFT OUTER JOIN #{db_table} ON #{db_table}.customized_type='Issue' AND #{db_table}.customized_id=#{Issue.table_name}.id AND #{db_table}.custom_field_id=#{custom_field_id} WHERE " +
660 "#{Issue.table_name}.id IN (SELECT #{Issue.table_name}.id FROM #{Issue.table_name} LEFT OUTER JOIN #{db_table} ON #{db_table}.customized_type='Issue' AND #{db_table}.customized_id=#{Issue.table_name}.id AND #{db_table}.custom_field_id=#{custom_field_id} WHERE " +
665 sql_for_field(field, operator, value, db_table, db_field, true) + ')'
661 sql_for_field(field, operator, value, db_table, db_field, true) + ')'
666 end
662 end
667
663
668 # Helper method to generate the WHERE sql for a +field+, +operator+ and a +value+
664 # Helper method to generate the WHERE sql for a +field+, +operator+ and a +value+
669 def sql_for_field(field, operator, value, db_table, db_field, is_custom_filter=false)
665 def sql_for_field(field, operator, value, db_table, db_field, is_custom_filter=false)
670 sql = ''
666 sql = ''
671 case operator
667 case operator
672 when "="
668 when "="
673 if value.any?
669 if value.any?
674 case type_for(field)
670 case type_for(field)
675 when :date, :date_past
671 when :date, :date_past
676 sql = date_clause(db_table, db_field, (Date.parse(value.first) rescue nil), (Date.parse(value.first) rescue nil))
672 sql = date_clause(db_table, db_field, (Date.parse(value.first) rescue nil), (Date.parse(value.first) rescue nil))
677 when :integer
673 when :integer
678 if is_custom_filter
674 if is_custom_filter
679 sql = "(#{db_table}.#{db_field} <> '' AND CAST(#{db_table}.#{db_field} AS decimal(60,3)) = #{value.first.to_i})"
675 sql = "(#{db_table}.#{db_field} <> '' AND CAST(#{db_table}.#{db_field} AS decimal(60,3)) = #{value.first.to_i})"
680 else
676 else
681 sql = "#{db_table}.#{db_field} = #{value.first.to_i}"
677 sql = "#{db_table}.#{db_field} = #{value.first.to_i}"
682 end
678 end
683 when :float
679 when :float
684 if is_custom_filter
680 if is_custom_filter
685 sql = "(#{db_table}.#{db_field} <> '' AND CAST(#{db_table}.#{db_field} AS decimal(60,3)) BETWEEN #{value.first.to_f - 1e-5} AND #{value.first.to_f + 1e-5})"
681 sql = "(#{db_table}.#{db_field} <> '' AND CAST(#{db_table}.#{db_field} AS decimal(60,3)) BETWEEN #{value.first.to_f - 1e-5} AND #{value.first.to_f + 1e-5})"
686 else
682 else
687 sql = "#{db_table}.#{db_field} BETWEEN #{value.first.to_f - 1e-5} AND #{value.first.to_f + 1e-5}"
683 sql = "#{db_table}.#{db_field} BETWEEN #{value.first.to_f - 1e-5} AND #{value.first.to_f + 1e-5}"
688 end
684 end
689 else
685 else
690 sql = "#{db_table}.#{db_field} IN (" + value.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + ")"
686 sql = "#{db_table}.#{db_field} IN (" + value.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + ")"
691 end
687 end
692 else
688 else
693 # IN an empty set
689 # IN an empty set
694 sql = "1=0"
690 sql = "1=0"
695 end
691 end
696 when "!"
692 when "!"
697 if value.any?
693 if value.any?
698 sql = "(#{db_table}.#{db_field} IS NULL OR #{db_table}.#{db_field} NOT IN (" + value.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + "))"
694 sql = "(#{db_table}.#{db_field} IS NULL OR #{db_table}.#{db_field} NOT IN (" + value.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + "))"
699 else
695 else
700 # NOT IN an empty set
696 # NOT IN an empty set
701 sql = "1=1"
697 sql = "1=1"
702 end
698 end
703 when "!*"
699 when "!*"
704 sql = "#{db_table}.#{db_field} IS NULL"
700 sql = "#{db_table}.#{db_field} IS NULL"
705 sql << " OR #{db_table}.#{db_field} = ''" if is_custom_filter
701 sql << " OR #{db_table}.#{db_field} = ''" if is_custom_filter
706 when "*"
702 when "*"
707 sql = "#{db_table}.#{db_field} IS NOT NULL"
703 sql = "#{db_table}.#{db_field} IS NOT NULL"
708 sql << " AND #{db_table}.#{db_field} <> ''" if is_custom_filter
704 sql << " AND #{db_table}.#{db_field} <> ''" if is_custom_filter
709 when ">="
705 when ">="
710 if [:date, :date_past].include?(type_for(field))
706 if [:date, :date_past].include?(type_for(field))
711 sql = date_clause(db_table, db_field, (Date.parse(value.first) rescue nil), nil)
707 sql = date_clause(db_table, db_field, (Date.parse(value.first) rescue nil), nil)
712 else
708 else
713 if is_custom_filter
709 if is_custom_filter
714 sql = "(#{db_table}.#{db_field} <> '' AND CAST(#{db_table}.#{db_field} AS decimal(60,3)) >= #{value.first.to_f})"
710 sql = "(#{db_table}.#{db_field} <> '' AND CAST(#{db_table}.#{db_field} AS decimal(60,3)) >= #{value.first.to_f})"
715 else
711 else
716 sql = "#{db_table}.#{db_field} >= #{value.first.to_f}"
712 sql = "#{db_table}.#{db_field} >= #{value.first.to_f}"
717 end
713 end
718 end
714 end
719 when "<="
715 when "<="
720 if [:date, :date_past].include?(type_for(field))
716 if [:date, :date_past].include?(type_for(field))
721 sql = date_clause(db_table, db_field, nil, (Date.parse(value.first) rescue nil))
717 sql = date_clause(db_table, db_field, nil, (Date.parse(value.first) rescue nil))
722 else
718 else
723 if is_custom_filter
719 if is_custom_filter
724 sql = "(#{db_table}.#{db_field} <> '' AND CAST(#{db_table}.#{db_field} AS decimal(60,3)) <= #{value.first.to_f})"
720 sql = "(#{db_table}.#{db_field} <> '' AND CAST(#{db_table}.#{db_field} AS decimal(60,3)) <= #{value.first.to_f})"
725 else
721 else
726 sql = "#{db_table}.#{db_field} <= #{value.first.to_f}"
722 sql = "#{db_table}.#{db_field} <= #{value.first.to_f}"
727 end
723 end
728 end
724 end
729 when "><"
725 when "><"
730 if [:date, :date_past].include?(type_for(field))
726 if [:date, :date_past].include?(type_for(field))
731 sql = date_clause(db_table, db_field, (Date.parse(value[0]) rescue nil), (Date.parse(value[1]) rescue nil))
727 sql = date_clause(db_table, db_field, (Date.parse(value[0]) rescue nil), (Date.parse(value[1]) rescue nil))
732 else
728 else
733 if is_custom_filter
729 if is_custom_filter
734 sql = "(#{db_table}.#{db_field} <> '' AND CAST(#{db_table}.#{db_field} AS decimal(60,3)) BETWEEN #{value[0].to_f} AND #{value[1].to_f})"
730 sql = "(#{db_table}.#{db_field} <> '' AND CAST(#{db_table}.#{db_field} AS decimal(60,3)) BETWEEN #{value[0].to_f} AND #{value[1].to_f})"
735 else
731 else
736 sql = "#{db_table}.#{db_field} BETWEEN #{value[0].to_f} AND #{value[1].to_f}"
732 sql = "#{db_table}.#{db_field} BETWEEN #{value[0].to_f} AND #{value[1].to_f}"
737 end
733 end
738 end
734 end
739 when "o"
735 when "o"
740 sql = "#{IssueStatus.table_name}.is_closed=#{connection.quoted_false}" if field == "status_id"
736 sql = "#{IssueStatus.table_name}.is_closed=#{connection.quoted_false}" if field == "status_id"
741 when "c"
737 when "c"
742 sql = "#{IssueStatus.table_name}.is_closed=#{connection.quoted_true}" if field == "status_id"
738 sql = "#{IssueStatus.table_name}.is_closed=#{connection.quoted_true}" if field == "status_id"
743 when ">t-"
739 when ">t-"
744 sql = relative_date_clause(db_table, db_field, - value.first.to_i, 0)
740 sql = relative_date_clause(db_table, db_field, - value.first.to_i, 0)
745 when "<t-"
741 when "<t-"
746 sql = relative_date_clause(db_table, db_field, nil, - value.first.to_i)
742 sql = relative_date_clause(db_table, db_field, nil, - value.first.to_i)
747 when "t-"
743 when "t-"
748 sql = relative_date_clause(db_table, db_field, - value.first.to_i, - value.first.to_i)
744 sql = relative_date_clause(db_table, db_field, - value.first.to_i, - value.first.to_i)
749 when ">t+"
745 when ">t+"
750 sql = relative_date_clause(db_table, db_field, value.first.to_i, nil)
746 sql = relative_date_clause(db_table, db_field, value.first.to_i, nil)
751 when "<t+"
747 when "<t+"
752 sql = relative_date_clause(db_table, db_field, 0, value.first.to_i)
748 sql = relative_date_clause(db_table, db_field, 0, value.first.to_i)
753 when "t+"
749 when "t+"
754 sql = relative_date_clause(db_table, db_field, value.first.to_i, value.first.to_i)
750 sql = relative_date_clause(db_table, db_field, value.first.to_i, value.first.to_i)
755 when "t"
751 when "t"
756 sql = relative_date_clause(db_table, db_field, 0, 0)
752 sql = relative_date_clause(db_table, db_field, 0, 0)
757 when "w"
753 when "w"
758 first_day_of_week = l(:general_first_day_of_week).to_i
754 first_day_of_week = l(:general_first_day_of_week).to_i
759 day_of_week = Date.today.cwday
755 day_of_week = Date.today.cwday
760 days_ago = (day_of_week >= first_day_of_week ? day_of_week - first_day_of_week : day_of_week + 7 - first_day_of_week)
756 days_ago = (day_of_week >= first_day_of_week ? day_of_week - first_day_of_week : day_of_week + 7 - first_day_of_week)
761 sql = relative_date_clause(db_table, db_field, - days_ago, - days_ago + 6)
757 sql = relative_date_clause(db_table, db_field, - days_ago, - days_ago + 6)
762 when "~"
758 when "~"
763 sql = "LOWER(#{db_table}.#{db_field}) LIKE '%#{connection.quote_string(value.first.to_s.downcase)}%'"
759 sql = "LOWER(#{db_table}.#{db_field}) LIKE '%#{connection.quote_string(value.first.to_s.downcase)}%'"
764 when "!~"
760 when "!~"
765 sql = "LOWER(#{db_table}.#{db_field}) NOT LIKE '%#{connection.quote_string(value.first.to_s.downcase)}%'"
761 sql = "LOWER(#{db_table}.#{db_field}) NOT LIKE '%#{connection.quote_string(value.first.to_s.downcase)}%'"
766 else
762 else
767 raise "Unknown query operator #{operator}"
763 raise "Unknown query operator #{operator}"
768 end
764 end
769
765
770 return sql
766 return sql
771 end
767 end
772
768
773 def add_custom_fields_filters(custom_fields)
769 def add_custom_fields_filters(custom_fields)
774 @available_filters ||= {}
770 @available_filters ||= {}
775
771
776 custom_fields.select(&:is_filter?).each do |field|
772 custom_fields.select(&:is_filter?).each do |field|
777 case field.field_format
773 case field.field_format
778 when "text"
774 when "text"
779 options = { :type => :text, :order => 20 }
775 options = { :type => :text, :order => 20 }
780 when "list"
776 when "list"
781 options = { :type => :list_optional, :values => field.possible_values, :order => 20}
777 options = { :type => :list_optional, :values => field.possible_values, :order => 20}
782 when "date"
778 when "date"
783 options = { :type => :date, :order => 20 }
779 options = { :type => :date, :order => 20 }
784 when "bool"
780 when "bool"
785 options = { :type => :list, :values => [[l(:general_text_yes), "1"], [l(:general_text_no), "0"]], :order => 20 }
781 options = { :type => :list, :values => [[l(:general_text_yes), "1"], [l(:general_text_no), "0"]], :order => 20 }
786 when "int"
782 when "int"
787 options = { :type => :integer, :order => 20 }
783 options = { :type => :integer, :order => 20 }
788 when "float"
784 when "float"
789 options = { :type => :float, :order => 20 }
785 options = { :type => :float, :order => 20 }
790 when "user", "version"
786 when "user", "version"
791 next unless project
787 next unless project
792 options = { :type => :list_optional, :values => field.possible_values_options(project), :order => 20}
788 options = { :type => :list_optional, :values => field.possible_values_options(project), :order => 20}
793 else
789 else
794 options = { :type => :string, :order => 20 }
790 options = { :type => :string, :order => 20 }
795 end
791 end
796 @available_filters["cf_#{field.id}"] = options.merge({ :name => field.name })
792 @available_filters["cf_#{field.id}"] = options.merge({ :name => field.name })
797 end
793 end
798 end
794 end
799
795
800 # Returns a SQL clause for a date or datetime field.
796 # Returns a SQL clause for a date or datetime field.
801 def date_clause(table, field, from, to)
797 def date_clause(table, field, from, to)
802 s = []
798 s = []
803 if from
799 if from
804 from_yesterday = from - 1
800 from_yesterday = from - 1
805 from_yesterday_utc = Time.gm(from_yesterday.year, from_yesterday.month, from_yesterday.day)
801 from_yesterday_utc = Time.gm(from_yesterday.year, from_yesterday.month, from_yesterday.day)
806 s << ("#{table}.#{field} > '%s'" % [connection.quoted_date(from_yesterday_utc.end_of_day)])
802 s << ("#{table}.#{field} > '%s'" % [connection.quoted_date(from_yesterday_utc.end_of_day)])
807 end
803 end
808 if to
804 if to
809 to_utc = Time.gm(to.year, to.month, to.day)
805 to_utc = Time.gm(to.year, to.month, to.day)
810 s << ("#{table}.#{field} <= '%s'" % [connection.quoted_date(to_utc.end_of_day)])
806 s << ("#{table}.#{field} <= '%s'" % [connection.quoted_date(to_utc.end_of_day)])
811 end
807 end
812 s.join(' AND ')
808 s.join(' AND ')
813 end
809 end
814
810
815 # Returns a SQL clause for a date or datetime field using relative dates.
811 # Returns a SQL clause for a date or datetime field using relative dates.
816 def relative_date_clause(table, field, days_from, days_to)
812 def relative_date_clause(table, field, days_from, days_to)
817 date_clause(table, field, (days_from ? Date.today + days_from : nil), (days_to ? Date.today + days_to : nil))
813 date_clause(table, field, (days_from ? Date.today + days_from : nil), (days_to ? Date.today + days_to : nil))
818 end
814 end
819 end
815 end
@@ -1,107 +1,108
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class TimeEntry < ActiveRecord::Base
18 class TimeEntry < ActiveRecord::Base
19 # could have used polymorphic association
19 # could have used polymorphic association
20 # project association here allows easy loading of time entries at project level with one database trip
20 # project association here allows easy loading of time entries at project level with one database trip
21 belongs_to :project
21 belongs_to :project
22 belongs_to :issue
22 belongs_to :issue
23 belongs_to :user
23 belongs_to :user
24 belongs_to :activity, :class_name => 'TimeEntryActivity', :foreign_key => 'activity_id'
24 belongs_to :activity, :class_name => 'TimeEntryActivity', :foreign_key => 'activity_id'
25
25
26 attr_protected :project_id, :user_id, :tyear, :tmonth, :tweek
26 attr_protected :project_id, :user_id, :tyear, :tmonth, :tweek
27
27
28 acts_as_customizable
28 acts_as_customizable
29 acts_as_event :title => Proc.new {|o| "#{l_hours(o.hours)} (#{(o.issue || o.project).event_title})"},
29 acts_as_event :title => Proc.new {|o| "#{l_hours(o.hours)} (#{(o.issue || o.project).event_title})"},
30 :url => Proc.new {|o| {:controller => 'timelog', :action => 'index', :project_id => o.project, :issue_id => o.issue}},
30 :url => Proc.new {|o| {:controller => 'timelog', :action => 'index', :project_id => o.project, :issue_id => o.issue}},
31 :author => :user,
31 :author => :user,
32 :description => :comments
32 :description => :comments
33
33
34 acts_as_activity_provider :timestamp => "#{table_name}.created_on",
34 acts_as_activity_provider :timestamp => "#{table_name}.created_on",
35 :author_key => :user_id,
35 :author_key => :user_id,
36 :find_options => {:include => :project}
36 :find_options => {:include => :project}
37
37
38 validates_presence_of :user_id, :activity_id, :project_id, :hours, :spent_on
38 validates_presence_of :user_id, :activity_id, :project_id, :hours, :spent_on
39 validates_numericality_of :hours, :allow_nil => true, :message => :invalid
39 validates_numericality_of :hours, :allow_nil => true, :message => :invalid
40 validates_length_of :comments, :maximum => 255, :allow_nil => true
40 validates_length_of :comments, :maximum => 255, :allow_nil => true
41 before_validation :set_project_if_nil
41 before_validation :set_project_if_nil
42 validate :validate_time_entry
42 validate :validate_time_entry
43
43
44 named_scope :visible, lambda {|*args| {
44 named_scope :visible, lambda {|*args| {
45 :include => :project,
45 :include => :project,
46 :conditions => Project.allowed_to_condition(args.shift || User.current, :view_time_entries, *args)
46 :conditions => Project.allowed_to_condition(args.shift || User.current, :view_time_entries, *args)
47 }}
47 }}
48 named_scope :on_issue, lambda {|issue| {
48 named_scope :on_issue, lambda {|issue| {
49 :include => :issue,
49 :include => :issue,
50 :conditions => "#{Issue.table_name}.root_id = #{issue.root_id} AND #{Issue.table_name}.lft >= #{issue.lft} AND #{Issue.table_name}.rgt <= #{issue.rgt}"
50 :conditions => "#{Issue.table_name}.root_id = #{issue.root_id} AND #{Issue.table_name}.lft >= #{issue.lft} AND #{Issue.table_name}.rgt <= #{issue.rgt}"
51 }}
51 }}
52 named_scope :on_project, lambda {|project, include_subprojects| {
52 named_scope :on_project, lambda {|project, include_subprojects| {
53 :include => :project,
53 :include => :project,
54 :conditions => project.project_condition(include_subprojects)
54 :conditions => project.project_condition(include_subprojects)
55 }}
55 }}
56 named_scope :spent_between, lambda {|from, to|
56 named_scope :spent_between, lambda {|from, to|
57 if from && to
57 if from && to
58 {:conditions => ["#{TimeEntry.table_name}.spent_on BETWEEN ? AND ?", from, to]}
58 {:conditions => ["#{TimeEntry.table_name}.spent_on BETWEEN ? AND ?", from, to]}
59 elsif from
59 elsif from
60 {:conditions => ["#{TimeEntry.table_name}.spent_on >= ?", from]}
60 {:conditions => ["#{TimeEntry.table_name}.spent_on >= ?", from]}
61 elsif to
61 elsif to
62 {:conditions => ["#{TimeEntry.table_name}.spent_on <= ?", to]}
62 {:conditions => ["#{TimeEntry.table_name}.spent_on <= ?", to]}
63 else
63 else
64 {}
64 {}
65 end
65 end
66 }
66 }
67
67
68 def after_initialize
68 def initialize(attributes=nil, *args)
69 super
69 if new_record? && self.activity.nil?
70 if new_record? && self.activity.nil?
70 if default_activity = TimeEntryActivity.default
71 if default_activity = TimeEntryActivity.default
71 self.activity_id = default_activity.id
72 self.activity_id = default_activity.id
72 end
73 end
73 self.hours = nil if hours == 0
74 self.hours = nil if hours == 0
74 end
75 end
75 end
76 end
76
77
77 def set_project_if_nil
78 def set_project_if_nil
78 self.project = issue.project if issue && project.nil?
79 self.project = issue.project if issue && project.nil?
79 end
80 end
80
81
81 def validate_time_entry
82 def validate_time_entry
82 errors.add :hours, :invalid if hours && (hours < 0 || hours >= 1000)
83 errors.add :hours, :invalid if hours && (hours < 0 || hours >= 1000)
83 errors.add :project_id, :invalid if project.nil?
84 errors.add :project_id, :invalid if project.nil?
84 errors.add :issue_id, :invalid if (issue_id && !issue) || (issue && project!=issue.project)
85 errors.add :issue_id, :invalid if (issue_id && !issue) || (issue && project!=issue.project)
85 end
86 end
86
87
87 def hours=(h)
88 def hours=(h)
88 write_attribute :hours, (h.is_a?(String) ? (h.to_hours || h) : h)
89 write_attribute :hours, (h.is_a?(String) ? (h.to_hours || h) : h)
89 end
90 end
90
91
91 # tyear, tmonth, tweek assigned where setting spent_on attributes
92 # tyear, tmonth, tweek assigned where setting spent_on attributes
92 # these attributes make time aggregations easier
93 # these attributes make time aggregations easier
93 def spent_on=(date)
94 def spent_on=(date)
94 super
95 super
95 if spent_on.is_a?(Time)
96 if spent_on.is_a?(Time)
96 self.spent_on = spent_on.to_date
97 self.spent_on = spent_on.to_date
97 end
98 end
98 self.tyear = spent_on ? spent_on.year : nil
99 self.tyear = spent_on ? spent_on.year : nil
99 self.tmonth = spent_on ? spent_on.month : nil
100 self.tmonth = spent_on ? spent_on.month : nil
100 self.tweek = spent_on ? Date.civil(spent_on.year, spent_on.month, spent_on.day).cweek : nil
101 self.tweek = spent_on ? Date.civil(spent_on.year, spent_on.month, spent_on.day).cweek : nil
101 end
102 end
102
103
103 # Returns true if the time entry can be edited by usr, otherwise false
104 # Returns true if the time entry can be edited by usr, otherwise false
104 def editable_by?(usr)
105 def editable_by?(usr)
105 (usr == user && usr.allowed_to?(:edit_own_time_entries, project)) || usr.allowed_to?(:edit_time_entries, project)
106 (usr == user && usr.allowed_to?(:edit_own_time_entries, project)) || usr.allowed_to?(:edit_time_entries, project)
106 end
107 end
107 end
108 end
@@ -1,225 +1,226
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 require 'diff'
18 require 'diff'
19 require 'enumerator'
19 require 'enumerator'
20
20
21 class WikiPage < ActiveRecord::Base
21 class WikiPage < ActiveRecord::Base
22 belongs_to :wiki
22 belongs_to :wiki
23 has_one :content, :class_name => 'WikiContent', :foreign_key => 'page_id', :dependent => :destroy
23 has_one :content, :class_name => 'WikiContent', :foreign_key => 'page_id', :dependent => :destroy
24 acts_as_attachable :delete_permission => :delete_wiki_pages_attachments
24 acts_as_attachable :delete_permission => :delete_wiki_pages_attachments
25 acts_as_tree :dependent => :nullify, :order => 'title'
25 acts_as_tree :dependent => :nullify, :order => 'title'
26
26
27 acts_as_watchable
27 acts_as_watchable
28 acts_as_event :title => Proc.new {|o| "#{l(:label_wiki)}: #{o.title}"},
28 acts_as_event :title => Proc.new {|o| "#{l(:label_wiki)}: #{o.title}"},
29 :description => :text,
29 :description => :text,
30 :datetime => :created_on,
30 :datetime => :created_on,
31 :url => Proc.new {|o| {:controller => 'wiki', :action => 'show', :project_id => o.wiki.project, :id => o.title}}
31 :url => Proc.new {|o| {:controller => 'wiki', :action => 'show', :project_id => o.wiki.project, :id => o.title}}
32
32
33 acts_as_searchable :columns => ['title', "#{WikiContent.table_name}.text"],
33 acts_as_searchable :columns => ['title', "#{WikiContent.table_name}.text"],
34 :include => [{:wiki => :project}, :content],
34 :include => [{:wiki => :project}, :content],
35 :permission => :view_wiki_pages,
35 :permission => :view_wiki_pages,
36 :project_key => "#{Wiki.table_name}.project_id"
36 :project_key => "#{Wiki.table_name}.project_id"
37
37
38 attr_accessor :redirect_existing_links
38 attr_accessor :redirect_existing_links
39
39
40 validates_presence_of :title
40 validates_presence_of :title
41 validates_format_of :title, :with => /^[^,\.\/\?\;\|\s]*$/
41 validates_format_of :title, :with => /^[^,\.\/\?\;\|\s]*$/
42 validates_uniqueness_of :title, :scope => :wiki_id, :case_sensitive => false
42 validates_uniqueness_of :title, :scope => :wiki_id, :case_sensitive => false
43 validates_associated :content
43 validates_associated :content
44
44
45 validate :validate_parent_title
45 validate :validate_parent_title
46 before_destroy :remove_redirects
46 before_destroy :remove_redirects
47 before_save :handle_redirects
47 before_save :handle_redirects
48
48
49 # eager load information about last updates, without loading text
49 # eager load information about last updates, without loading text
50 named_scope :with_updated_on, {
50 named_scope :with_updated_on, {
51 :select => "#{WikiPage.table_name}.*, #{WikiContent.table_name}.updated_on",
51 :select => "#{WikiPage.table_name}.*, #{WikiContent.table_name}.updated_on",
52 :joins => "LEFT JOIN #{WikiContent.table_name} ON #{WikiContent.table_name}.page_id = #{WikiPage.table_name}.id"
52 :joins => "LEFT JOIN #{WikiContent.table_name} ON #{WikiContent.table_name}.page_id = #{WikiPage.table_name}.id"
53 }
53 }
54
54
55 # Wiki pages that are protected by default
55 # Wiki pages that are protected by default
56 DEFAULT_PROTECTED_PAGES = %w(sidebar)
56 DEFAULT_PROTECTED_PAGES = %w(sidebar)
57
57
58 def after_initialize
58 def initialize(attributes=nil, *args)
59 super
59 if new_record? && DEFAULT_PROTECTED_PAGES.include?(title.to_s.downcase)
60 if new_record? && DEFAULT_PROTECTED_PAGES.include?(title.to_s.downcase)
60 self.protected = true
61 self.protected = true
61 end
62 end
62 end
63 end
63
64
64 def visible?(user=User.current)
65 def visible?(user=User.current)
65 !user.nil? && user.allowed_to?(:view_wiki_pages, project)
66 !user.nil? && user.allowed_to?(:view_wiki_pages, project)
66 end
67 end
67
68
68 def title=(value)
69 def title=(value)
69 value = Wiki.titleize(value)
70 value = Wiki.titleize(value)
70 @previous_title = read_attribute(:title) if @previous_title.blank?
71 @previous_title = read_attribute(:title) if @previous_title.blank?
71 write_attribute(:title, value)
72 write_attribute(:title, value)
72 end
73 end
73
74
74 def handle_redirects
75 def handle_redirects
75 self.title = Wiki.titleize(title)
76 self.title = Wiki.titleize(title)
76 # Manage redirects if the title has changed
77 # Manage redirects if the title has changed
77 if !@previous_title.blank? && (@previous_title != title) && !new_record?
78 if !@previous_title.blank? && (@previous_title != title) && !new_record?
78 # Update redirects that point to the old title
79 # Update redirects that point to the old title
79 wiki.redirects.find_all_by_redirects_to(@previous_title).each do |r|
80 wiki.redirects.find_all_by_redirects_to(@previous_title).each do |r|
80 r.redirects_to = title
81 r.redirects_to = title
81 r.title == r.redirects_to ? r.destroy : r.save
82 r.title == r.redirects_to ? r.destroy : r.save
82 end
83 end
83 # Remove redirects for the new title
84 # Remove redirects for the new title
84 wiki.redirects.find_all_by_title(title).each(&:destroy)
85 wiki.redirects.find_all_by_title(title).each(&:destroy)
85 # Create a redirect to the new title
86 # Create a redirect to the new title
86 wiki.redirects << WikiRedirect.new(:title => @previous_title, :redirects_to => title) unless redirect_existing_links == "0"
87 wiki.redirects << WikiRedirect.new(:title => @previous_title, :redirects_to => title) unless redirect_existing_links == "0"
87 @previous_title = nil
88 @previous_title = nil
88 end
89 end
89 end
90 end
90
91
91 def remove_redirects
92 def remove_redirects
92 # Remove redirects to this page
93 # Remove redirects to this page
93 wiki.redirects.find_all_by_redirects_to(title).each(&:destroy)
94 wiki.redirects.find_all_by_redirects_to(title).each(&:destroy)
94 end
95 end
95
96
96 def pretty_title
97 def pretty_title
97 WikiPage.pretty_title(title)
98 WikiPage.pretty_title(title)
98 end
99 end
99
100
100 def content_for_version(version=nil)
101 def content_for_version(version=nil)
101 result = content.versions.find_by_version(version.to_i) if version
102 result = content.versions.find_by_version(version.to_i) if version
102 result ||= content
103 result ||= content
103 result
104 result
104 end
105 end
105
106
106 def diff(version_to=nil, version_from=nil)
107 def diff(version_to=nil, version_from=nil)
107 version_to = version_to ? version_to.to_i : self.content.version
108 version_to = version_to ? version_to.to_i : self.content.version
108 version_from = version_from ? version_from.to_i : version_to - 1
109 version_from = version_from ? version_from.to_i : version_to - 1
109 version_to, version_from = version_from, version_to unless version_from < version_to
110 version_to, version_from = version_from, version_to unless version_from < version_to
110
111
111 content_to = content.versions.find_by_version(version_to)
112 content_to = content.versions.find_by_version(version_to)
112 content_from = content.versions.find_by_version(version_from)
113 content_from = content.versions.find_by_version(version_from)
113
114
114 (content_to && content_from) ? WikiDiff.new(content_to, content_from) : nil
115 (content_to && content_from) ? WikiDiff.new(content_to, content_from) : nil
115 end
116 end
116
117
117 def annotate(version=nil)
118 def annotate(version=nil)
118 version = version ? version.to_i : self.content.version
119 version = version ? version.to_i : self.content.version
119 c = content.versions.find_by_version(version)
120 c = content.versions.find_by_version(version)
120 c ? WikiAnnotate.new(c) : nil
121 c ? WikiAnnotate.new(c) : nil
121 end
122 end
122
123
123 def self.pretty_title(str)
124 def self.pretty_title(str)
124 (str && str.is_a?(String)) ? str.tr('_', ' ') : str
125 (str && str.is_a?(String)) ? str.tr('_', ' ') : str
125 end
126 end
126
127
127 def project
128 def project
128 wiki.project
129 wiki.project
129 end
130 end
130
131
131 def text
132 def text
132 content.text if content
133 content.text if content
133 end
134 end
134
135
135 def updated_on
136 def updated_on
136 unless @updated_on
137 unless @updated_on
137 if time = read_attribute(:updated_on)
138 if time = read_attribute(:updated_on)
138 # content updated_on was eager loaded with the page
139 # content updated_on was eager loaded with the page
139 @updated_on = Time.parse(time) rescue nil
140 @updated_on = Time.parse(time) rescue nil
140 else
141 else
141 @updated_on = content && content.updated_on
142 @updated_on = content && content.updated_on
142 end
143 end
143 end
144 end
144 @updated_on
145 @updated_on
145 end
146 end
146
147
147 # Returns true if usr is allowed to edit the page, otherwise false
148 # Returns true if usr is allowed to edit the page, otherwise false
148 def editable_by?(usr)
149 def editable_by?(usr)
149 !protected? || usr.allowed_to?(:protect_wiki_pages, wiki.project)
150 !protected? || usr.allowed_to?(:protect_wiki_pages, wiki.project)
150 end
151 end
151
152
152 def attachments_deletable?(usr=User.current)
153 def attachments_deletable?(usr=User.current)
153 editable_by?(usr) && super(usr)
154 editable_by?(usr) && super(usr)
154 end
155 end
155
156
156 def parent_title
157 def parent_title
157 @parent_title || (self.parent && self.parent.pretty_title)
158 @parent_title || (self.parent && self.parent.pretty_title)
158 end
159 end
159
160
160 def parent_title=(t)
161 def parent_title=(t)
161 @parent_title = t
162 @parent_title = t
162 parent_page = t.blank? ? nil : self.wiki.find_page(t)
163 parent_page = t.blank? ? nil : self.wiki.find_page(t)
163 self.parent = parent_page
164 self.parent = parent_page
164 end
165 end
165
166
166 protected
167 protected
167
168
168 def validate_parent_title
169 def validate_parent_title
169 errors.add(:parent_title, :invalid) if !@parent_title.blank? && parent.nil?
170 errors.add(:parent_title, :invalid) if !@parent_title.blank? && parent.nil?
170 errors.add(:parent_title, :circular_dependency) if parent && (parent == self || parent.ancestors.include?(self))
171 errors.add(:parent_title, :circular_dependency) if parent && (parent == self || parent.ancestors.include?(self))
171 errors.add(:parent_title, :not_same_project) if parent && (parent.wiki_id != wiki_id)
172 errors.add(:parent_title, :not_same_project) if parent && (parent.wiki_id != wiki_id)
172 end
173 end
173 end
174 end
174
175
175 class WikiDiff < Redmine::Helpers::Diff
176 class WikiDiff < Redmine::Helpers::Diff
176 attr_reader :content_to, :content_from
177 attr_reader :content_to, :content_from
177
178
178 def initialize(content_to, content_from)
179 def initialize(content_to, content_from)
179 @content_to = content_to
180 @content_to = content_to
180 @content_from = content_from
181 @content_from = content_from
181 super(content_to.text, content_from.text)
182 super(content_to.text, content_from.text)
182 end
183 end
183 end
184 end
184
185
185 class WikiAnnotate
186 class WikiAnnotate
186 attr_reader :lines, :content
187 attr_reader :lines, :content
187
188
188 def initialize(content)
189 def initialize(content)
189 @content = content
190 @content = content
190 current = content
191 current = content
191 current_lines = current.text.split(/\r?\n/)
192 current_lines = current.text.split(/\r?\n/)
192 @lines = current_lines.collect {|t| [nil, nil, t]}
193 @lines = current_lines.collect {|t| [nil, nil, t]}
193 positions = []
194 positions = []
194 current_lines.size.times {|i| positions << i}
195 current_lines.size.times {|i| positions << i}
195 while (current.previous)
196 while (current.previous)
196 d = current.previous.text.split(/\r?\n/).diff(current.text.split(/\r?\n/)).diffs.flatten
197 d = current.previous.text.split(/\r?\n/).diff(current.text.split(/\r?\n/)).diffs.flatten
197 d.each_slice(3) do |s|
198 d.each_slice(3) do |s|
198 sign, line = s[0], s[1]
199 sign, line = s[0], s[1]
199 if sign == '+' && positions[line] && positions[line] != -1
200 if sign == '+' && positions[line] && positions[line] != -1
200 if @lines[positions[line]][0].nil?
201 if @lines[positions[line]][0].nil?
201 @lines[positions[line]][0] = current.version
202 @lines[positions[line]][0] = current.version
202 @lines[positions[line]][1] = current.author
203 @lines[positions[line]][1] = current.author
203 end
204 end
204 end
205 end
205 end
206 end
206 d.each_slice(3) do |s|
207 d.each_slice(3) do |s|
207 sign, line = s[0], s[1]
208 sign, line = s[0], s[1]
208 if sign == '-'
209 if sign == '-'
209 positions.insert(line, -1)
210 positions.insert(line, -1)
210 else
211 else
211 positions[line] = nil
212 positions[line] = nil
212 end
213 end
213 end
214 end
214 positions.compact!
215 positions.compact!
215 # Stop if every line is annotated
216 # Stop if every line is annotated
216 break unless @lines.detect { |line| line[0].nil? }
217 break unless @lines.detect { |line| line[0].nil? }
217 current = current.previous
218 current = current.previous
218 end
219 end
219 @lines.each { |line|
220 @lines.each { |line|
220 line[0] ||= current.version
221 line[0] ||= current.version
221 # if the last known version is > 1 (eg. history was cleared), we don't know the author
222 # if the last known version is > 1 (eg. history was cleared), we don't know the author
222 line[1] ||= current.author if current.version == 1
223 line[1] ||= current.author if current.version == 1
223 }
224 }
224 end
225 end
225 end
226 end
General Comments 0
You need to be logged in to leave comments. Login now