##// END OF EJS Templates
Makes issue safe_attributes extensible (#6000)....
Jean-Philippe Lang -
r4377:3409333522a7
parent child
Show More
@@ -0,0 +1,75
1 # Redmine - project management software
2 # Copyright (C) 2006-2010 Jean-Philippe Lang
3 #
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
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
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
18 module Redmine
19 module SafeAttributes
20 def self.included(base)
21 base.extend(ClassMethods)
22 end
23
24 module ClassMethods
25 # Declares safe attributes
26 # An optional Proc can be given for conditional inclusion
27 #
28 # Example:
29 # safe_attributes 'title', 'pages'
30 # safe_attributes 'isbn', :if => {|book, user| book.author == user}
31 def safe_attributes(*args)
32 @safe_attributes ||= []
33 if args.empty?
34 @safe_attributes
35 else
36 options = args.last.is_a?(Hash) ? args.pop : {}
37 @safe_attributes << [args, options]
38 end
39 end
40 end
41
42 # Returns an array that can be safely set by user or current user
43 #
44 # Example:
45 # book.safe_attributes # => ['title', 'pages']
46 # book.safe_attributes(book.author) # => ['title', 'pages', 'isbn']
47 def safe_attribute_names(user=User.current)
48 names = []
49 self.class.safe_attributes.collect do |attrs, options|
50 if options[:if].nil? || options[:if].call(self, user)
51 names += attrs.collect(&:to_s)
52 end
53 end
54 names.uniq
55 end
56
57 # Returns a hash with unsafe attributes removed
58 # from the given attrs hash
59 #
60 # Example:
61 # book.delete_unsafe_attributes({'title' => 'My book', 'foo' => 'bar'})
62 # # => {'title' => 'My book'}
63 def delete_unsafe_attributes(attrs, user=User.current)
64 safe = safe_attribute_names(user)
65 attrs.dup.delete_if {|k,v| !safe.include?(k)}
66 end
67
68 # Sets attributes from attrs that are safe
69 # attrs is a Hash with string keys
70 def safe_attributes=(attrs, user=User.current)
71 return unless attrs.is_a?(Hash)
72 self.attributes = delete_unsafe_attributes(attrs, user)
73 end
74 end
75 end
@@ -0,0 +1,87
1 # Redmine - project management software
2 # Copyright (C) 2006-2010 Jean-Philippe Lang
3 #
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
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
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
18 require File.dirname(__FILE__) + '/../../../test_helper'
19
20 class Redmine::SafeAttributesTest < ActiveSupport::TestCase
21
22 class Base
23 def attributes=(attrs)
24 attrs.each do |key, value|
25 send("#{key}=", value)
26 end
27 end
28 end
29
30 class Person < Base
31 attr_accessor :firstname, :lastname, :login
32 include Redmine::SafeAttributes
33 safe_attributes :firstname, :lastname
34 safe_attributes :login, :if => lambda {|person, user| user.admin?}
35 end
36
37 class Book < Base
38 attr_accessor :title
39 include Redmine::SafeAttributes
40 safe_attributes :title
41 end
42
43 def test_safe_attribute_names
44 p = Person.new
45 assert_equal ['firstname', 'lastname'], p.safe_attribute_names(User.anonymous)
46 assert_equal ['firstname', 'lastname', 'login'], p.safe_attribute_names(User.find(1))
47 end
48
49 def test_safe_attribute_names_without_user
50 p = Person.new
51 User.current = nil
52 assert_equal ['firstname', 'lastname'], p.safe_attribute_names
53 User.current = User.find(1)
54 assert_equal ['firstname', 'lastname', 'login'], p.safe_attribute_names
55 end
56
57 def test_set_safe_attributes
58 p = Person.new
59 p.send('safe_attributes=', {'firstname' => 'John', 'lastname' => 'Smith', 'login' => 'jsmith'}, User.anonymous)
60 assert_equal 'John', p.firstname
61 assert_equal 'Smith', p.lastname
62 assert_nil p.login
63
64 p = Person.new
65 User.current = User.find(1)
66 p.send('safe_attributes=', {'firstname' => 'John', 'lastname' => 'Smith', 'login' => 'jsmith'}, User.find(1))
67 assert_equal 'John', p.firstname
68 assert_equal 'Smith', p.lastname
69 assert_equal 'jsmith', p.login
70 end
71
72 def test_set_safe_attributes_without_user
73 p = Person.new
74 User.current = nil
75 p.safe_attributes = {'firstname' => 'John', 'lastname' => 'Smith', 'login' => 'jsmith'}
76 assert_equal 'John', p.firstname
77 assert_equal 'Smith', p.lastname
78 assert_nil p.login
79
80 p = Person.new
81 User.current = User.find(1)
82 p.safe_attributes = {'firstname' => 'John', 'lastname' => 'Smith', 'login' => 'jsmith'}
83 assert_equal 'John', p.firstname
84 assert_equal 'Smith', p.lastname
85 assert_equal 'jsmith', p.login
86 end
87 end
@@ -1,887 +1,882
1 # redMine - project management software
1 # redMine - project management software
2 # Copyright (C) 2006-2007 Jean-Philippe Lang
2 # Copyright (C) 2006-2007 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
20
19 belongs_to :project
21 belongs_to :project
20 belongs_to :tracker
22 belongs_to :tracker
21 belongs_to :status, :class_name => 'IssueStatus', :foreign_key => 'status_id'
23 belongs_to :status, :class_name => 'IssueStatus', :foreign_key => 'status_id'
22 belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
24 belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
23 belongs_to :assigned_to, :class_name => 'User', :foreign_key => 'assigned_to_id'
25 belongs_to :assigned_to, :class_name => 'User', :foreign_key => 'assigned_to_id'
24 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'
25 belongs_to :priority, :class_name => 'IssuePriority', :foreign_key => 'priority_id'
27 belongs_to :priority, :class_name => 'IssuePriority', :foreign_key => 'priority_id'
26 belongs_to :category, :class_name => 'IssueCategory', :foreign_key => 'category_id'
28 belongs_to :category, :class_name => 'IssueCategory', :foreign_key => 'category_id'
27
29
28 has_many :journals, :as => :journalized, :dependent => :destroy
30 has_many :journals, :as => :journalized, :dependent => :destroy
29 has_many :time_entries, :dependent => :delete_all
31 has_many :time_entries, :dependent => :delete_all
30 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"
31
33
32 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
33 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
34
36
35 acts_as_nested_set :scope => 'root_id'
37 acts_as_nested_set :scope => 'root_id'
36 acts_as_attachable :after_remove => :attachment_removed
38 acts_as_attachable :after_remove => :attachment_removed
37 acts_as_customizable
39 acts_as_customizable
38 acts_as_watchable
40 acts_as_watchable
39 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"],
40 :include => [:project, :journals],
42 :include => [:project, :journals],
41 # 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
42 :order_column => "#{table_name}.id"
44 :order_column => "#{table_name}.id"
43 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}"},
44 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
46 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
45 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
47 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
46
48
47 acts_as_activity_provider :find_options => {:include => [:project, :author, :tracker]},
49 acts_as_activity_provider :find_options => {:include => [:project, :author, :tracker]},
48 :author_key => :author_id
50 :author_key => :author_id
49
51
50 DONE_RATIO_OPTIONS = %w(issue_field issue_status)
52 DONE_RATIO_OPTIONS = %w(issue_field issue_status)
51
53
52 attr_reader :current_journal
54 attr_reader :current_journal
53
55
54 validates_presence_of :subject, :priority, :project, :tracker, :author, :status
56 validates_presence_of :subject, :priority, :project, :tracker, :author, :status
55
57
56 validates_length_of :subject, :maximum => 255
58 validates_length_of :subject, :maximum => 255
57 validates_inclusion_of :done_ratio, :in => 0..100
59 validates_inclusion_of :done_ratio, :in => 0..100
58 validates_numericality_of :estimated_hours, :allow_nil => true
60 validates_numericality_of :estimated_hours, :allow_nil => true
59
61
60 named_scope :visible, lambda {|*args| { :include => :project,
62 named_scope :visible, lambda {|*args| { :include => :project,
61 :conditions => Project.allowed_to_condition(args.first || User.current, :view_issues) } }
63 :conditions => Project.allowed_to_condition(args.first || User.current, :view_issues) } }
62
64
63 named_scope :open, :conditions => ["#{IssueStatus.table_name}.is_closed = ?", false], :include => :status
65 named_scope :open, :conditions => ["#{IssueStatus.table_name}.is_closed = ?", false], :include => :status
64
66
65 named_scope :recently_updated, :order => "#{Issue.table_name}.updated_on DESC"
67 named_scope :recently_updated, :order => "#{Issue.table_name}.updated_on DESC"
66 named_scope :with_limit, lambda { |limit| { :limit => limit} }
68 named_scope :with_limit, lambda { |limit| { :limit => limit} }
67 named_scope :on_active_project, :include => [:status, :project, :tracker],
69 named_scope :on_active_project, :include => [:status, :project, :tracker],
68 :conditions => ["#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"]
70 :conditions => ["#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"]
69 named_scope :for_gantt, lambda {
71 named_scope :for_gantt, lambda {
70 {
72 {
71 :include => [:tracker, :status, :assigned_to, :priority, :project, :fixed_version]
73 :include => [:tracker, :status, :assigned_to, :priority, :project, :fixed_version]
72 }
74 }
73 }
75 }
74
76
75 named_scope :without_version, lambda {
77 named_scope :without_version, lambda {
76 {
78 {
77 :conditions => { :fixed_version_id => nil}
79 :conditions => { :fixed_version_id => nil}
78 }
80 }
79 }
81 }
80
82
81 named_scope :with_query, lambda {|query|
83 named_scope :with_query, lambda {|query|
82 {
84 {
83 :conditions => Query.merge_conditions(query.statement)
85 :conditions => Query.merge_conditions(query.statement)
84 }
86 }
85 }
87 }
86
88
87 before_create :default_assign
89 before_create :default_assign
88 before_save :close_duplicates, :update_done_ratio_from_issue_status
90 before_save :close_duplicates, :update_done_ratio_from_issue_status
89 after_save :reschedule_following_issues, :update_nested_set_attributes, :update_parent_attributes, :create_journal
91 after_save :reschedule_following_issues, :update_nested_set_attributes, :update_parent_attributes, :create_journal
90 after_destroy :destroy_children
92 after_destroy :destroy_children
91 after_destroy :update_parent_attributes
93 after_destroy :update_parent_attributes
92
94
93 # Returns true if usr or current user is allowed to view the issue
95 # Returns true if usr or current user is allowed to view the issue
94 def visible?(usr=nil)
96 def visible?(usr=nil)
95 (usr || User.current).allowed_to?(:view_issues, self.project)
97 (usr || User.current).allowed_to?(:view_issues, self.project)
96 end
98 end
97
99
98 def after_initialize
100 def after_initialize
99 if new_record?
101 if new_record?
100 # set default values for new records only
102 # set default values for new records only
101 self.status ||= IssueStatus.default
103 self.status ||= IssueStatus.default
102 self.priority ||= IssuePriority.default
104 self.priority ||= IssuePriority.default
103 end
105 end
104 end
106 end
105
107
106 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
108 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
107 def available_custom_fields
109 def available_custom_fields
108 (project && tracker) ? project.all_issue_custom_fields.select {|c| tracker.custom_fields.include? c } : []
110 (project && tracker) ? project.all_issue_custom_fields.select {|c| tracker.custom_fields.include? c } : []
109 end
111 end
110
112
111 def copy_from(arg)
113 def copy_from(arg)
112 issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg)
114 issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg)
113 self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
115 self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
114 self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
116 self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
115 self.status = issue.status
117 self.status = issue.status
116 self
118 self
117 end
119 end
118
120
119 # Moves/copies an issue to a new project and tracker
121 # Moves/copies an issue to a new project and tracker
120 # Returns the moved/copied issue on success, false on failure
122 # Returns the moved/copied issue on success, false on failure
121 def move_to_project(*args)
123 def move_to_project(*args)
122 ret = Issue.transaction do
124 ret = Issue.transaction do
123 move_to_project_without_transaction(*args) || raise(ActiveRecord::Rollback)
125 move_to_project_without_transaction(*args) || raise(ActiveRecord::Rollback)
124 end || false
126 end || false
125 end
127 end
126
128
127 def move_to_project_without_transaction(new_project, new_tracker = nil, options = {})
129 def move_to_project_without_transaction(new_project, new_tracker = nil, options = {})
128 options ||= {}
130 options ||= {}
129 issue = options[:copy] ? self.class.new.copy_from(self) : self
131 issue = options[:copy] ? self.class.new.copy_from(self) : self
130
132
131 if new_project && issue.project_id != new_project.id
133 if new_project && issue.project_id != new_project.id
132 # delete issue relations
134 # delete issue relations
133 unless Setting.cross_project_issue_relations?
135 unless Setting.cross_project_issue_relations?
134 issue.relations_from.clear
136 issue.relations_from.clear
135 issue.relations_to.clear
137 issue.relations_to.clear
136 end
138 end
137 # issue is moved to another project
139 # issue is moved to another project
138 # reassign to the category with same name if any
140 # reassign to the category with same name if any
139 new_category = issue.category.nil? ? nil : new_project.issue_categories.find_by_name(issue.category.name)
141 new_category = issue.category.nil? ? nil : new_project.issue_categories.find_by_name(issue.category.name)
140 issue.category = new_category
142 issue.category = new_category
141 # Keep the fixed_version if it's still valid in the new_project
143 # Keep the fixed_version if it's still valid in the new_project
142 unless new_project.shared_versions.include?(issue.fixed_version)
144 unless new_project.shared_versions.include?(issue.fixed_version)
143 issue.fixed_version = nil
145 issue.fixed_version = nil
144 end
146 end
145 issue.project = new_project
147 issue.project = new_project
146 if issue.parent && issue.parent.project_id != issue.project_id
148 if issue.parent && issue.parent.project_id != issue.project_id
147 issue.parent_issue_id = nil
149 issue.parent_issue_id = nil
148 end
150 end
149 end
151 end
150 if new_tracker
152 if new_tracker
151 issue.tracker = new_tracker
153 issue.tracker = new_tracker
152 issue.reset_custom_values!
154 issue.reset_custom_values!
153 end
155 end
154 if options[:copy]
156 if options[:copy]
155 issue.custom_field_values = self.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
157 issue.custom_field_values = self.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
156 issue.status = if options[:attributes] && options[:attributes][:status_id]
158 issue.status = if options[:attributes] && options[:attributes][:status_id]
157 IssueStatus.find_by_id(options[:attributes][:status_id])
159 IssueStatus.find_by_id(options[:attributes][:status_id])
158 else
160 else
159 self.status
161 self.status
160 end
162 end
161 end
163 end
162 # Allow bulk setting of attributes on the issue
164 # Allow bulk setting of attributes on the issue
163 if options[:attributes]
165 if options[:attributes]
164 issue.attributes = options[:attributes]
166 issue.attributes = options[:attributes]
165 end
167 end
166 if issue.save
168 if issue.save
167 unless options[:copy]
169 unless options[:copy]
168 # Manually update project_id on related time entries
170 # Manually update project_id on related time entries
169 TimeEntry.update_all("project_id = #{new_project.id}", {:issue_id => id})
171 TimeEntry.update_all("project_id = #{new_project.id}", {:issue_id => id})
170
172
171 issue.children.each do |child|
173 issue.children.each do |child|
172 unless child.move_to_project_without_transaction(new_project)
174 unless child.move_to_project_without_transaction(new_project)
173 # Move failed and transaction was rollback'd
175 # Move failed and transaction was rollback'd
174 return false
176 return false
175 end
177 end
176 end
178 end
177 end
179 end
178 else
180 else
179 return false
181 return false
180 end
182 end
181 issue
183 issue
182 end
184 end
183
185
184 def status_id=(sid)
186 def status_id=(sid)
185 self.status = nil
187 self.status = nil
186 write_attribute(:status_id, sid)
188 write_attribute(:status_id, sid)
187 end
189 end
188
190
189 def priority_id=(pid)
191 def priority_id=(pid)
190 self.priority = nil
192 self.priority = nil
191 write_attribute(:priority_id, pid)
193 write_attribute(:priority_id, pid)
192 end
194 end
193
195
194 def tracker_id=(tid)
196 def tracker_id=(tid)
195 self.tracker = nil
197 self.tracker = nil
196 result = write_attribute(:tracker_id, tid)
198 result = write_attribute(:tracker_id, tid)
197 @custom_field_values = nil
199 @custom_field_values = nil
198 result
200 result
199 end
201 end
200
202
201 # Overrides attributes= so that tracker_id gets assigned first
203 # Overrides attributes= so that tracker_id gets assigned first
202 def attributes_with_tracker_first=(new_attributes, *args)
204 def attributes_with_tracker_first=(new_attributes, *args)
203 return if new_attributes.nil?
205 return if new_attributes.nil?
204 new_tracker_id = new_attributes['tracker_id'] || new_attributes[:tracker_id]
206 new_tracker_id = new_attributes['tracker_id'] || new_attributes[:tracker_id]
205 if new_tracker_id
207 if new_tracker_id
206 self.tracker_id = new_tracker_id
208 self.tracker_id = new_tracker_id
207 end
209 end
208 send :attributes_without_tracker_first=, new_attributes, *args
210 send :attributes_without_tracker_first=, new_attributes, *args
209 end
211 end
210 # Do not redefine alias chain on reload (see #4838)
212 # Do not redefine alias chain on reload (see #4838)
211 alias_method_chain(:attributes=, :tracker_first) unless method_defined?(:attributes_without_tracker_first=)
213 alias_method_chain(:attributes=, :tracker_first) unless method_defined?(:attributes_without_tracker_first=)
212
214
213 def estimated_hours=(h)
215 def estimated_hours=(h)
214 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
216 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
215 end
217 end
216
218
217 SAFE_ATTRIBUTES = %w(
219 safe_attributes 'tracker_id',
218 tracker_id
220 'status_id',
219 status_id
221 'parent_issue_id',
220 parent_issue_id
222 'category_id',
221 category_id
223 'assigned_to_id',
222 assigned_to_id
224 'priority_id',
223 priority_id
225 'fixed_version_id',
224 fixed_version_id
226 'subject',
225 subject
227 'description',
226 description
228 'start_date',
227 start_date
229 'due_date',
228 due_date
230 'done_ratio',
229 done_ratio
231 'estimated_hours',
230 estimated_hours
232 'custom_field_values',
231 custom_field_values
233 'custom_fields',
232 custom_fields
234 'lock_version',
233 lock_version
235 :if => lambda {|issue, user| issue.new_record? || user.allowed_to?(:edit_issues, issue.project) }
234 ) unless const_defined?(:SAFE_ATTRIBUTES)
236
235
237 safe_attributes 'status_id',
236 SAFE_ATTRIBUTES_ON_TRANSITION = %w(
238 'assigned_to_id',
237 status_id
239 'fixed_version_id',
238 assigned_to_id
240 'done_ratio',
239 fixed_version_id
241 :if => lambda {|issue, user| issue.new_statuses_allowed_to(user).any? }
240 done_ratio
241 ) unless const_defined?(:SAFE_ATTRIBUTES_ON_TRANSITION)
242
242
243 # Safely sets attributes
243 # Safely sets attributes
244 # Should be called from controllers instead of #attributes=
244 # Should be called from controllers instead of #attributes=
245 # attr_accessible is too rough because we still want things like
245 # attr_accessible is too rough because we still want things like
246 # Issue.new(:project => foo) to work
246 # Issue.new(:project => foo) to work
247 # TODO: move workflow/permission checks from controllers to here
247 # TODO: move workflow/permission checks from controllers to here
248 def safe_attributes=(attrs, user=User.current)
248 def safe_attributes=(attrs, user=User.current)
249 return unless attrs.is_a?(Hash)
249 return unless attrs.is_a?(Hash)
250
250
251 # User can change issue attributes only if he has :edit permission or if a workflow transition is allowed
251 # User can change issue attributes only if he has :edit permission or if a workflow transition is allowed
252 if new_record? || user.allowed_to?(:edit_issues, project)
252 attrs = delete_unsafe_attributes(attrs, user)
253 attrs = attrs.reject {|k,v| !SAFE_ATTRIBUTES.include?(k)}
253 return if attrs.empty?
254 elsif new_statuses_allowed_to(user).any?
255 attrs = attrs.reject {|k,v| !SAFE_ATTRIBUTES_ON_TRANSITION.include?(k)}
256 else
257 return
258 end
259
254
260 # Tracker must be set before since new_statuses_allowed_to depends on it.
255 # Tracker must be set before since new_statuses_allowed_to depends on it.
261 if t = attrs.delete('tracker_id')
256 if t = attrs.delete('tracker_id')
262 self.tracker_id = t
257 self.tracker_id = t
263 end
258 end
264
259
265 if attrs['status_id']
260 if attrs['status_id']
266 unless new_statuses_allowed_to(user).collect(&:id).include?(attrs['status_id'].to_i)
261 unless new_statuses_allowed_to(user).collect(&:id).include?(attrs['status_id'].to_i)
267 attrs.delete('status_id')
262 attrs.delete('status_id')
268 end
263 end
269 end
264 end
270
265
271 unless leaf?
266 unless leaf?
272 attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
267 attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
273 end
268 end
274
269
275 if attrs.has_key?('parent_issue_id')
270 if attrs.has_key?('parent_issue_id')
276 if !user.allowed_to?(:manage_subtasks, project)
271 if !user.allowed_to?(:manage_subtasks, project)
277 attrs.delete('parent_issue_id')
272 attrs.delete('parent_issue_id')
278 elsif !attrs['parent_issue_id'].blank?
273 elsif !attrs['parent_issue_id'].blank?
279 attrs.delete('parent_issue_id') unless Issue.visible(user).exists?(attrs['parent_issue_id'].to_i)
274 attrs.delete('parent_issue_id') unless Issue.visible(user).exists?(attrs['parent_issue_id'].to_i)
280 end
275 end
281 end
276 end
282
277
283 self.attributes = attrs
278 self.attributes = attrs
284 end
279 end
285
280
286 def done_ratio
281 def done_ratio
287 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
282 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
288 status.default_done_ratio
283 status.default_done_ratio
289 else
284 else
290 read_attribute(:done_ratio)
285 read_attribute(:done_ratio)
291 end
286 end
292 end
287 end
293
288
294 def self.use_status_for_done_ratio?
289 def self.use_status_for_done_ratio?
295 Setting.issue_done_ratio == 'issue_status'
290 Setting.issue_done_ratio == 'issue_status'
296 end
291 end
297
292
298 def self.use_field_for_done_ratio?
293 def self.use_field_for_done_ratio?
299 Setting.issue_done_ratio == 'issue_field'
294 Setting.issue_done_ratio == 'issue_field'
300 end
295 end
301
296
302 def validate
297 def validate
303 if self.due_date.nil? && @attributes['due_date'] && !@attributes['due_date'].empty?
298 if self.due_date.nil? && @attributes['due_date'] && !@attributes['due_date'].empty?
304 errors.add :due_date, :not_a_date
299 errors.add :due_date, :not_a_date
305 end
300 end
306
301
307 if self.due_date and self.start_date and self.due_date < self.start_date
302 if self.due_date and self.start_date and self.due_date < self.start_date
308 errors.add :due_date, :greater_than_start_date
303 errors.add :due_date, :greater_than_start_date
309 end
304 end
310
305
311 if start_date && soonest_start && start_date < soonest_start
306 if start_date && soonest_start && start_date < soonest_start
312 errors.add :start_date, :invalid
307 errors.add :start_date, :invalid
313 end
308 end
314
309
315 if fixed_version
310 if fixed_version
316 if !assignable_versions.include?(fixed_version)
311 if !assignable_versions.include?(fixed_version)
317 errors.add :fixed_version_id, :inclusion
312 errors.add :fixed_version_id, :inclusion
318 elsif reopened? && fixed_version.closed?
313 elsif reopened? && fixed_version.closed?
319 errors.add_to_base I18n.t(:error_can_not_reopen_issue_on_closed_version)
314 errors.add_to_base I18n.t(:error_can_not_reopen_issue_on_closed_version)
320 end
315 end
321 end
316 end
322
317
323 # Checks that the issue can not be added/moved to a disabled tracker
318 # Checks that the issue can not be added/moved to a disabled tracker
324 if project && (tracker_id_changed? || project_id_changed?)
319 if project && (tracker_id_changed? || project_id_changed?)
325 unless project.trackers.include?(tracker)
320 unless project.trackers.include?(tracker)
326 errors.add :tracker_id, :inclusion
321 errors.add :tracker_id, :inclusion
327 end
322 end
328 end
323 end
329
324
330 # Checks parent issue assignment
325 # Checks parent issue assignment
331 if @parent_issue
326 if @parent_issue
332 if @parent_issue.project_id != project_id
327 if @parent_issue.project_id != project_id
333 errors.add :parent_issue_id, :not_same_project
328 errors.add :parent_issue_id, :not_same_project
334 elsif !new_record?
329 elsif !new_record?
335 # moving an existing issue
330 # moving an existing issue
336 if @parent_issue.root_id != root_id
331 if @parent_issue.root_id != root_id
337 # we can always move to another tree
332 # we can always move to another tree
338 elsif move_possible?(@parent_issue)
333 elsif move_possible?(@parent_issue)
339 # move accepted inside tree
334 # move accepted inside tree
340 else
335 else
341 errors.add :parent_issue_id, :not_a_valid_parent
336 errors.add :parent_issue_id, :not_a_valid_parent
342 end
337 end
343 end
338 end
344 end
339 end
345 end
340 end
346
341
347 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
342 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
348 # even if the user turns off the setting later
343 # even if the user turns off the setting later
349 def update_done_ratio_from_issue_status
344 def update_done_ratio_from_issue_status
350 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
345 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
351 self.done_ratio = status.default_done_ratio
346 self.done_ratio = status.default_done_ratio
352 end
347 end
353 end
348 end
354
349
355 def init_journal(user, notes = "")
350 def init_journal(user, notes = "")
356 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
351 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
357 @issue_before_change = self.clone
352 @issue_before_change = self.clone
358 @issue_before_change.status = self.status
353 @issue_before_change.status = self.status
359 @custom_values_before_change = {}
354 @custom_values_before_change = {}
360 self.custom_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
355 self.custom_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
361 # Make sure updated_on is updated when adding a note.
356 # Make sure updated_on is updated when adding a note.
362 updated_on_will_change!
357 updated_on_will_change!
363 @current_journal
358 @current_journal
364 end
359 end
365
360
366 # Return true if the issue is closed, otherwise false
361 # Return true if the issue is closed, otherwise false
367 def closed?
362 def closed?
368 self.status.is_closed?
363 self.status.is_closed?
369 end
364 end
370
365
371 # Return true if the issue is being reopened
366 # Return true if the issue is being reopened
372 def reopened?
367 def reopened?
373 if !new_record? && status_id_changed?
368 if !new_record? && status_id_changed?
374 status_was = IssueStatus.find_by_id(status_id_was)
369 status_was = IssueStatus.find_by_id(status_id_was)
375 status_new = IssueStatus.find_by_id(status_id)
370 status_new = IssueStatus.find_by_id(status_id)
376 if status_was && status_new && status_was.is_closed? && !status_new.is_closed?
371 if status_was && status_new && status_was.is_closed? && !status_new.is_closed?
377 return true
372 return true
378 end
373 end
379 end
374 end
380 false
375 false
381 end
376 end
382
377
383 # Return true if the issue is being closed
378 # Return true if the issue is being closed
384 def closing?
379 def closing?
385 if !new_record? && status_id_changed?
380 if !new_record? && status_id_changed?
386 status_was = IssueStatus.find_by_id(status_id_was)
381 status_was = IssueStatus.find_by_id(status_id_was)
387 status_new = IssueStatus.find_by_id(status_id)
382 status_new = IssueStatus.find_by_id(status_id)
388 if status_was && status_new && !status_was.is_closed? && status_new.is_closed?
383 if status_was && status_new && !status_was.is_closed? && status_new.is_closed?
389 return true
384 return true
390 end
385 end
391 end
386 end
392 false
387 false
393 end
388 end
394
389
395 # Returns true if the issue is overdue
390 # Returns true if the issue is overdue
396 def overdue?
391 def overdue?
397 !due_date.nil? && (due_date < Date.today) && !status.is_closed?
392 !due_date.nil? && (due_date < Date.today) && !status.is_closed?
398 end
393 end
399
394
400 # Is the amount of work done less than it should for the due date
395 # Is the amount of work done less than it should for the due date
401 def behind_schedule?
396 def behind_schedule?
402 return false if start_date.nil? || due_date.nil?
397 return false if start_date.nil? || due_date.nil?
403 done_date = start_date + ((due_date - start_date+1)* done_ratio/100).floor
398 done_date = start_date + ((due_date - start_date+1)* done_ratio/100).floor
404 return done_date <= Date.today
399 return done_date <= Date.today
405 end
400 end
406
401
407 # Does this issue have children?
402 # Does this issue have children?
408 def children?
403 def children?
409 !leaf?
404 !leaf?
410 end
405 end
411
406
412 # Users the issue can be assigned to
407 # Users the issue can be assigned to
413 def assignable_users
408 def assignable_users
414 users = project.assignable_users
409 users = project.assignable_users
415 users << author if author
410 users << author if author
416 users.uniq.sort
411 users.uniq.sort
417 end
412 end
418
413
419 # Versions that the issue can be assigned to
414 # Versions that the issue can be assigned to
420 def assignable_versions
415 def assignable_versions
421 @assignable_versions ||= (project.shared_versions.open + [Version.find_by_id(fixed_version_id_was)]).compact.uniq.sort
416 @assignable_versions ||= (project.shared_versions.open + [Version.find_by_id(fixed_version_id_was)]).compact.uniq.sort
422 end
417 end
423
418
424 # Returns true if this issue is blocked by another issue that is still open
419 # Returns true if this issue is blocked by another issue that is still open
425 def blocked?
420 def blocked?
426 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
421 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
427 end
422 end
428
423
429 # Returns an array of status that user is able to apply
424 # Returns an array of status that user is able to apply
430 def new_statuses_allowed_to(user, include_default=false)
425 def new_statuses_allowed_to(user, include_default=false)
431 statuses = status.find_new_statuses_allowed_to(user.roles_for_project(project), tracker)
426 statuses = status.find_new_statuses_allowed_to(user.roles_for_project(project), tracker)
432 statuses << status unless statuses.empty?
427 statuses << status unless statuses.empty?
433 statuses << IssueStatus.default if include_default
428 statuses << IssueStatus.default if include_default
434 statuses = statuses.uniq.sort
429 statuses = statuses.uniq.sort
435 blocked? ? statuses.reject {|s| s.is_closed?} : statuses
430 blocked? ? statuses.reject {|s| s.is_closed?} : statuses
436 end
431 end
437
432
438 # Returns the mail adresses of users that should be notified
433 # Returns the mail adresses of users that should be notified
439 def recipients
434 def recipients
440 notified = project.notified_users
435 notified = project.notified_users
441 # Author and assignee are always notified unless they have been
436 # Author and assignee are always notified unless they have been
442 # locked or don't want to be notified
437 # locked or don't want to be notified
443 notified << author if author && author.active? && author.notify_about?(self)
438 notified << author if author && author.active? && author.notify_about?(self)
444 notified << assigned_to if assigned_to && assigned_to.active? && assigned_to.notify_about?(self)
439 notified << assigned_to if assigned_to && assigned_to.active? && assigned_to.notify_about?(self)
445 notified.uniq!
440 notified.uniq!
446 # Remove users that can not view the issue
441 # Remove users that can not view the issue
447 notified.reject! {|user| !visible?(user)}
442 notified.reject! {|user| !visible?(user)}
448 notified.collect(&:mail)
443 notified.collect(&:mail)
449 end
444 end
450
445
451 # Returns the total number of hours spent on this issue and its descendants
446 # Returns the total number of hours spent on this issue and its descendants
452 #
447 #
453 # Example:
448 # Example:
454 # spent_hours => 0.0
449 # spent_hours => 0.0
455 # spent_hours => 50.2
450 # spent_hours => 50.2
456 def spent_hours
451 def spent_hours
457 @spent_hours ||= self_and_descendants.sum("#{TimeEntry.table_name}.hours", :include => :time_entries).to_f || 0.0
452 @spent_hours ||= self_and_descendants.sum("#{TimeEntry.table_name}.hours", :include => :time_entries).to_f || 0.0
458 end
453 end
459
454
460 def relations
455 def relations
461 (relations_from + relations_to).sort
456 (relations_from + relations_to).sort
462 end
457 end
463
458
464 def all_dependent_issues
459 def all_dependent_issues
465 dependencies = []
460 dependencies = []
466 relations_from.each do |relation|
461 relations_from.each do |relation|
467 dependencies << relation.issue_to
462 dependencies << relation.issue_to
468 dependencies += relation.issue_to.all_dependent_issues
463 dependencies += relation.issue_to.all_dependent_issues
469 end
464 end
470 dependencies
465 dependencies
471 end
466 end
472
467
473 # Returns an array of issues that duplicate this one
468 # Returns an array of issues that duplicate this one
474 def duplicates
469 def duplicates
475 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
470 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
476 end
471 end
477
472
478 # Returns the due date or the target due date if any
473 # Returns the due date or the target due date if any
479 # Used on gantt chart
474 # Used on gantt chart
480 def due_before
475 def due_before
481 due_date || (fixed_version ? fixed_version.effective_date : nil)
476 due_date || (fixed_version ? fixed_version.effective_date : nil)
482 end
477 end
483
478
484 # Returns the time scheduled for this issue.
479 # Returns the time scheduled for this issue.
485 #
480 #
486 # Example:
481 # Example:
487 # Start Date: 2/26/09, End Date: 3/04/09
482 # Start Date: 2/26/09, End Date: 3/04/09
488 # duration => 6
483 # duration => 6
489 def duration
484 def duration
490 (start_date && due_date) ? due_date - start_date : 0
485 (start_date && due_date) ? due_date - start_date : 0
491 end
486 end
492
487
493 def soonest_start
488 def soonest_start
494 @soonest_start ||= (
489 @soonest_start ||= (
495 relations_to.collect{|relation| relation.successor_soonest_start} +
490 relations_to.collect{|relation| relation.successor_soonest_start} +
496 ancestors.collect(&:soonest_start)
491 ancestors.collect(&:soonest_start)
497 ).compact.max
492 ).compact.max
498 end
493 end
499
494
500 def reschedule_after(date)
495 def reschedule_after(date)
501 return if date.nil?
496 return if date.nil?
502 if leaf?
497 if leaf?
503 if start_date.nil? || start_date < date
498 if start_date.nil? || start_date < date
504 self.start_date, self.due_date = date, date + duration
499 self.start_date, self.due_date = date, date + duration
505 save
500 save
506 end
501 end
507 else
502 else
508 leaves.each do |leaf|
503 leaves.each do |leaf|
509 leaf.reschedule_after(date)
504 leaf.reschedule_after(date)
510 end
505 end
511 end
506 end
512 end
507 end
513
508
514 def <=>(issue)
509 def <=>(issue)
515 if issue.nil?
510 if issue.nil?
516 -1
511 -1
517 elsif root_id != issue.root_id
512 elsif root_id != issue.root_id
518 (root_id || 0) <=> (issue.root_id || 0)
513 (root_id || 0) <=> (issue.root_id || 0)
519 else
514 else
520 (lft || 0) <=> (issue.lft || 0)
515 (lft || 0) <=> (issue.lft || 0)
521 end
516 end
522 end
517 end
523
518
524 def to_s
519 def to_s
525 "#{tracker} ##{id}: #{subject}"
520 "#{tracker} ##{id}: #{subject}"
526 end
521 end
527
522
528 # Returns a string of css classes that apply to the issue
523 # Returns a string of css classes that apply to the issue
529 def css_classes
524 def css_classes
530 s = "issue status-#{status.position} priority-#{priority.position}"
525 s = "issue status-#{status.position} priority-#{priority.position}"
531 s << ' closed' if closed?
526 s << ' closed' if closed?
532 s << ' overdue' if overdue?
527 s << ' overdue' if overdue?
533 s << ' created-by-me' if User.current.logged? && author_id == User.current.id
528 s << ' created-by-me' if User.current.logged? && author_id == User.current.id
534 s << ' assigned-to-me' if User.current.logged? && assigned_to_id == User.current.id
529 s << ' assigned-to-me' if User.current.logged? && assigned_to_id == User.current.id
535 s
530 s
536 end
531 end
537
532
538 # Saves an issue, time_entry, attachments, and a journal from the parameters
533 # Saves an issue, time_entry, attachments, and a journal from the parameters
539 # Returns false if save fails
534 # Returns false if save fails
540 def save_issue_with_child_records(params, existing_time_entry=nil)
535 def save_issue_with_child_records(params, existing_time_entry=nil)
541 Issue.transaction do
536 Issue.transaction do
542 if params[:time_entry] && params[:time_entry][:hours].present? && User.current.allowed_to?(:log_time, project)
537 if params[:time_entry] && params[:time_entry][:hours].present? && User.current.allowed_to?(:log_time, project)
543 @time_entry = existing_time_entry || TimeEntry.new
538 @time_entry = existing_time_entry || TimeEntry.new
544 @time_entry.project = project
539 @time_entry.project = project
545 @time_entry.issue = self
540 @time_entry.issue = self
546 @time_entry.user = User.current
541 @time_entry.user = User.current
547 @time_entry.spent_on = Date.today
542 @time_entry.spent_on = Date.today
548 @time_entry.attributes = params[:time_entry]
543 @time_entry.attributes = params[:time_entry]
549 self.time_entries << @time_entry
544 self.time_entries << @time_entry
550 end
545 end
551
546
552 if valid?
547 if valid?
553 attachments = Attachment.attach_files(self, params[:attachments])
548 attachments = Attachment.attach_files(self, params[:attachments])
554
549
555 attachments[:files].each {|a| @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => a.id, :value => a.filename)}
550 attachments[:files].each {|a| @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => a.id, :value => a.filename)}
556 # TODO: Rename hook
551 # TODO: Rename hook
557 Redmine::Hook.call_hook(:controller_issues_edit_before_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
552 Redmine::Hook.call_hook(:controller_issues_edit_before_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
558 begin
553 begin
559 if save
554 if save
560 # TODO: Rename hook
555 # TODO: Rename hook
561 Redmine::Hook.call_hook(:controller_issues_edit_after_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
556 Redmine::Hook.call_hook(:controller_issues_edit_after_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
562 else
557 else
563 raise ActiveRecord::Rollback
558 raise ActiveRecord::Rollback
564 end
559 end
565 rescue ActiveRecord::StaleObjectError
560 rescue ActiveRecord::StaleObjectError
566 attachments[:files].each(&:destroy)
561 attachments[:files].each(&:destroy)
567 errors.add_to_base l(:notice_locking_conflict)
562 errors.add_to_base l(:notice_locking_conflict)
568 raise ActiveRecord::Rollback
563 raise ActiveRecord::Rollback
569 end
564 end
570 end
565 end
571 end
566 end
572 end
567 end
573
568
574 # Unassigns issues from +version+ if it's no longer shared with issue's project
569 # Unassigns issues from +version+ if it's no longer shared with issue's project
575 def self.update_versions_from_sharing_change(version)
570 def self.update_versions_from_sharing_change(version)
576 # Update issues assigned to the version
571 # Update issues assigned to the version
577 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
572 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
578 end
573 end
579
574
580 # Unassigns issues from versions that are no longer shared
575 # Unassigns issues from versions that are no longer shared
581 # after +project+ was moved
576 # after +project+ was moved
582 def self.update_versions_from_hierarchy_change(project)
577 def self.update_versions_from_hierarchy_change(project)
583 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
578 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
584 # Update issues of the moved projects and issues assigned to a version of a moved project
579 # Update issues of the moved projects and issues assigned to a version of a moved project
585 Issue.update_versions(["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)", moved_project_ids, moved_project_ids])
580 Issue.update_versions(["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)", moved_project_ids, moved_project_ids])
586 end
581 end
587
582
588 def parent_issue_id=(arg)
583 def parent_issue_id=(arg)
589 parent_issue_id = arg.blank? ? nil : arg.to_i
584 parent_issue_id = arg.blank? ? nil : arg.to_i
590 if parent_issue_id && @parent_issue = Issue.find_by_id(parent_issue_id)
585 if parent_issue_id && @parent_issue = Issue.find_by_id(parent_issue_id)
591 @parent_issue.id
586 @parent_issue.id
592 else
587 else
593 @parent_issue = nil
588 @parent_issue = nil
594 nil
589 nil
595 end
590 end
596 end
591 end
597
592
598 def parent_issue_id
593 def parent_issue_id
599 if instance_variable_defined? :@parent_issue
594 if instance_variable_defined? :@parent_issue
600 @parent_issue.nil? ? nil : @parent_issue.id
595 @parent_issue.nil? ? nil : @parent_issue.id
601 else
596 else
602 parent_id
597 parent_id
603 end
598 end
604 end
599 end
605
600
606 # Extracted from the ReportsController.
601 # Extracted from the ReportsController.
607 def self.by_tracker(project)
602 def self.by_tracker(project)
608 count_and_group_by(:project => project,
603 count_and_group_by(:project => project,
609 :field => 'tracker_id',
604 :field => 'tracker_id',
610 :joins => Tracker.table_name)
605 :joins => Tracker.table_name)
611 end
606 end
612
607
613 def self.by_version(project)
608 def self.by_version(project)
614 count_and_group_by(:project => project,
609 count_and_group_by(:project => project,
615 :field => 'fixed_version_id',
610 :field => 'fixed_version_id',
616 :joins => Version.table_name)
611 :joins => Version.table_name)
617 end
612 end
618
613
619 def self.by_priority(project)
614 def self.by_priority(project)
620 count_and_group_by(:project => project,
615 count_and_group_by(:project => project,
621 :field => 'priority_id',
616 :field => 'priority_id',
622 :joins => IssuePriority.table_name)
617 :joins => IssuePriority.table_name)
623 end
618 end
624
619
625 def self.by_category(project)
620 def self.by_category(project)
626 count_and_group_by(:project => project,
621 count_and_group_by(:project => project,
627 :field => 'category_id',
622 :field => 'category_id',
628 :joins => IssueCategory.table_name)
623 :joins => IssueCategory.table_name)
629 end
624 end
630
625
631 def self.by_assigned_to(project)
626 def self.by_assigned_to(project)
632 count_and_group_by(:project => project,
627 count_and_group_by(:project => project,
633 :field => 'assigned_to_id',
628 :field => 'assigned_to_id',
634 :joins => User.table_name)
629 :joins => User.table_name)
635 end
630 end
636
631
637 def self.by_author(project)
632 def self.by_author(project)
638 count_and_group_by(:project => project,
633 count_and_group_by(:project => project,
639 :field => 'author_id',
634 :field => 'author_id',
640 :joins => User.table_name)
635 :joins => User.table_name)
641 end
636 end
642
637
643 def self.by_subproject(project)
638 def self.by_subproject(project)
644 ActiveRecord::Base.connection.select_all("select s.id as status_id,
639 ActiveRecord::Base.connection.select_all("select s.id as status_id,
645 s.is_closed as closed,
640 s.is_closed as closed,
646 i.project_id as project_id,
641 i.project_id as project_id,
647 count(i.id) as total
642 count(i.id) as total
648 from
643 from
649 #{Issue.table_name} i, #{IssueStatus.table_name} s
644 #{Issue.table_name} i, #{IssueStatus.table_name} s
650 where
645 where
651 i.status_id=s.id
646 i.status_id=s.id
652 and i.project_id IN (#{project.descendants.active.collect{|p| p.id}.join(',')})
647 and i.project_id IN (#{project.descendants.active.collect{|p| p.id}.join(',')})
653 group by s.id, s.is_closed, i.project_id") if project.descendants.active.any?
648 group by s.id, s.is_closed, i.project_id") if project.descendants.active.any?
654 end
649 end
655 # End ReportsController extraction
650 # End ReportsController extraction
656
651
657 # Returns an array of projects that current user can move issues to
652 # Returns an array of projects that current user can move issues to
658 def self.allowed_target_projects_on_move
653 def self.allowed_target_projects_on_move
659 projects = []
654 projects = []
660 if User.current.admin?
655 if User.current.admin?
661 # admin is allowed to move issues to any active (visible) project
656 # admin is allowed to move issues to any active (visible) project
662 projects = Project.visible.all
657 projects = Project.visible.all
663 elsif User.current.logged?
658 elsif User.current.logged?
664 if Role.non_member.allowed_to?(:move_issues)
659 if Role.non_member.allowed_to?(:move_issues)
665 projects = Project.visible.all
660 projects = Project.visible.all
666 else
661 else
667 User.current.memberships.each {|m| projects << m.project if m.roles.detect {|r| r.allowed_to?(:move_issues)}}
662 User.current.memberships.each {|m| projects << m.project if m.roles.detect {|r| r.allowed_to?(:move_issues)}}
668 end
663 end
669 end
664 end
670 projects
665 projects
671 end
666 end
672
667
673 private
668 private
674
669
675 def update_nested_set_attributes
670 def update_nested_set_attributes
676 if root_id.nil?
671 if root_id.nil?
677 # issue was just created
672 # issue was just created
678 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
673 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
679 set_default_left_and_right
674 set_default_left_and_right
680 Issue.update_all("root_id = #{root_id}, lft = #{lft}, rgt = #{rgt}", ["id = ?", id])
675 Issue.update_all("root_id = #{root_id}, lft = #{lft}, rgt = #{rgt}", ["id = ?", id])
681 if @parent_issue
676 if @parent_issue
682 move_to_child_of(@parent_issue)
677 move_to_child_of(@parent_issue)
683 end
678 end
684 reload
679 reload
685 elsif parent_issue_id != parent_id
680 elsif parent_issue_id != parent_id
686 former_parent_id = parent_id
681 former_parent_id = parent_id
687 # moving an existing issue
682 # moving an existing issue
688 if @parent_issue && @parent_issue.root_id == root_id
683 if @parent_issue && @parent_issue.root_id == root_id
689 # inside the same tree
684 # inside the same tree
690 move_to_child_of(@parent_issue)
685 move_to_child_of(@parent_issue)
691 else
686 else
692 # to another tree
687 # to another tree
693 unless root?
688 unless root?
694 move_to_right_of(root)
689 move_to_right_of(root)
695 reload
690 reload
696 end
691 end
697 old_root_id = root_id
692 old_root_id = root_id
698 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id )
693 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id )
699 target_maxright = nested_set_scope.maximum(right_column_name) || 0
694 target_maxright = nested_set_scope.maximum(right_column_name) || 0
700 offset = target_maxright + 1 - lft
695 offset = target_maxright + 1 - lft
701 Issue.update_all("root_id = #{root_id}, lft = lft + #{offset}, rgt = rgt + #{offset}",
696 Issue.update_all("root_id = #{root_id}, lft = lft + #{offset}, rgt = rgt + #{offset}",
702 ["root_id = ? AND lft >= ? AND rgt <= ? ", old_root_id, lft, rgt])
697 ["root_id = ? AND lft >= ? AND rgt <= ? ", old_root_id, lft, rgt])
703 self[left_column_name] = lft + offset
698 self[left_column_name] = lft + offset
704 self[right_column_name] = rgt + offset
699 self[right_column_name] = rgt + offset
705 if @parent_issue
700 if @parent_issue
706 move_to_child_of(@parent_issue)
701 move_to_child_of(@parent_issue)
707 end
702 end
708 end
703 end
709 reload
704 reload
710 # delete invalid relations of all descendants
705 # delete invalid relations of all descendants
711 self_and_descendants.each do |issue|
706 self_and_descendants.each do |issue|
712 issue.relations.each do |relation|
707 issue.relations.each do |relation|
713 relation.destroy unless relation.valid?
708 relation.destroy unless relation.valid?
714 end
709 end
715 end
710 end
716 # update former parent
711 # update former parent
717 recalculate_attributes_for(former_parent_id) if former_parent_id
712 recalculate_attributes_for(former_parent_id) if former_parent_id
718 end
713 end
719 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
714 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
720 end
715 end
721
716
722 def update_parent_attributes
717 def update_parent_attributes
723 recalculate_attributes_for(parent_id) if parent_id
718 recalculate_attributes_for(parent_id) if parent_id
724 end
719 end
725
720
726 def recalculate_attributes_for(issue_id)
721 def recalculate_attributes_for(issue_id)
727 if issue_id && p = Issue.find_by_id(issue_id)
722 if issue_id && p = Issue.find_by_id(issue_id)
728 # priority = highest priority of children
723 # priority = highest priority of children
729 if priority_position = p.children.maximum("#{IssuePriority.table_name}.position", :include => :priority)
724 if priority_position = p.children.maximum("#{IssuePriority.table_name}.position", :include => :priority)
730 p.priority = IssuePriority.find_by_position(priority_position)
725 p.priority = IssuePriority.find_by_position(priority_position)
731 end
726 end
732
727
733 # start/due dates = lowest/highest dates of children
728 # start/due dates = lowest/highest dates of children
734 p.start_date = p.children.minimum(:start_date)
729 p.start_date = p.children.minimum(:start_date)
735 p.due_date = p.children.maximum(:due_date)
730 p.due_date = p.children.maximum(:due_date)
736 if p.start_date && p.due_date && p.due_date < p.start_date
731 if p.start_date && p.due_date && p.due_date < p.start_date
737 p.start_date, p.due_date = p.due_date, p.start_date
732 p.start_date, p.due_date = p.due_date, p.start_date
738 end
733 end
739
734
740 # done ratio = weighted average ratio of leaves
735 # done ratio = weighted average ratio of leaves
741 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
736 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
742 leaves_count = p.leaves.count
737 leaves_count = p.leaves.count
743 if leaves_count > 0
738 if leaves_count > 0
744 average = p.leaves.average(:estimated_hours).to_f
739 average = p.leaves.average(:estimated_hours).to_f
745 if average == 0
740 if average == 0
746 average = 1
741 average = 1
747 end
742 end
748 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
743 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
749 progress = done / (average * leaves_count)
744 progress = done / (average * leaves_count)
750 p.done_ratio = progress.round
745 p.done_ratio = progress.round
751 end
746 end
752 end
747 end
753
748
754 # estimate = sum of leaves estimates
749 # estimate = sum of leaves estimates
755 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
750 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
756 p.estimated_hours = nil if p.estimated_hours == 0.0
751 p.estimated_hours = nil if p.estimated_hours == 0.0
757
752
758 # ancestors will be recursively updated
753 # ancestors will be recursively updated
759 p.save(false)
754 p.save(false)
760 end
755 end
761 end
756 end
762
757
763 def destroy_children
758 def destroy_children
764 unless leaf?
759 unless leaf?
765 children.each do |child|
760 children.each do |child|
766 child.destroy
761 child.destroy
767 end
762 end
768 end
763 end
769 end
764 end
770
765
771 # Update issues so their versions are not pointing to a
766 # Update issues so their versions are not pointing to a
772 # fixed_version that is not shared with the issue's project
767 # fixed_version that is not shared with the issue's project
773 def self.update_versions(conditions=nil)
768 def self.update_versions(conditions=nil)
774 # Only need to update issues with a fixed_version from
769 # Only need to update issues with a fixed_version from
775 # a different project and that is not systemwide shared
770 # a different project and that is not systemwide shared
776 Issue.all(:conditions => merge_conditions("#{Issue.table_name}.fixed_version_id IS NOT NULL" +
771 Issue.all(:conditions => merge_conditions("#{Issue.table_name}.fixed_version_id IS NOT NULL" +
777 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
772 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
778 " AND #{Version.table_name}.sharing <> 'system'",
773 " AND #{Version.table_name}.sharing <> 'system'",
779 conditions),
774 conditions),
780 :include => [:project, :fixed_version]
775 :include => [:project, :fixed_version]
781 ).each do |issue|
776 ).each do |issue|
782 next if issue.project.nil? || issue.fixed_version.nil?
777 next if issue.project.nil? || issue.fixed_version.nil?
783 unless issue.project.shared_versions.include?(issue.fixed_version)
778 unless issue.project.shared_versions.include?(issue.fixed_version)
784 issue.init_journal(User.current)
779 issue.init_journal(User.current)
785 issue.fixed_version = nil
780 issue.fixed_version = nil
786 issue.save
781 issue.save
787 end
782 end
788 end
783 end
789 end
784 end
790
785
791 # Callback on attachment deletion
786 # Callback on attachment deletion
792 def attachment_removed(obj)
787 def attachment_removed(obj)
793 journal = init_journal(User.current)
788 journal = init_journal(User.current)
794 journal.details << JournalDetail.new(:property => 'attachment',
789 journal.details << JournalDetail.new(:property => 'attachment',
795 :prop_key => obj.id,
790 :prop_key => obj.id,
796 :old_value => obj.filename)
791 :old_value => obj.filename)
797 journal.save
792 journal.save
798 end
793 end
799
794
800 # Default assignment based on category
795 # Default assignment based on category
801 def default_assign
796 def default_assign
802 if assigned_to.nil? && category && category.assigned_to
797 if assigned_to.nil? && category && category.assigned_to
803 self.assigned_to = category.assigned_to
798 self.assigned_to = category.assigned_to
804 end
799 end
805 end
800 end
806
801
807 # Updates start/due dates of following issues
802 # Updates start/due dates of following issues
808 def reschedule_following_issues
803 def reschedule_following_issues
809 if start_date_changed? || due_date_changed?
804 if start_date_changed? || due_date_changed?
810 relations_from.each do |relation|
805 relations_from.each do |relation|
811 relation.set_issue_to_dates
806 relation.set_issue_to_dates
812 end
807 end
813 end
808 end
814 end
809 end
815
810
816 # Closes duplicates if the issue is being closed
811 # Closes duplicates if the issue is being closed
817 def close_duplicates
812 def close_duplicates
818 if closing?
813 if closing?
819 duplicates.each do |duplicate|
814 duplicates.each do |duplicate|
820 # Reload is need in case the duplicate was updated by a previous duplicate
815 # Reload is need in case the duplicate was updated by a previous duplicate
821 duplicate.reload
816 duplicate.reload
822 # Don't re-close it if it's already closed
817 # Don't re-close it if it's already closed
823 next if duplicate.closed?
818 next if duplicate.closed?
824 # Same user and notes
819 # Same user and notes
825 if @current_journal
820 if @current_journal
826 duplicate.init_journal(@current_journal.user, @current_journal.notes)
821 duplicate.init_journal(@current_journal.user, @current_journal.notes)
827 end
822 end
828 duplicate.update_attribute :status, self.status
823 duplicate.update_attribute :status, self.status
829 end
824 end
830 end
825 end
831 end
826 end
832
827
833 # Saves the changes in a Journal
828 # Saves the changes in a Journal
834 # Called after_save
829 # Called after_save
835 def create_journal
830 def create_journal
836 if @current_journal
831 if @current_journal
837 # attributes changes
832 # attributes changes
838 (Issue.column_names - %w(id description root_id lft rgt lock_version created_on updated_on)).each {|c|
833 (Issue.column_names - %w(id description root_id lft rgt lock_version created_on updated_on)).each {|c|
839 @current_journal.details << JournalDetail.new(:property => 'attr',
834 @current_journal.details << JournalDetail.new(:property => 'attr',
840 :prop_key => c,
835 :prop_key => c,
841 :old_value => @issue_before_change.send(c),
836 :old_value => @issue_before_change.send(c),
842 :value => send(c)) unless send(c)==@issue_before_change.send(c)
837 :value => send(c)) unless send(c)==@issue_before_change.send(c)
843 }
838 }
844 # custom fields changes
839 # custom fields changes
845 custom_values.each {|c|
840 custom_values.each {|c|
846 next if (@custom_values_before_change[c.custom_field_id]==c.value ||
841 next if (@custom_values_before_change[c.custom_field_id]==c.value ||
847 (@custom_values_before_change[c.custom_field_id].blank? && c.value.blank?))
842 (@custom_values_before_change[c.custom_field_id].blank? && c.value.blank?))
848 @current_journal.details << JournalDetail.new(:property => 'cf',
843 @current_journal.details << JournalDetail.new(:property => 'cf',
849 :prop_key => c.custom_field_id,
844 :prop_key => c.custom_field_id,
850 :old_value => @custom_values_before_change[c.custom_field_id],
845 :old_value => @custom_values_before_change[c.custom_field_id],
851 :value => c.value)
846 :value => c.value)
852 }
847 }
853 @current_journal.save
848 @current_journal.save
854 # reset current journal
849 # reset current journal
855 init_journal @current_journal.user, @current_journal.notes
850 init_journal @current_journal.user, @current_journal.notes
856 end
851 end
857 end
852 end
858
853
859 # Query generator for selecting groups of issue counts for a project
854 # Query generator for selecting groups of issue counts for a project
860 # based on specific criteria
855 # based on specific criteria
861 #
856 #
862 # Options
857 # Options
863 # * project - Project to search in.
858 # * project - Project to search in.
864 # * field - String. Issue field to key off of in the grouping.
859 # * field - String. Issue field to key off of in the grouping.
865 # * joins - String. The table name to join against.
860 # * joins - String. The table name to join against.
866 def self.count_and_group_by(options)
861 def self.count_and_group_by(options)
867 project = options.delete(:project)
862 project = options.delete(:project)
868 select_field = options.delete(:field)
863 select_field = options.delete(:field)
869 joins = options.delete(:joins)
864 joins = options.delete(:joins)
870
865
871 where = "i.#{select_field}=j.id"
866 where = "i.#{select_field}=j.id"
872
867
873 ActiveRecord::Base.connection.select_all("select s.id as status_id,
868 ActiveRecord::Base.connection.select_all("select s.id as status_id,
874 s.is_closed as closed,
869 s.is_closed as closed,
875 j.id as #{select_field},
870 j.id as #{select_field},
876 count(i.id) as total
871 count(i.id) as total
877 from
872 from
878 #{Issue.table_name} i, #{IssueStatus.table_name} s, #{joins} j
873 #{Issue.table_name} i, #{IssueStatus.table_name} s, #{joins} j
879 where
874 where
880 i.status_id=s.id
875 i.status_id=s.id
881 and #{where}
876 and #{where}
882 and i.project_id=#{project.id}
877 and i.project_id=#{project.id}
883 group by s.id, s.is_closed, j.id")
878 group by s.id, s.is_closed, j.id")
884 end
879 end
885
880
886
881
887 end
882 end
General Comments 0
You need to be logged in to leave comments. Login now