##// END OF EJS Templates
Adds Issue#visible_condition to build issue visibility statement....
Jean-Philippe Lang -
r5021:fba3d5d327c4
parent child
Show More
@@ -1,876 +1,881
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 => 'User', :foreign_key => 'assigned_to_id'
25 belongs_to :assigned_to, :class_name => 'User', :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_remove => :attachment_removed
38 acts_as_attachable :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
61
62 named_scope :visible, lambda {|*args| { :include => :project,
62 named_scope :visible, lambda {|*args| { :include => :project,
63 :conditions => Project.allowed_to_condition(args.first || User.current, :view_issues) } }
63 :conditions => Issue.visible_condition(args.first || User.current) } }
64
64
65 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
66
66
67 named_scope :recently_updated, :order => "#{Issue.table_name}.updated_on DESC"
67 named_scope :recently_updated, :order => "#{Issue.table_name}.updated_on DESC"
68 named_scope :with_limit, lambda { |limit| { :limit => limit} }
68 named_scope :with_limit, lambda { |limit| { :limit => limit} }
69 named_scope :on_active_project, :include => [:status, :project, :tracker],
69 named_scope :on_active_project, :include => [:status, :project, :tracker],
70 :conditions => ["#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"]
70 :conditions => ["#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"]
71
71
72 named_scope :without_version, lambda {
72 named_scope :without_version, lambda {
73 {
73 {
74 :conditions => { :fixed_version_id => nil}
74 :conditions => { :fixed_version_id => nil}
75 }
75 }
76 }
76 }
77
77
78 named_scope :with_query, lambda {|query|
78 named_scope :with_query, lambda {|query|
79 {
79 {
80 :conditions => Query.merge_conditions(query.statement)
80 :conditions => Query.merge_conditions(query.statement)
81 }
81 }
82 }
82 }
83
83
84 before_create :default_assign
84 before_create :default_assign
85 before_save :close_duplicates, :update_done_ratio_from_issue_status
85 before_save :close_duplicates, :update_done_ratio_from_issue_status
86 after_save :reschedule_following_issues, :update_nested_set_attributes, :update_parent_attributes, :create_journal
86 after_save :reschedule_following_issues, :update_nested_set_attributes, :update_parent_attributes, :create_journal
87 after_destroy :update_parent_attributes
87 after_destroy :update_parent_attributes
88
88
89 # Returns a SQL conditions string used to find all issues visible by the specified user
90 def self.visible_condition(user, options={})
91 Project.allowed_to_condition(user, :view_issues, options)
92 end
93
89 # Returns true if usr or current user is allowed to view the issue
94 # Returns true if usr or current user is allowed to view the issue
90 def visible?(usr=nil)
95 def visible?(usr=nil)
91 (usr || User.current).allowed_to?(:view_issues, self.project)
96 (usr || User.current).allowed_to?(:view_issues, self.project)
92 end
97 end
93
98
94 def after_initialize
99 def after_initialize
95 if new_record?
100 if new_record?
96 # set default values for new records only
101 # set default values for new records only
97 self.status ||= IssueStatus.default
102 self.status ||= IssueStatus.default
98 self.priority ||= IssuePriority.default
103 self.priority ||= IssuePriority.default
99 end
104 end
100 end
105 end
101
106
102 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
107 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
103 def available_custom_fields
108 def available_custom_fields
104 (project && tracker) ? project.all_issue_custom_fields.select {|c| tracker.custom_fields.include? c } : []
109 (project && tracker) ? project.all_issue_custom_fields.select {|c| tracker.custom_fields.include? c } : []
105 end
110 end
106
111
107 def copy_from(arg)
112 def copy_from(arg)
108 issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg)
113 issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg)
109 self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
114 self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
110 self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
115 self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
111 self.status = issue.status
116 self.status = issue.status
112 self
117 self
113 end
118 end
114
119
115 # Moves/copies an issue to a new project and tracker
120 # Moves/copies an issue to a new project and tracker
116 # Returns the moved/copied issue on success, false on failure
121 # Returns the moved/copied issue on success, false on failure
117 def move_to_project(*args)
122 def move_to_project(*args)
118 ret = Issue.transaction do
123 ret = Issue.transaction do
119 move_to_project_without_transaction(*args) || raise(ActiveRecord::Rollback)
124 move_to_project_without_transaction(*args) || raise(ActiveRecord::Rollback)
120 end || false
125 end || false
121 end
126 end
122
127
123 def move_to_project_without_transaction(new_project, new_tracker = nil, options = {})
128 def move_to_project_without_transaction(new_project, new_tracker = nil, options = {})
124 options ||= {}
129 options ||= {}
125 issue = options[:copy] ? self.class.new.copy_from(self) : self
130 issue = options[:copy] ? self.class.new.copy_from(self) : self
126
131
127 if new_project && issue.project_id != new_project.id
132 if new_project && issue.project_id != new_project.id
128 # delete issue relations
133 # delete issue relations
129 unless Setting.cross_project_issue_relations?
134 unless Setting.cross_project_issue_relations?
130 issue.relations_from.clear
135 issue.relations_from.clear
131 issue.relations_to.clear
136 issue.relations_to.clear
132 end
137 end
133 # issue is moved to another project
138 # issue is moved to another project
134 # reassign to the category with same name if any
139 # reassign to the category with same name if any
135 new_category = issue.category.nil? ? nil : new_project.issue_categories.find_by_name(issue.category.name)
140 new_category = issue.category.nil? ? nil : new_project.issue_categories.find_by_name(issue.category.name)
136 issue.category = new_category
141 issue.category = new_category
137 # Keep the fixed_version if it's still valid in the new_project
142 # Keep the fixed_version if it's still valid in the new_project
138 unless new_project.shared_versions.include?(issue.fixed_version)
143 unless new_project.shared_versions.include?(issue.fixed_version)
139 issue.fixed_version = nil
144 issue.fixed_version = nil
140 end
145 end
141 issue.project = new_project
146 issue.project = new_project
142 if issue.parent && issue.parent.project_id != issue.project_id
147 if issue.parent && issue.parent.project_id != issue.project_id
143 issue.parent_issue_id = nil
148 issue.parent_issue_id = nil
144 end
149 end
145 end
150 end
146 if new_tracker
151 if new_tracker
147 issue.tracker = new_tracker
152 issue.tracker = new_tracker
148 issue.reset_custom_values!
153 issue.reset_custom_values!
149 end
154 end
150 if options[:copy]
155 if options[:copy]
151 issue.custom_field_values = self.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
156 issue.custom_field_values = self.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
152 issue.status = if options[:attributes] && options[:attributes][:status_id]
157 issue.status = if options[:attributes] && options[:attributes][:status_id]
153 IssueStatus.find_by_id(options[:attributes][:status_id])
158 IssueStatus.find_by_id(options[:attributes][:status_id])
154 else
159 else
155 self.status
160 self.status
156 end
161 end
157 end
162 end
158 # Allow bulk setting of attributes on the issue
163 # Allow bulk setting of attributes on the issue
159 if options[:attributes]
164 if options[:attributes]
160 issue.attributes = options[:attributes]
165 issue.attributes = options[:attributes]
161 end
166 end
162 if issue.save
167 if issue.save
163 unless options[:copy]
168 unless options[:copy]
164 # Manually update project_id on related time entries
169 # Manually update project_id on related time entries
165 TimeEntry.update_all("project_id = #{new_project.id}", {:issue_id => id})
170 TimeEntry.update_all("project_id = #{new_project.id}", {:issue_id => id})
166
171
167 issue.children.each do |child|
172 issue.children.each do |child|
168 unless child.move_to_project_without_transaction(new_project)
173 unless child.move_to_project_without_transaction(new_project)
169 # Move failed and transaction was rollback'd
174 # Move failed and transaction was rollback'd
170 return false
175 return false
171 end
176 end
172 end
177 end
173 end
178 end
174 else
179 else
175 return false
180 return false
176 end
181 end
177 issue
182 issue
178 end
183 end
179
184
180 def status_id=(sid)
185 def status_id=(sid)
181 self.status = nil
186 self.status = nil
182 write_attribute(:status_id, sid)
187 write_attribute(:status_id, sid)
183 end
188 end
184
189
185 def priority_id=(pid)
190 def priority_id=(pid)
186 self.priority = nil
191 self.priority = nil
187 write_attribute(:priority_id, pid)
192 write_attribute(:priority_id, pid)
188 end
193 end
189
194
190 def tracker_id=(tid)
195 def tracker_id=(tid)
191 self.tracker = nil
196 self.tracker = nil
192 result = write_attribute(:tracker_id, tid)
197 result = write_attribute(:tracker_id, tid)
193 @custom_field_values = nil
198 @custom_field_values = nil
194 result
199 result
195 end
200 end
196
201
197 # Overrides attributes= so that tracker_id gets assigned first
202 # Overrides attributes= so that tracker_id gets assigned first
198 def attributes_with_tracker_first=(new_attributes, *args)
203 def attributes_with_tracker_first=(new_attributes, *args)
199 return if new_attributes.nil?
204 return if new_attributes.nil?
200 new_tracker_id = new_attributes['tracker_id'] || new_attributes[:tracker_id]
205 new_tracker_id = new_attributes['tracker_id'] || new_attributes[:tracker_id]
201 if new_tracker_id
206 if new_tracker_id
202 self.tracker_id = new_tracker_id
207 self.tracker_id = new_tracker_id
203 end
208 end
204 send :attributes_without_tracker_first=, new_attributes, *args
209 send :attributes_without_tracker_first=, new_attributes, *args
205 end
210 end
206 # Do not redefine alias chain on reload (see #4838)
211 # Do not redefine alias chain on reload (see #4838)
207 alias_method_chain(:attributes=, :tracker_first) unless method_defined?(:attributes_without_tracker_first=)
212 alias_method_chain(:attributes=, :tracker_first) unless method_defined?(:attributes_without_tracker_first=)
208
213
209 def estimated_hours=(h)
214 def estimated_hours=(h)
210 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
215 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
211 end
216 end
212
217
213 safe_attributes 'tracker_id',
218 safe_attributes 'tracker_id',
214 'status_id',
219 'status_id',
215 'parent_issue_id',
220 'parent_issue_id',
216 'category_id',
221 'category_id',
217 'assigned_to_id',
222 'assigned_to_id',
218 'priority_id',
223 'priority_id',
219 'fixed_version_id',
224 'fixed_version_id',
220 'subject',
225 'subject',
221 'description',
226 'description',
222 'start_date',
227 'start_date',
223 'due_date',
228 'due_date',
224 'done_ratio',
229 'done_ratio',
225 'estimated_hours',
230 'estimated_hours',
226 'custom_field_values',
231 'custom_field_values',
227 'custom_fields',
232 'custom_fields',
228 'lock_version',
233 'lock_version',
229 :if => lambda {|issue, user| issue.new_record? || user.allowed_to?(:edit_issues, issue.project) }
234 :if => lambda {|issue, user| issue.new_record? || user.allowed_to?(:edit_issues, issue.project) }
230
235
231 safe_attributes 'status_id',
236 safe_attributes 'status_id',
232 'assigned_to_id',
237 'assigned_to_id',
233 'fixed_version_id',
238 'fixed_version_id',
234 'done_ratio',
239 'done_ratio',
235 :if => lambda {|issue, user| issue.new_statuses_allowed_to(user).any? }
240 :if => lambda {|issue, user| issue.new_statuses_allowed_to(user).any? }
236
241
237 # Safely sets attributes
242 # Safely sets attributes
238 # Should be called from controllers instead of #attributes=
243 # Should be called from controllers instead of #attributes=
239 # attr_accessible is too rough because we still want things like
244 # attr_accessible is too rough because we still want things like
240 # Issue.new(:project => foo) to work
245 # Issue.new(:project => foo) to work
241 # TODO: move workflow/permission checks from controllers to here
246 # TODO: move workflow/permission checks from controllers to here
242 def safe_attributes=(attrs, user=User.current)
247 def safe_attributes=(attrs, user=User.current)
243 return unless attrs.is_a?(Hash)
248 return unless attrs.is_a?(Hash)
244
249
245 # User can change issue attributes only if he has :edit permission or if a workflow transition is allowed
250 # User can change issue attributes only if he has :edit permission or if a workflow transition is allowed
246 attrs = delete_unsafe_attributes(attrs, user)
251 attrs = delete_unsafe_attributes(attrs, user)
247 return if attrs.empty?
252 return if attrs.empty?
248
253
249 # Tracker must be set before since new_statuses_allowed_to depends on it.
254 # Tracker must be set before since new_statuses_allowed_to depends on it.
250 if t = attrs.delete('tracker_id')
255 if t = attrs.delete('tracker_id')
251 self.tracker_id = t
256 self.tracker_id = t
252 end
257 end
253
258
254 if attrs['status_id']
259 if attrs['status_id']
255 unless new_statuses_allowed_to(user).collect(&:id).include?(attrs['status_id'].to_i)
260 unless new_statuses_allowed_to(user).collect(&:id).include?(attrs['status_id'].to_i)
256 attrs.delete('status_id')
261 attrs.delete('status_id')
257 end
262 end
258 end
263 end
259
264
260 unless leaf?
265 unless leaf?
261 attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
266 attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
262 end
267 end
263
268
264 if attrs.has_key?('parent_issue_id')
269 if attrs.has_key?('parent_issue_id')
265 if !user.allowed_to?(:manage_subtasks, project)
270 if !user.allowed_to?(:manage_subtasks, project)
266 attrs.delete('parent_issue_id')
271 attrs.delete('parent_issue_id')
267 elsif !attrs['parent_issue_id'].blank?
272 elsif !attrs['parent_issue_id'].blank?
268 attrs.delete('parent_issue_id') unless Issue.visible(user).exists?(attrs['parent_issue_id'].to_i)
273 attrs.delete('parent_issue_id') unless Issue.visible(user).exists?(attrs['parent_issue_id'].to_i)
269 end
274 end
270 end
275 end
271
276
272 self.attributes = attrs
277 self.attributes = attrs
273 end
278 end
274
279
275 def done_ratio
280 def done_ratio
276 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
281 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
277 status.default_done_ratio
282 status.default_done_ratio
278 else
283 else
279 read_attribute(:done_ratio)
284 read_attribute(:done_ratio)
280 end
285 end
281 end
286 end
282
287
283 def self.use_status_for_done_ratio?
288 def self.use_status_for_done_ratio?
284 Setting.issue_done_ratio == 'issue_status'
289 Setting.issue_done_ratio == 'issue_status'
285 end
290 end
286
291
287 def self.use_field_for_done_ratio?
292 def self.use_field_for_done_ratio?
288 Setting.issue_done_ratio == 'issue_field'
293 Setting.issue_done_ratio == 'issue_field'
289 end
294 end
290
295
291 def validate
296 def validate
292 if self.due_date.nil? && @attributes['due_date'] && !@attributes['due_date'].empty?
297 if self.due_date.nil? && @attributes['due_date'] && !@attributes['due_date'].empty?
293 errors.add :due_date, :not_a_date
298 errors.add :due_date, :not_a_date
294 end
299 end
295
300
296 if self.due_date and self.start_date and self.due_date < self.start_date
301 if self.due_date and self.start_date and self.due_date < self.start_date
297 errors.add :due_date, :greater_than_start_date
302 errors.add :due_date, :greater_than_start_date
298 end
303 end
299
304
300 if start_date && soonest_start && start_date < soonest_start
305 if start_date && soonest_start && start_date < soonest_start
301 errors.add :start_date, :invalid
306 errors.add :start_date, :invalid
302 end
307 end
303
308
304 if fixed_version
309 if fixed_version
305 if !assignable_versions.include?(fixed_version)
310 if !assignable_versions.include?(fixed_version)
306 errors.add :fixed_version_id, :inclusion
311 errors.add :fixed_version_id, :inclusion
307 elsif reopened? && fixed_version.closed?
312 elsif reopened? && fixed_version.closed?
308 errors.add_to_base I18n.t(:error_can_not_reopen_issue_on_closed_version)
313 errors.add_to_base I18n.t(:error_can_not_reopen_issue_on_closed_version)
309 end
314 end
310 end
315 end
311
316
312 # Checks that the issue can not be added/moved to a disabled tracker
317 # Checks that the issue can not be added/moved to a disabled tracker
313 if project && (tracker_id_changed? || project_id_changed?)
318 if project && (tracker_id_changed? || project_id_changed?)
314 unless project.trackers.include?(tracker)
319 unless project.trackers.include?(tracker)
315 errors.add :tracker_id, :inclusion
320 errors.add :tracker_id, :inclusion
316 end
321 end
317 end
322 end
318
323
319 # Checks parent issue assignment
324 # Checks parent issue assignment
320 if @parent_issue
325 if @parent_issue
321 if @parent_issue.project_id != project_id
326 if @parent_issue.project_id != project_id
322 errors.add :parent_issue_id, :not_same_project
327 errors.add :parent_issue_id, :not_same_project
323 elsif !new_record?
328 elsif !new_record?
324 # moving an existing issue
329 # moving an existing issue
325 if @parent_issue.root_id != root_id
330 if @parent_issue.root_id != root_id
326 # we can always move to another tree
331 # we can always move to another tree
327 elsif move_possible?(@parent_issue)
332 elsif move_possible?(@parent_issue)
328 # move accepted inside tree
333 # move accepted inside tree
329 else
334 else
330 errors.add :parent_issue_id, :not_a_valid_parent
335 errors.add :parent_issue_id, :not_a_valid_parent
331 end
336 end
332 end
337 end
333 end
338 end
334 end
339 end
335
340
336 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
341 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
337 # even if the user turns off the setting later
342 # even if the user turns off the setting later
338 def update_done_ratio_from_issue_status
343 def update_done_ratio_from_issue_status
339 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
344 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
340 self.done_ratio = status.default_done_ratio
345 self.done_ratio = status.default_done_ratio
341 end
346 end
342 end
347 end
343
348
344 def init_journal(user, notes = "")
349 def init_journal(user, notes = "")
345 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
350 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
346 @issue_before_change = self.clone
351 @issue_before_change = self.clone
347 @issue_before_change.status = self.status
352 @issue_before_change.status = self.status
348 @custom_values_before_change = {}
353 @custom_values_before_change = {}
349 self.custom_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
354 self.custom_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
350 # Make sure updated_on is updated when adding a note.
355 # Make sure updated_on is updated when adding a note.
351 updated_on_will_change!
356 updated_on_will_change!
352 @current_journal
357 @current_journal
353 end
358 end
354
359
355 # Return true if the issue is closed, otherwise false
360 # Return true if the issue is closed, otherwise false
356 def closed?
361 def closed?
357 self.status.is_closed?
362 self.status.is_closed?
358 end
363 end
359
364
360 # Return true if the issue is being reopened
365 # Return true if the issue is being reopened
361 def reopened?
366 def reopened?
362 if !new_record? && status_id_changed?
367 if !new_record? && status_id_changed?
363 status_was = IssueStatus.find_by_id(status_id_was)
368 status_was = IssueStatus.find_by_id(status_id_was)
364 status_new = IssueStatus.find_by_id(status_id)
369 status_new = IssueStatus.find_by_id(status_id)
365 if status_was && status_new && status_was.is_closed? && !status_new.is_closed?
370 if status_was && status_new && status_was.is_closed? && !status_new.is_closed?
366 return true
371 return true
367 end
372 end
368 end
373 end
369 false
374 false
370 end
375 end
371
376
372 # Return true if the issue is being closed
377 # Return true if the issue is being closed
373 def closing?
378 def closing?
374 if !new_record? && status_id_changed?
379 if !new_record? && status_id_changed?
375 status_was = IssueStatus.find_by_id(status_id_was)
380 status_was = IssueStatus.find_by_id(status_id_was)
376 status_new = IssueStatus.find_by_id(status_id)
381 status_new = IssueStatus.find_by_id(status_id)
377 if status_was && status_new && !status_was.is_closed? && status_new.is_closed?
382 if status_was && status_new && !status_was.is_closed? && status_new.is_closed?
378 return true
383 return true
379 end
384 end
380 end
385 end
381 false
386 false
382 end
387 end
383
388
384 # Returns true if the issue is overdue
389 # Returns true if the issue is overdue
385 def overdue?
390 def overdue?
386 !due_date.nil? && (due_date < Date.today) && !status.is_closed?
391 !due_date.nil? && (due_date < Date.today) && !status.is_closed?
387 end
392 end
388
393
389 # Is the amount of work done less than it should for the due date
394 # Is the amount of work done less than it should for the due date
390 def behind_schedule?
395 def behind_schedule?
391 return false if start_date.nil? || due_date.nil?
396 return false if start_date.nil? || due_date.nil?
392 done_date = start_date + ((due_date - start_date+1)* done_ratio/100).floor
397 done_date = start_date + ((due_date - start_date+1)* done_ratio/100).floor
393 return done_date <= Date.today
398 return done_date <= Date.today
394 end
399 end
395
400
396 # Does this issue have children?
401 # Does this issue have children?
397 def children?
402 def children?
398 !leaf?
403 !leaf?
399 end
404 end
400
405
401 # Users the issue can be assigned to
406 # Users the issue can be assigned to
402 def assignable_users
407 def assignable_users
403 users = project.assignable_users
408 users = project.assignable_users
404 users << author if author
409 users << author if author
405 users.uniq.sort
410 users.uniq.sort
406 end
411 end
407
412
408 # Versions that the issue can be assigned to
413 # Versions that the issue can be assigned to
409 def assignable_versions
414 def assignable_versions
410 @assignable_versions ||= (project.shared_versions.open + [Version.find_by_id(fixed_version_id_was)]).compact.uniq.sort
415 @assignable_versions ||= (project.shared_versions.open + [Version.find_by_id(fixed_version_id_was)]).compact.uniq.sort
411 end
416 end
412
417
413 # Returns true if this issue is blocked by another issue that is still open
418 # Returns true if this issue is blocked by another issue that is still open
414 def blocked?
419 def blocked?
415 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
420 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
416 end
421 end
417
422
418 # Returns an array of status that user is able to apply
423 # Returns an array of status that user is able to apply
419 def new_statuses_allowed_to(user, include_default=false)
424 def new_statuses_allowed_to(user, include_default=false)
420 statuses = status.find_new_statuses_allowed_to(
425 statuses = status.find_new_statuses_allowed_to(
421 user.roles_for_project(project),
426 user.roles_for_project(project),
422 tracker,
427 tracker,
423 author == user,
428 author == user,
424 assigned_to_id_changed? ? assigned_to_id_was == user.id : assigned_to_id == user.id
429 assigned_to_id_changed? ? assigned_to_id_was == user.id : assigned_to_id == user.id
425 )
430 )
426 statuses << status unless statuses.empty?
431 statuses << status unless statuses.empty?
427 statuses << IssueStatus.default if include_default
432 statuses << IssueStatus.default if include_default
428 statuses = statuses.uniq.sort
433 statuses = statuses.uniq.sort
429 blocked? ? statuses.reject {|s| s.is_closed?} : statuses
434 blocked? ? statuses.reject {|s| s.is_closed?} : statuses
430 end
435 end
431
436
432 # Returns the mail adresses of users that should be notified
437 # Returns the mail adresses of users that should be notified
433 def recipients
438 def recipients
434 notified = project.notified_users
439 notified = project.notified_users
435 # Author and assignee are always notified unless they have been
440 # Author and assignee are always notified unless they have been
436 # locked or don't want to be notified
441 # locked or don't want to be notified
437 notified << author if author && author.active? && author.notify_about?(self)
442 notified << author if author && author.active? && author.notify_about?(self)
438 notified << assigned_to if assigned_to && assigned_to.active? && assigned_to.notify_about?(self)
443 notified << assigned_to if assigned_to && assigned_to.active? && assigned_to.notify_about?(self)
439 notified.uniq!
444 notified.uniq!
440 # Remove users that can not view the issue
445 # Remove users that can not view the issue
441 notified.reject! {|user| !visible?(user)}
446 notified.reject! {|user| !visible?(user)}
442 notified.collect(&:mail)
447 notified.collect(&:mail)
443 end
448 end
444
449
445 # Returns the total number of hours spent on this issue and its descendants
450 # Returns the total number of hours spent on this issue and its descendants
446 #
451 #
447 # Example:
452 # Example:
448 # spent_hours => 0.0
453 # spent_hours => 0.0
449 # spent_hours => 50.2
454 # spent_hours => 50.2
450 def spent_hours
455 def spent_hours
451 @spent_hours ||= self_and_descendants.sum("#{TimeEntry.table_name}.hours", :include => :time_entries).to_f || 0.0
456 @spent_hours ||= self_and_descendants.sum("#{TimeEntry.table_name}.hours", :include => :time_entries).to_f || 0.0
452 end
457 end
453
458
454 def relations
459 def relations
455 (relations_from + relations_to).sort
460 (relations_from + relations_to).sort
456 end
461 end
457
462
458 def all_dependent_issues(except=[])
463 def all_dependent_issues(except=[])
459 except << self
464 except << self
460 dependencies = []
465 dependencies = []
461 relations_from.each do |relation|
466 relations_from.each do |relation|
462 if relation.issue_to && !except.include?(relation.issue_to)
467 if relation.issue_to && !except.include?(relation.issue_to)
463 dependencies << relation.issue_to
468 dependencies << relation.issue_to
464 dependencies += relation.issue_to.all_dependent_issues(except)
469 dependencies += relation.issue_to.all_dependent_issues(except)
465 end
470 end
466 end
471 end
467 dependencies
472 dependencies
468 end
473 end
469
474
470 # Returns an array of issues that duplicate this one
475 # Returns an array of issues that duplicate this one
471 def duplicates
476 def duplicates
472 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
477 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
473 end
478 end
474
479
475 # Returns the due date or the target due date if any
480 # Returns the due date or the target due date if any
476 # Used on gantt chart
481 # Used on gantt chart
477 def due_before
482 def due_before
478 due_date || (fixed_version ? fixed_version.effective_date : nil)
483 due_date || (fixed_version ? fixed_version.effective_date : nil)
479 end
484 end
480
485
481 # Returns the time scheduled for this issue.
486 # Returns the time scheduled for this issue.
482 #
487 #
483 # Example:
488 # Example:
484 # Start Date: 2/26/09, End Date: 3/04/09
489 # Start Date: 2/26/09, End Date: 3/04/09
485 # duration => 6
490 # duration => 6
486 def duration
491 def duration
487 (start_date && due_date) ? due_date - start_date : 0
492 (start_date && due_date) ? due_date - start_date : 0
488 end
493 end
489
494
490 def soonest_start
495 def soonest_start
491 @soonest_start ||= (
496 @soonest_start ||= (
492 relations_to.collect{|relation| relation.successor_soonest_start} +
497 relations_to.collect{|relation| relation.successor_soonest_start} +
493 ancestors.collect(&:soonest_start)
498 ancestors.collect(&:soonest_start)
494 ).compact.max
499 ).compact.max
495 end
500 end
496
501
497 def reschedule_after(date)
502 def reschedule_after(date)
498 return if date.nil?
503 return if date.nil?
499 if leaf?
504 if leaf?
500 if start_date.nil? || start_date < date
505 if start_date.nil? || start_date < date
501 self.start_date, self.due_date = date, date + duration
506 self.start_date, self.due_date = date, date + duration
502 save
507 save
503 end
508 end
504 else
509 else
505 leaves.each do |leaf|
510 leaves.each do |leaf|
506 leaf.reschedule_after(date)
511 leaf.reschedule_after(date)
507 end
512 end
508 end
513 end
509 end
514 end
510
515
511 def <=>(issue)
516 def <=>(issue)
512 if issue.nil?
517 if issue.nil?
513 -1
518 -1
514 elsif root_id != issue.root_id
519 elsif root_id != issue.root_id
515 (root_id || 0) <=> (issue.root_id || 0)
520 (root_id || 0) <=> (issue.root_id || 0)
516 else
521 else
517 (lft || 0) <=> (issue.lft || 0)
522 (lft || 0) <=> (issue.lft || 0)
518 end
523 end
519 end
524 end
520
525
521 def to_s
526 def to_s
522 "#{tracker} ##{id}: #{subject}"
527 "#{tracker} ##{id}: #{subject}"
523 end
528 end
524
529
525 # Returns a string of css classes that apply to the issue
530 # Returns a string of css classes that apply to the issue
526 def css_classes
531 def css_classes
527 s = "issue status-#{status.position} priority-#{priority.position}"
532 s = "issue status-#{status.position} priority-#{priority.position}"
528 s << ' closed' if closed?
533 s << ' closed' if closed?
529 s << ' overdue' if overdue?
534 s << ' overdue' if overdue?
530 s << ' created-by-me' if User.current.logged? && author_id == User.current.id
535 s << ' created-by-me' if User.current.logged? && author_id == User.current.id
531 s << ' assigned-to-me' if User.current.logged? && assigned_to_id == User.current.id
536 s << ' assigned-to-me' if User.current.logged? && assigned_to_id == User.current.id
532 s
537 s
533 end
538 end
534
539
535 # Saves an issue, time_entry, attachments, and a journal from the parameters
540 # Saves an issue, time_entry, attachments, and a journal from the parameters
536 # Returns false if save fails
541 # Returns false if save fails
537 def save_issue_with_child_records(params, existing_time_entry=nil)
542 def save_issue_with_child_records(params, existing_time_entry=nil)
538 Issue.transaction do
543 Issue.transaction do
539 if params[:time_entry] && (params[:time_entry][:hours].present? || params[:time_entry][:comments].present?) && User.current.allowed_to?(:log_time, project)
544 if params[:time_entry] && (params[:time_entry][:hours].present? || params[:time_entry][:comments].present?) && User.current.allowed_to?(:log_time, project)
540 @time_entry = existing_time_entry || TimeEntry.new
545 @time_entry = existing_time_entry || TimeEntry.new
541 @time_entry.project = project
546 @time_entry.project = project
542 @time_entry.issue = self
547 @time_entry.issue = self
543 @time_entry.user = User.current
548 @time_entry.user = User.current
544 @time_entry.spent_on = Date.today
549 @time_entry.spent_on = Date.today
545 @time_entry.attributes = params[:time_entry]
550 @time_entry.attributes = params[:time_entry]
546 self.time_entries << @time_entry
551 self.time_entries << @time_entry
547 end
552 end
548
553
549 if valid?
554 if valid?
550 attachments = Attachment.attach_files(self, params[:attachments])
555 attachments = Attachment.attach_files(self, params[:attachments])
551
556
552 attachments[:files].each {|a| @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => a.id, :value => a.filename)}
557 attachments[:files].each {|a| @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => a.id, :value => a.filename)}
553 # TODO: Rename hook
558 # TODO: Rename hook
554 Redmine::Hook.call_hook(:controller_issues_edit_before_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
559 Redmine::Hook.call_hook(:controller_issues_edit_before_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
555 begin
560 begin
556 if save
561 if save
557 # TODO: Rename hook
562 # TODO: Rename hook
558 Redmine::Hook.call_hook(:controller_issues_edit_after_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
563 Redmine::Hook.call_hook(:controller_issues_edit_after_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
559 else
564 else
560 raise ActiveRecord::Rollback
565 raise ActiveRecord::Rollback
561 end
566 end
562 rescue ActiveRecord::StaleObjectError
567 rescue ActiveRecord::StaleObjectError
563 attachments[:files].each(&:destroy)
568 attachments[:files].each(&:destroy)
564 errors.add_to_base l(:notice_locking_conflict)
569 errors.add_to_base l(:notice_locking_conflict)
565 raise ActiveRecord::Rollback
570 raise ActiveRecord::Rollback
566 end
571 end
567 end
572 end
568 end
573 end
569 end
574 end
570
575
571 # Unassigns issues from +version+ if it's no longer shared with issue's project
576 # Unassigns issues from +version+ if it's no longer shared with issue's project
572 def self.update_versions_from_sharing_change(version)
577 def self.update_versions_from_sharing_change(version)
573 # Update issues assigned to the version
578 # Update issues assigned to the version
574 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
579 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
575 end
580 end
576
581
577 # Unassigns issues from versions that are no longer shared
582 # Unassigns issues from versions that are no longer shared
578 # after +project+ was moved
583 # after +project+ was moved
579 def self.update_versions_from_hierarchy_change(project)
584 def self.update_versions_from_hierarchy_change(project)
580 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
585 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
581 # Update issues of the moved projects and issues assigned to a version of a moved project
586 # Update issues of the moved projects and issues assigned to a version of a moved project
582 Issue.update_versions(["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)", moved_project_ids, moved_project_ids])
587 Issue.update_versions(["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)", moved_project_ids, moved_project_ids])
583 end
588 end
584
589
585 def parent_issue_id=(arg)
590 def parent_issue_id=(arg)
586 parent_issue_id = arg.blank? ? nil : arg.to_i
591 parent_issue_id = arg.blank? ? nil : arg.to_i
587 if parent_issue_id && @parent_issue = Issue.find_by_id(parent_issue_id)
592 if parent_issue_id && @parent_issue = Issue.find_by_id(parent_issue_id)
588 @parent_issue.id
593 @parent_issue.id
589 else
594 else
590 @parent_issue = nil
595 @parent_issue = nil
591 nil
596 nil
592 end
597 end
593 end
598 end
594
599
595 def parent_issue_id
600 def parent_issue_id
596 if instance_variable_defined? :@parent_issue
601 if instance_variable_defined? :@parent_issue
597 @parent_issue.nil? ? nil : @parent_issue.id
602 @parent_issue.nil? ? nil : @parent_issue.id
598 else
603 else
599 parent_id
604 parent_id
600 end
605 end
601 end
606 end
602
607
603 # Extracted from the ReportsController.
608 # Extracted from the ReportsController.
604 def self.by_tracker(project)
609 def self.by_tracker(project)
605 count_and_group_by(:project => project,
610 count_and_group_by(:project => project,
606 :field => 'tracker_id',
611 :field => 'tracker_id',
607 :joins => Tracker.table_name)
612 :joins => Tracker.table_name)
608 end
613 end
609
614
610 def self.by_version(project)
615 def self.by_version(project)
611 count_and_group_by(:project => project,
616 count_and_group_by(:project => project,
612 :field => 'fixed_version_id',
617 :field => 'fixed_version_id',
613 :joins => Version.table_name)
618 :joins => Version.table_name)
614 end
619 end
615
620
616 def self.by_priority(project)
621 def self.by_priority(project)
617 count_and_group_by(:project => project,
622 count_and_group_by(:project => project,
618 :field => 'priority_id',
623 :field => 'priority_id',
619 :joins => IssuePriority.table_name)
624 :joins => IssuePriority.table_name)
620 end
625 end
621
626
622 def self.by_category(project)
627 def self.by_category(project)
623 count_and_group_by(:project => project,
628 count_and_group_by(:project => project,
624 :field => 'category_id',
629 :field => 'category_id',
625 :joins => IssueCategory.table_name)
630 :joins => IssueCategory.table_name)
626 end
631 end
627
632
628 def self.by_assigned_to(project)
633 def self.by_assigned_to(project)
629 count_and_group_by(:project => project,
634 count_and_group_by(:project => project,
630 :field => 'assigned_to_id',
635 :field => 'assigned_to_id',
631 :joins => User.table_name)
636 :joins => User.table_name)
632 end
637 end
633
638
634 def self.by_author(project)
639 def self.by_author(project)
635 count_and_group_by(:project => project,
640 count_and_group_by(:project => project,
636 :field => 'author_id',
641 :field => 'author_id',
637 :joins => User.table_name)
642 :joins => User.table_name)
638 end
643 end
639
644
640 def self.by_subproject(project)
645 def self.by_subproject(project)
641 ActiveRecord::Base.connection.select_all("select s.id as status_id,
646 ActiveRecord::Base.connection.select_all("select s.id as status_id,
642 s.is_closed as closed,
647 s.is_closed as closed,
643 i.project_id as project_id,
648 i.project_id as project_id,
644 count(i.id) as total
649 count(i.id) as total
645 from
650 from
646 #{Issue.table_name} i, #{IssueStatus.table_name} s
651 #{Issue.table_name} i, #{IssueStatus.table_name} s
647 where
652 where
648 i.status_id=s.id
653 i.status_id=s.id
649 and i.project_id IN (#{project.descendants.active.collect{|p| p.id}.join(',')})
654 and i.project_id IN (#{project.descendants.active.collect{|p| p.id}.join(',')})
650 group by s.id, s.is_closed, i.project_id") if project.descendants.active.any?
655 group by s.id, s.is_closed, i.project_id") if project.descendants.active.any?
651 end
656 end
652 # End ReportsController extraction
657 # End ReportsController extraction
653
658
654 # Returns an array of projects that current user can move issues to
659 # Returns an array of projects that current user can move issues to
655 def self.allowed_target_projects_on_move
660 def self.allowed_target_projects_on_move
656 projects = []
661 projects = []
657 if User.current.admin?
662 if User.current.admin?
658 # admin is allowed to move issues to any active (visible) project
663 # admin is allowed to move issues to any active (visible) project
659 projects = Project.visible.all
664 projects = Project.visible.all
660 elsif User.current.logged?
665 elsif User.current.logged?
661 if Role.non_member.allowed_to?(:move_issues)
666 if Role.non_member.allowed_to?(:move_issues)
662 projects = Project.visible.all
667 projects = Project.visible.all
663 else
668 else
664 User.current.memberships.each {|m| projects << m.project if m.roles.detect {|r| r.allowed_to?(:move_issues)}}
669 User.current.memberships.each {|m| projects << m.project if m.roles.detect {|r| r.allowed_to?(:move_issues)}}
665 end
670 end
666 end
671 end
667 projects
672 projects
668 end
673 end
669
674
670 private
675 private
671
676
672 def update_nested_set_attributes
677 def update_nested_set_attributes
673 if root_id.nil?
678 if root_id.nil?
674 # issue was just created
679 # issue was just created
675 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
680 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
676 set_default_left_and_right
681 set_default_left_and_right
677 Issue.update_all("root_id = #{root_id}, lft = #{lft}, rgt = #{rgt}", ["id = ?", id])
682 Issue.update_all("root_id = #{root_id}, lft = #{lft}, rgt = #{rgt}", ["id = ?", id])
678 if @parent_issue
683 if @parent_issue
679 move_to_child_of(@parent_issue)
684 move_to_child_of(@parent_issue)
680 end
685 end
681 reload
686 reload
682 elsif parent_issue_id != parent_id
687 elsif parent_issue_id != parent_id
683 former_parent_id = parent_id
688 former_parent_id = parent_id
684 # moving an existing issue
689 # moving an existing issue
685 if @parent_issue && @parent_issue.root_id == root_id
690 if @parent_issue && @parent_issue.root_id == root_id
686 # inside the same tree
691 # inside the same tree
687 move_to_child_of(@parent_issue)
692 move_to_child_of(@parent_issue)
688 else
693 else
689 # to another tree
694 # to another tree
690 unless root?
695 unless root?
691 move_to_right_of(root)
696 move_to_right_of(root)
692 reload
697 reload
693 end
698 end
694 old_root_id = root_id
699 old_root_id = root_id
695 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id )
700 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id )
696 target_maxright = nested_set_scope.maximum(right_column_name) || 0
701 target_maxright = nested_set_scope.maximum(right_column_name) || 0
697 offset = target_maxright + 1 - lft
702 offset = target_maxright + 1 - lft
698 Issue.update_all("root_id = #{root_id}, lft = lft + #{offset}, rgt = rgt + #{offset}",
703 Issue.update_all("root_id = #{root_id}, lft = lft + #{offset}, rgt = rgt + #{offset}",
699 ["root_id = ? AND lft >= ? AND rgt <= ? ", old_root_id, lft, rgt])
704 ["root_id = ? AND lft >= ? AND rgt <= ? ", old_root_id, lft, rgt])
700 self[left_column_name] = lft + offset
705 self[left_column_name] = lft + offset
701 self[right_column_name] = rgt + offset
706 self[right_column_name] = rgt + offset
702 if @parent_issue
707 if @parent_issue
703 move_to_child_of(@parent_issue)
708 move_to_child_of(@parent_issue)
704 end
709 end
705 end
710 end
706 reload
711 reload
707 # delete invalid relations of all descendants
712 # delete invalid relations of all descendants
708 self_and_descendants.each do |issue|
713 self_and_descendants.each do |issue|
709 issue.relations.each do |relation|
714 issue.relations.each do |relation|
710 relation.destroy unless relation.valid?
715 relation.destroy unless relation.valid?
711 end
716 end
712 end
717 end
713 # update former parent
718 # update former parent
714 recalculate_attributes_for(former_parent_id) if former_parent_id
719 recalculate_attributes_for(former_parent_id) if former_parent_id
715 end
720 end
716 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
721 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
717 end
722 end
718
723
719 def update_parent_attributes
724 def update_parent_attributes
720 recalculate_attributes_for(parent_id) if parent_id
725 recalculate_attributes_for(parent_id) if parent_id
721 end
726 end
722
727
723 def recalculate_attributes_for(issue_id)
728 def recalculate_attributes_for(issue_id)
724 if issue_id && p = Issue.find_by_id(issue_id)
729 if issue_id && p = Issue.find_by_id(issue_id)
725 # priority = highest priority of children
730 # priority = highest priority of children
726 if priority_position = p.children.maximum("#{IssuePriority.table_name}.position", :include => :priority)
731 if priority_position = p.children.maximum("#{IssuePriority.table_name}.position", :include => :priority)
727 p.priority = IssuePriority.find_by_position(priority_position)
732 p.priority = IssuePriority.find_by_position(priority_position)
728 end
733 end
729
734
730 # start/due dates = lowest/highest dates of children
735 # start/due dates = lowest/highest dates of children
731 p.start_date = p.children.minimum(:start_date)
736 p.start_date = p.children.minimum(:start_date)
732 p.due_date = p.children.maximum(:due_date)
737 p.due_date = p.children.maximum(:due_date)
733 if p.start_date && p.due_date && p.due_date < p.start_date
738 if p.start_date && p.due_date && p.due_date < p.start_date
734 p.start_date, p.due_date = p.due_date, p.start_date
739 p.start_date, p.due_date = p.due_date, p.start_date
735 end
740 end
736
741
737 # done ratio = weighted average ratio of leaves
742 # done ratio = weighted average ratio of leaves
738 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
743 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
739 leaves_count = p.leaves.count
744 leaves_count = p.leaves.count
740 if leaves_count > 0
745 if leaves_count > 0
741 average = p.leaves.average(:estimated_hours).to_f
746 average = p.leaves.average(:estimated_hours).to_f
742 if average == 0
747 if average == 0
743 average = 1
748 average = 1
744 end
749 end
745 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
750 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
746 progress = done / (average * leaves_count)
751 progress = done / (average * leaves_count)
747 p.done_ratio = progress.round
752 p.done_ratio = progress.round
748 end
753 end
749 end
754 end
750
755
751 # estimate = sum of leaves estimates
756 # estimate = sum of leaves estimates
752 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
757 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
753 p.estimated_hours = nil if p.estimated_hours == 0.0
758 p.estimated_hours = nil if p.estimated_hours == 0.0
754
759
755 # ancestors will be recursively updated
760 # ancestors will be recursively updated
756 p.save(false)
761 p.save(false)
757 end
762 end
758 end
763 end
759
764
760 # Update issues so their versions are not pointing to a
765 # Update issues so their versions are not pointing to a
761 # fixed_version that is not shared with the issue's project
766 # fixed_version that is not shared with the issue's project
762 def self.update_versions(conditions=nil)
767 def self.update_versions(conditions=nil)
763 # Only need to update issues with a fixed_version from
768 # Only need to update issues with a fixed_version from
764 # a different project and that is not systemwide shared
769 # a different project and that is not systemwide shared
765 Issue.all(:conditions => merge_conditions("#{Issue.table_name}.fixed_version_id IS NOT NULL" +
770 Issue.all(:conditions => merge_conditions("#{Issue.table_name}.fixed_version_id IS NOT NULL" +
766 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
771 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
767 " AND #{Version.table_name}.sharing <> 'system'",
772 " AND #{Version.table_name}.sharing <> 'system'",
768 conditions),
773 conditions),
769 :include => [:project, :fixed_version]
774 :include => [:project, :fixed_version]
770 ).each do |issue|
775 ).each do |issue|
771 next if issue.project.nil? || issue.fixed_version.nil?
776 next if issue.project.nil? || issue.fixed_version.nil?
772 unless issue.project.shared_versions.include?(issue.fixed_version)
777 unless issue.project.shared_versions.include?(issue.fixed_version)
773 issue.init_journal(User.current)
778 issue.init_journal(User.current)
774 issue.fixed_version = nil
779 issue.fixed_version = nil
775 issue.save
780 issue.save
776 end
781 end
777 end
782 end
778 end
783 end
779
784
780 # Callback on attachment deletion
785 # Callback on attachment deletion
781 def attachment_removed(obj)
786 def attachment_removed(obj)
782 journal = init_journal(User.current)
787 journal = init_journal(User.current)
783 journal.details << JournalDetail.new(:property => 'attachment',
788 journal.details << JournalDetail.new(:property => 'attachment',
784 :prop_key => obj.id,
789 :prop_key => obj.id,
785 :old_value => obj.filename)
790 :old_value => obj.filename)
786 journal.save
791 journal.save
787 end
792 end
788
793
789 # Default assignment based on category
794 # Default assignment based on category
790 def default_assign
795 def default_assign
791 if assigned_to.nil? && category && category.assigned_to
796 if assigned_to.nil? && category && category.assigned_to
792 self.assigned_to = category.assigned_to
797 self.assigned_to = category.assigned_to
793 end
798 end
794 end
799 end
795
800
796 # Updates start/due dates of following issues
801 # Updates start/due dates of following issues
797 def reschedule_following_issues
802 def reschedule_following_issues
798 if start_date_changed? || due_date_changed?
803 if start_date_changed? || due_date_changed?
799 relations_from.each do |relation|
804 relations_from.each do |relation|
800 relation.set_issue_to_dates
805 relation.set_issue_to_dates
801 end
806 end
802 end
807 end
803 end
808 end
804
809
805 # Closes duplicates if the issue is being closed
810 # Closes duplicates if the issue is being closed
806 def close_duplicates
811 def close_duplicates
807 if closing?
812 if closing?
808 duplicates.each do |duplicate|
813 duplicates.each do |duplicate|
809 # Reload is need in case the duplicate was updated by a previous duplicate
814 # Reload is need in case the duplicate was updated by a previous duplicate
810 duplicate.reload
815 duplicate.reload
811 # Don't re-close it if it's already closed
816 # Don't re-close it if it's already closed
812 next if duplicate.closed?
817 next if duplicate.closed?
813 # Same user and notes
818 # Same user and notes
814 if @current_journal
819 if @current_journal
815 duplicate.init_journal(@current_journal.user, @current_journal.notes)
820 duplicate.init_journal(@current_journal.user, @current_journal.notes)
816 end
821 end
817 duplicate.update_attribute :status, self.status
822 duplicate.update_attribute :status, self.status
818 end
823 end
819 end
824 end
820 end
825 end
821
826
822 # Saves the changes in a Journal
827 # Saves the changes in a Journal
823 # Called after_save
828 # Called after_save
824 def create_journal
829 def create_journal
825 if @current_journal
830 if @current_journal
826 # attributes changes
831 # attributes changes
827 (Issue.column_names - %w(id root_id lft rgt lock_version created_on updated_on)).each {|c|
832 (Issue.column_names - %w(id root_id lft rgt lock_version created_on updated_on)).each {|c|
828 @current_journal.details << JournalDetail.new(:property => 'attr',
833 @current_journal.details << JournalDetail.new(:property => 'attr',
829 :prop_key => c,
834 :prop_key => c,
830 :old_value => @issue_before_change.send(c),
835 :old_value => @issue_before_change.send(c),
831 :value => send(c)) unless send(c)==@issue_before_change.send(c)
836 :value => send(c)) unless send(c)==@issue_before_change.send(c)
832 }
837 }
833 # custom fields changes
838 # custom fields changes
834 custom_values.each {|c|
839 custom_values.each {|c|
835 next if (@custom_values_before_change[c.custom_field_id]==c.value ||
840 next if (@custom_values_before_change[c.custom_field_id]==c.value ||
836 (@custom_values_before_change[c.custom_field_id].blank? && c.value.blank?))
841 (@custom_values_before_change[c.custom_field_id].blank? && c.value.blank?))
837 @current_journal.details << JournalDetail.new(:property => 'cf',
842 @current_journal.details << JournalDetail.new(:property => 'cf',
838 :prop_key => c.custom_field_id,
843 :prop_key => c.custom_field_id,
839 :old_value => @custom_values_before_change[c.custom_field_id],
844 :old_value => @custom_values_before_change[c.custom_field_id],
840 :value => c.value)
845 :value => c.value)
841 }
846 }
842 @current_journal.save
847 @current_journal.save
843 # reset current journal
848 # reset current journal
844 init_journal @current_journal.user, @current_journal.notes
849 init_journal @current_journal.user, @current_journal.notes
845 end
850 end
846 end
851 end
847
852
848 # Query generator for selecting groups of issue counts for a project
853 # Query generator for selecting groups of issue counts for a project
849 # based on specific criteria
854 # based on specific criteria
850 #
855 #
851 # Options
856 # Options
852 # * project - Project to search in.
857 # * project - Project to search in.
853 # * field - String. Issue field to key off of in the grouping.
858 # * field - String. Issue field to key off of in the grouping.
854 # * joins - String. The table name to join against.
859 # * joins - String. The table name to join against.
855 def self.count_and_group_by(options)
860 def self.count_and_group_by(options)
856 project = options.delete(:project)
861 project = options.delete(:project)
857 select_field = options.delete(:field)
862 select_field = options.delete(:field)
858 joins = options.delete(:joins)
863 joins = options.delete(:joins)
859
864
860 where = "i.#{select_field}=j.id"
865 where = "i.#{select_field}=j.id"
861
866
862 ActiveRecord::Base.connection.select_all("select s.id as status_id,
867 ActiveRecord::Base.connection.select_all("select s.id as status_id,
863 s.is_closed as closed,
868 s.is_closed as closed,
864 j.id as #{select_field},
869 j.id as #{select_field},
865 count(i.id) as total
870 count(i.id) as total
866 from
871 from
867 #{Issue.table_name} i, #{IssueStatus.table_name} s, #{joins} j
872 #{Issue.table_name} i, #{IssueStatus.table_name} s, #{joins} j
868 where
873 where
869 i.status_id=s.id
874 i.status_id=s.id
870 and #{where}
875 and #{where}
871 and i.project_id=#{project.id}
876 and i.project_id=#{project.id}
872 group by s.id, s.is_closed, j.id")
877 group by s.id, s.is_closed, j.id")
873 end
878 end
874
879
875
880
876 end
881 end
@@ -1,657 +1,657
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2008 Jean-Philippe Lang
2 # Copyright (C) 2006-2008 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 value(issue)
42 def value(issue)
43 issue.send name
43 issue.send name
44 end
44 end
45 end
45 end
46
46
47 class QueryCustomFieldColumn < QueryColumn
47 class QueryCustomFieldColumn < QueryColumn
48
48
49 def initialize(custom_field)
49 def initialize(custom_field)
50 self.name = "cf_#{custom_field.id}".to_sym
50 self.name = "cf_#{custom_field.id}".to_sym
51 self.sortable = custom_field.order_statement || false
51 self.sortable = custom_field.order_statement || false
52 if %w(list date bool int).include?(custom_field.field_format)
52 if %w(list date bool int).include?(custom_field.field_format)
53 self.groupable = custom_field.order_statement
53 self.groupable = custom_field.order_statement
54 end
54 end
55 self.groupable ||= false
55 self.groupable ||= false
56 @cf = custom_field
56 @cf = custom_field
57 end
57 end
58
58
59 def caption
59 def caption
60 @cf.name
60 @cf.name
61 end
61 end
62
62
63 def custom_field
63 def custom_field
64 @cf
64 @cf
65 end
65 end
66
66
67 def value(issue)
67 def value(issue)
68 cv = issue.custom_values.detect {|v| v.custom_field_id == @cf.id}
68 cv = issue.custom_values.detect {|v| v.custom_field_id == @cf.id}
69 cv && @cf.cast_value(cv.value)
69 cv && @cf.cast_value(cv.value)
70 end
70 end
71 end
71 end
72
72
73 class Query < ActiveRecord::Base
73 class Query < ActiveRecord::Base
74 class StatementInvalid < ::ActiveRecord::StatementInvalid
74 class StatementInvalid < ::ActiveRecord::StatementInvalid
75 end
75 end
76
76
77 belongs_to :project
77 belongs_to :project
78 belongs_to :user
78 belongs_to :user
79 serialize :filters
79 serialize :filters
80 serialize :column_names
80 serialize :column_names
81 serialize :sort_criteria, Array
81 serialize :sort_criteria, Array
82
82
83 attr_protected :project_id, :user_id
83 attr_protected :project_id, :user_id
84
84
85 validates_presence_of :name, :on => :save
85 validates_presence_of :name, :on => :save
86 validates_length_of :name, :maximum => 255
86 validates_length_of :name, :maximum => 255
87
87
88 @@operators = { "=" => :label_equals,
88 @@operators = { "=" => :label_equals,
89 "!" => :label_not_equals,
89 "!" => :label_not_equals,
90 "o" => :label_open_issues,
90 "o" => :label_open_issues,
91 "c" => :label_closed_issues,
91 "c" => :label_closed_issues,
92 "!*" => :label_none,
92 "!*" => :label_none,
93 "*" => :label_all,
93 "*" => :label_all,
94 ">=" => :label_greater_or_equal,
94 ">=" => :label_greater_or_equal,
95 "<=" => :label_less_or_equal,
95 "<=" => :label_less_or_equal,
96 "<t+" => :label_in_less_than,
96 "<t+" => :label_in_less_than,
97 ">t+" => :label_in_more_than,
97 ">t+" => :label_in_more_than,
98 "t+" => :label_in,
98 "t+" => :label_in,
99 "t" => :label_today,
99 "t" => :label_today,
100 "w" => :label_this_week,
100 "w" => :label_this_week,
101 ">t-" => :label_less_than_ago,
101 ">t-" => :label_less_than_ago,
102 "<t-" => :label_more_than_ago,
102 "<t-" => :label_more_than_ago,
103 "t-" => :label_ago,
103 "t-" => :label_ago,
104 "~" => :label_contains,
104 "~" => :label_contains,
105 "!~" => :label_not_contains }
105 "!~" => :label_not_contains }
106
106
107 cattr_reader :operators
107 cattr_reader :operators
108
108
109 @@operators_by_filter_type = { :list => [ "=", "!" ],
109 @@operators_by_filter_type = { :list => [ "=", "!" ],
110 :list_status => [ "o", "=", "!", "c", "*" ],
110 :list_status => [ "o", "=", "!", "c", "*" ],
111 :list_optional => [ "=", "!", "!*", "*" ],
111 :list_optional => [ "=", "!", "!*", "*" ],
112 :list_subprojects => [ "*", "!*", "=" ],
112 :list_subprojects => [ "*", "!*", "=" ],
113 :date => [ "<t+", ">t+", "t+", "t", "w", ">t-", "<t-", "t-" ],
113 :date => [ "<t+", ">t+", "t+", "t", "w", ">t-", "<t-", "t-" ],
114 :date_past => [ ">t-", "<t-", "t-", "t", "w" ],
114 :date_past => [ ">t-", "<t-", "t-", "t", "w" ],
115 :string => [ "=", "~", "!", "!~" ],
115 :string => [ "=", "~", "!", "!~" ],
116 :text => [ "~", "!~" ],
116 :text => [ "~", "!~" ],
117 :integer => [ "=", ">=", "<=", "!*", "*" ] }
117 :integer => [ "=", ">=", "<=", "!*", "*" ] }
118
118
119 cattr_reader :operators_by_filter_type
119 cattr_reader :operators_by_filter_type
120
120
121 @@available_columns = [
121 @@available_columns = [
122 QueryColumn.new(:project, :sortable => "#{Project.table_name}.name", :groupable => true),
122 QueryColumn.new(:project, :sortable => "#{Project.table_name}.name", :groupable => true),
123 QueryColumn.new(:tracker, :sortable => "#{Tracker.table_name}.position", :groupable => true),
123 QueryColumn.new(:tracker, :sortable => "#{Tracker.table_name}.position", :groupable => true),
124 QueryColumn.new(:parent, :sortable => ["#{Issue.table_name}.root_id", "#{Issue.table_name}.lft ASC"], :default_order => 'desc', :caption => :field_parent_issue),
124 QueryColumn.new(:parent, :sortable => ["#{Issue.table_name}.root_id", "#{Issue.table_name}.lft ASC"], :default_order => 'desc', :caption => :field_parent_issue),
125 QueryColumn.new(:status, :sortable => "#{IssueStatus.table_name}.position", :groupable => true),
125 QueryColumn.new(:status, :sortable => "#{IssueStatus.table_name}.position", :groupable => true),
126 QueryColumn.new(:priority, :sortable => "#{IssuePriority.table_name}.position", :default_order => 'desc', :groupable => true),
126 QueryColumn.new(:priority, :sortable => "#{IssuePriority.table_name}.position", :default_order => 'desc', :groupable => true),
127 QueryColumn.new(:subject, :sortable => "#{Issue.table_name}.subject"),
127 QueryColumn.new(:subject, :sortable => "#{Issue.table_name}.subject"),
128 QueryColumn.new(:author),
128 QueryColumn.new(:author),
129 QueryColumn.new(:assigned_to, :sortable => ["#{User.table_name}.lastname", "#{User.table_name}.firstname", "#{User.table_name}.id"], :groupable => true),
129 QueryColumn.new(:assigned_to, :sortable => ["#{User.table_name}.lastname", "#{User.table_name}.firstname", "#{User.table_name}.id"], :groupable => true),
130 QueryColumn.new(:updated_on, :sortable => "#{Issue.table_name}.updated_on", :default_order => 'desc'),
130 QueryColumn.new(:updated_on, :sortable => "#{Issue.table_name}.updated_on", :default_order => 'desc'),
131 QueryColumn.new(:category, :sortable => "#{IssueCategory.table_name}.name", :groupable => true),
131 QueryColumn.new(:category, :sortable => "#{IssueCategory.table_name}.name", :groupable => true),
132 QueryColumn.new(:fixed_version, :sortable => ["#{Version.table_name}.effective_date", "#{Version.table_name}.name"], :default_order => 'desc', :groupable => true),
132 QueryColumn.new(:fixed_version, :sortable => ["#{Version.table_name}.effective_date", "#{Version.table_name}.name"], :default_order => 'desc', :groupable => true),
133 QueryColumn.new(:start_date, :sortable => "#{Issue.table_name}.start_date"),
133 QueryColumn.new(:start_date, :sortable => "#{Issue.table_name}.start_date"),
134 QueryColumn.new(:due_date, :sortable => "#{Issue.table_name}.due_date"),
134 QueryColumn.new(:due_date, :sortable => "#{Issue.table_name}.due_date"),
135 QueryColumn.new(:estimated_hours, :sortable => "#{Issue.table_name}.estimated_hours"),
135 QueryColumn.new(:estimated_hours, :sortable => "#{Issue.table_name}.estimated_hours"),
136 QueryColumn.new(:done_ratio, :sortable => "#{Issue.table_name}.done_ratio", :groupable => true),
136 QueryColumn.new(:done_ratio, :sortable => "#{Issue.table_name}.done_ratio", :groupable => true),
137 QueryColumn.new(:created_on, :sortable => "#{Issue.table_name}.created_on", :default_order => 'desc'),
137 QueryColumn.new(:created_on, :sortable => "#{Issue.table_name}.created_on", :default_order => 'desc'),
138 ]
138 ]
139 cattr_reader :available_columns
139 cattr_reader :available_columns
140
140
141 def initialize(attributes = nil)
141 def initialize(attributes = nil)
142 super attributes
142 super attributes
143 self.filters ||= { 'status_id' => {:operator => "o", :values => [""]} }
143 self.filters ||= { 'status_id' => {:operator => "o", :values => [""]} }
144 end
144 end
145
145
146 def after_initialize
146 def after_initialize
147 # Store the fact that project is nil (used in #editable_by?)
147 # Store the fact that project is nil (used in #editable_by?)
148 @is_for_all = project.nil?
148 @is_for_all = project.nil?
149 end
149 end
150
150
151 def validate
151 def validate
152 filters.each_key do |field|
152 filters.each_key do |field|
153 errors.add label_for(field), :blank unless
153 errors.add label_for(field), :blank unless
154 # filter requires one or more values
154 # filter requires one or more values
155 (values_for(field) and !values_for(field).first.blank?) or
155 (values_for(field) and !values_for(field).first.blank?) or
156 # filter doesn't require any value
156 # filter doesn't require any value
157 ["o", "c", "!*", "*", "t", "w"].include? operator_for(field)
157 ["o", "c", "!*", "*", "t", "w"].include? operator_for(field)
158 end if filters
158 end if filters
159 end
159 end
160
160
161 def editable_by?(user)
161 def editable_by?(user)
162 return false unless user
162 return false unless user
163 # Admin can edit them all and regular users can edit their private queries
163 # Admin can edit them all and regular users can edit their private queries
164 return true if user.admin? || (!is_public && self.user_id == user.id)
164 return true if user.admin? || (!is_public && self.user_id == user.id)
165 # Members can not edit public queries that are for all project (only admin is allowed to)
165 # Members can not edit public queries that are for all project (only admin is allowed to)
166 is_public && !@is_for_all && user.allowed_to?(:manage_public_queries, project)
166 is_public && !@is_for_all && user.allowed_to?(:manage_public_queries, project)
167 end
167 end
168
168
169 def available_filters
169 def available_filters
170 return @available_filters if @available_filters
170 return @available_filters if @available_filters
171
171
172 trackers = project.nil? ? Tracker.find(:all, :order => 'position') : project.rolled_up_trackers
172 trackers = project.nil? ? Tracker.find(:all, :order => 'position') : project.rolled_up_trackers
173
173
174 @available_filters = { "status_id" => { :type => :list_status, :order => 1, :values => IssueStatus.find(:all, :order => 'position').collect{|s| [s.name, s.id.to_s] } },
174 @available_filters = { "status_id" => { :type => :list_status, :order => 1, :values => IssueStatus.find(:all, :order => 'position').collect{|s| [s.name, s.id.to_s] } },
175 "tracker_id" => { :type => :list, :order => 2, :values => trackers.collect{|s| [s.name, s.id.to_s] } },
175 "tracker_id" => { :type => :list, :order => 2, :values => trackers.collect{|s| [s.name, s.id.to_s] } },
176 "priority_id" => { :type => :list, :order => 3, :values => IssuePriority.all.collect{|s| [s.name, s.id.to_s] } },
176 "priority_id" => { :type => :list, :order => 3, :values => IssuePriority.all.collect{|s| [s.name, s.id.to_s] } },
177 "subject" => { :type => :text, :order => 8 },
177 "subject" => { :type => :text, :order => 8 },
178 "created_on" => { :type => :date_past, :order => 9 },
178 "created_on" => { :type => :date_past, :order => 9 },
179 "updated_on" => { :type => :date_past, :order => 10 },
179 "updated_on" => { :type => :date_past, :order => 10 },
180 "start_date" => { :type => :date, :order => 11 },
180 "start_date" => { :type => :date, :order => 11 },
181 "due_date" => { :type => :date, :order => 12 },
181 "due_date" => { :type => :date, :order => 12 },
182 "estimated_hours" => { :type => :integer, :order => 13 },
182 "estimated_hours" => { :type => :integer, :order => 13 },
183 "done_ratio" => { :type => :integer, :order => 14 }}
183 "done_ratio" => { :type => :integer, :order => 14 }}
184
184
185 user_values = []
185 user_values = []
186 user_values << ["<< #{l(:label_me)} >>", "me"] if User.current.logged?
186 user_values << ["<< #{l(:label_me)} >>", "me"] if User.current.logged?
187 if project
187 if project
188 user_values += project.users.sort.collect{|s| [s.name, s.id.to_s] }
188 user_values += project.users.sort.collect{|s| [s.name, s.id.to_s] }
189 else
189 else
190 all_projects = Project.visible.all
190 all_projects = Project.visible.all
191 if all_projects.any?
191 if all_projects.any?
192 # members of visible projects
192 # members of visible projects
193 user_values += User.active.find(:all, :conditions => ["#{User.table_name}.id IN (SELECT DISTINCT user_id FROM members WHERE project_id IN (?))", all_projects.collect(&:id)]).sort.collect{|s| [s.name, s.id.to_s] }
193 user_values += User.active.find(:all, :conditions => ["#{User.table_name}.id IN (SELECT DISTINCT user_id FROM members WHERE project_id IN (?))", all_projects.collect(&:id)]).sort.collect{|s| [s.name, s.id.to_s] }
194
194
195 # project filter
195 # project filter
196 project_values = []
196 project_values = []
197 Project.project_tree(all_projects) do |p, level|
197 Project.project_tree(all_projects) do |p, level|
198 prefix = (level > 0 ? ('--' * level + ' ') : '')
198 prefix = (level > 0 ? ('--' * level + ' ') : '')
199 project_values << ["#{prefix}#{p.name}", p.id.to_s]
199 project_values << ["#{prefix}#{p.name}", p.id.to_s]
200 end
200 end
201 @available_filters["project_id"] = { :type => :list, :order => 1, :values => project_values} unless project_values.empty?
201 @available_filters["project_id"] = { :type => :list, :order => 1, :values => project_values} unless project_values.empty?
202 end
202 end
203 end
203 end
204 @available_filters["assigned_to_id"] = { :type => :list_optional, :order => 4, :values => user_values } unless user_values.empty?
204 @available_filters["assigned_to_id"] = { :type => :list_optional, :order => 4, :values => user_values } unless user_values.empty?
205 @available_filters["author_id"] = { :type => :list, :order => 5, :values => user_values } unless user_values.empty?
205 @available_filters["author_id"] = { :type => :list, :order => 5, :values => user_values } unless user_values.empty?
206
206
207 group_values = Group.all.collect {|g| [g.name, g.id.to_s] }
207 group_values = Group.all.collect {|g| [g.name, g.id.to_s] }
208 @available_filters["member_of_group"] = { :type => :list_optional, :order => 6, :values => group_values } unless group_values.empty?
208 @available_filters["member_of_group"] = { :type => :list_optional, :order => 6, :values => group_values } unless group_values.empty?
209
209
210 role_values = Role.givable.collect {|r| [r.name, r.id.to_s] }
210 role_values = Role.givable.collect {|r| [r.name, r.id.to_s] }
211 @available_filters["assigned_to_role"] = { :type => :list_optional, :order => 7, :values => role_values } unless role_values.empty?
211 @available_filters["assigned_to_role"] = { :type => :list_optional, :order => 7, :values => role_values } unless role_values.empty?
212
212
213 if User.current.logged?
213 if User.current.logged?
214 @available_filters["watcher_id"] = { :type => :list, :order => 15, :values => [["<< #{l(:label_me)} >>", "me"]] }
214 @available_filters["watcher_id"] = { :type => :list, :order => 15, :values => [["<< #{l(:label_me)} >>", "me"]] }
215 end
215 end
216
216
217 if project
217 if project
218 # project specific filters
218 # project specific filters
219 unless @project.issue_categories.empty?
219 unless @project.issue_categories.empty?
220 @available_filters["category_id"] = { :type => :list_optional, :order => 6, :values => @project.issue_categories.collect{|s| [s.name, s.id.to_s] } }
220 @available_filters["category_id"] = { :type => :list_optional, :order => 6, :values => @project.issue_categories.collect{|s| [s.name, s.id.to_s] } }
221 end
221 end
222 unless @project.shared_versions.empty?
222 unless @project.shared_versions.empty?
223 @available_filters["fixed_version_id"] = { :type => :list_optional, :order => 7, :values => @project.shared_versions.sort.collect{|s| ["#{s.project.name} - #{s.name}", s.id.to_s] } }
223 @available_filters["fixed_version_id"] = { :type => :list_optional, :order => 7, :values => @project.shared_versions.sort.collect{|s| ["#{s.project.name} - #{s.name}", s.id.to_s] } }
224 end
224 end
225 unless @project.descendants.active.empty?
225 unless @project.descendants.active.empty?
226 @available_filters["subproject_id"] = { :type => :list_subprojects, :order => 13, :values => @project.descendants.visible.collect{|s| [s.name, s.id.to_s] } }
226 @available_filters["subproject_id"] = { :type => :list_subprojects, :order => 13, :values => @project.descendants.visible.collect{|s| [s.name, s.id.to_s] } }
227 end
227 end
228 add_custom_fields_filters(@project.all_issue_custom_fields)
228 add_custom_fields_filters(@project.all_issue_custom_fields)
229 else
229 else
230 # global filters for cross project issue list
230 # global filters for cross project issue list
231 system_shared_versions = Version.visible.find_all_by_sharing('system')
231 system_shared_versions = Version.visible.find_all_by_sharing('system')
232 unless system_shared_versions.empty?
232 unless system_shared_versions.empty?
233 @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] } }
233 @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] } }
234 end
234 end
235 add_custom_fields_filters(IssueCustomField.find(:all, :conditions => {:is_filter => true, :is_for_all => true}))
235 add_custom_fields_filters(IssueCustomField.find(:all, :conditions => {:is_filter => true, :is_for_all => true}))
236 end
236 end
237 @available_filters
237 @available_filters
238 end
238 end
239
239
240 def add_filter(field, operator, values)
240 def add_filter(field, operator, values)
241 # values must be an array
241 # values must be an array
242 return unless values and values.is_a? Array # and !values.first.empty?
242 return unless values and values.is_a? Array # and !values.first.empty?
243 # check if field is defined as an available filter
243 # check if field is defined as an available filter
244 if available_filters.has_key? field
244 if available_filters.has_key? field
245 filter_options = available_filters[field]
245 filter_options = available_filters[field]
246 # check if operator is allowed for that filter
246 # check if operator is allowed for that filter
247 #if @@operators_by_filter_type[filter_options[:type]].include? operator
247 #if @@operators_by_filter_type[filter_options[:type]].include? operator
248 # allowed_values = values & ([""] + (filter_options[:values] || []).collect {|val| val[1]})
248 # allowed_values = values & ([""] + (filter_options[:values] || []).collect {|val| val[1]})
249 # filters[field] = {:operator => operator, :values => allowed_values } if (allowed_values.first and !allowed_values.first.empty?) or ["o", "c", "!*", "*", "t"].include? operator
249 # filters[field] = {:operator => operator, :values => allowed_values } if (allowed_values.first and !allowed_values.first.empty?) or ["o", "c", "!*", "*", "t"].include? operator
250 #end
250 #end
251 filters[field] = {:operator => operator, :values => values }
251 filters[field] = {:operator => operator, :values => values }
252 end
252 end
253 end
253 end
254
254
255 def add_short_filter(field, expression)
255 def add_short_filter(field, expression)
256 return unless expression
256 return unless expression
257 parms = expression.scan(/^(o|c|!\*|!|\*)?(.*)$/).first
257 parms = expression.scan(/^(o|c|!\*|!|\*)?(.*)$/).first
258 add_filter field, (parms[0] || "="), [parms[1] || ""]
258 add_filter field, (parms[0] || "="), [parms[1] || ""]
259 end
259 end
260
260
261 # Add multiple filters using +add_filter+
261 # Add multiple filters using +add_filter+
262 def add_filters(fields, operators, values)
262 def add_filters(fields, operators, values)
263 if fields.is_a?(Array) && operators.is_a?(Hash) && values.is_a?(Hash)
263 if fields.is_a?(Array) && operators.is_a?(Hash) && values.is_a?(Hash)
264 fields.each do |field|
264 fields.each do |field|
265 add_filter(field, operators[field], values[field])
265 add_filter(field, operators[field], values[field])
266 end
266 end
267 end
267 end
268 end
268 end
269
269
270 def has_filter?(field)
270 def has_filter?(field)
271 filters and filters[field]
271 filters and filters[field]
272 end
272 end
273
273
274 def operator_for(field)
274 def operator_for(field)
275 has_filter?(field) ? filters[field][:operator] : nil
275 has_filter?(field) ? filters[field][:operator] : nil
276 end
276 end
277
277
278 def values_for(field)
278 def values_for(field)
279 has_filter?(field) ? filters[field][:values] : nil
279 has_filter?(field) ? filters[field][:values] : nil
280 end
280 end
281
281
282 def label_for(field)
282 def label_for(field)
283 label = available_filters[field][:name] if available_filters.has_key?(field)
283 label = available_filters[field][:name] if available_filters.has_key?(field)
284 label ||= field.gsub(/\_id$/, "")
284 label ||= field.gsub(/\_id$/, "")
285 end
285 end
286
286
287 def available_columns
287 def available_columns
288 return @available_columns if @available_columns
288 return @available_columns if @available_columns
289 @available_columns = Query.available_columns
289 @available_columns = Query.available_columns
290 @available_columns += (project ?
290 @available_columns += (project ?
291 project.all_issue_custom_fields :
291 project.all_issue_custom_fields :
292 IssueCustomField.find(:all)
292 IssueCustomField.find(:all)
293 ).collect {|cf| QueryCustomFieldColumn.new(cf) }
293 ).collect {|cf| QueryCustomFieldColumn.new(cf) }
294 end
294 end
295
295
296 def self.available_columns=(v)
296 def self.available_columns=(v)
297 self.available_columns = (v)
297 self.available_columns = (v)
298 end
298 end
299
299
300 def self.add_available_column(column)
300 def self.add_available_column(column)
301 self.available_columns << (column) if column.is_a?(QueryColumn)
301 self.available_columns << (column) if column.is_a?(QueryColumn)
302 end
302 end
303
303
304 # Returns an array of columns that can be used to group the results
304 # Returns an array of columns that can be used to group the results
305 def groupable_columns
305 def groupable_columns
306 available_columns.select {|c| c.groupable}
306 available_columns.select {|c| c.groupable}
307 end
307 end
308
308
309 # Returns a Hash of columns and the key for sorting
309 # Returns a Hash of columns and the key for sorting
310 def sortable_columns
310 def sortable_columns
311 {'id' => "#{Issue.table_name}.id"}.merge(available_columns.inject({}) {|h, column|
311 {'id' => "#{Issue.table_name}.id"}.merge(available_columns.inject({}) {|h, column|
312 h[column.name.to_s] = column.sortable
312 h[column.name.to_s] = column.sortable
313 h
313 h
314 })
314 })
315 end
315 end
316
316
317 def columns
317 def columns
318 if has_default_columns?
318 if has_default_columns?
319 available_columns.select do |c|
319 available_columns.select do |c|
320 # Adds the project column by default for cross-project lists
320 # Adds the project column by default for cross-project lists
321 Setting.issue_list_default_columns.include?(c.name.to_s) || (c.name == :project && project.nil?)
321 Setting.issue_list_default_columns.include?(c.name.to_s) || (c.name == :project && project.nil?)
322 end
322 end
323 else
323 else
324 # preserve the column_names order
324 # preserve the column_names order
325 column_names.collect {|name| available_columns.find {|col| col.name == name}}.compact
325 column_names.collect {|name| available_columns.find {|col| col.name == name}}.compact
326 end
326 end
327 end
327 end
328
328
329 def column_names=(names)
329 def column_names=(names)
330 if names
330 if names
331 names = names.select {|n| n.is_a?(Symbol) || !n.blank? }
331 names = names.select {|n| n.is_a?(Symbol) || !n.blank? }
332 names = names.collect {|n| n.is_a?(Symbol) ? n : n.to_sym }
332 names = names.collect {|n| n.is_a?(Symbol) ? n : n.to_sym }
333 # Set column_names to nil if default columns
333 # Set column_names to nil if default columns
334 if names.map(&:to_s) == Setting.issue_list_default_columns
334 if names.map(&:to_s) == Setting.issue_list_default_columns
335 names = nil
335 names = nil
336 end
336 end
337 end
337 end
338 write_attribute(:column_names, names)
338 write_attribute(:column_names, names)
339 end
339 end
340
340
341 def has_column?(column)
341 def has_column?(column)
342 column_names && column_names.include?(column.name)
342 column_names && column_names.include?(column.name)
343 end
343 end
344
344
345 def has_default_columns?
345 def has_default_columns?
346 column_names.nil? || column_names.empty?
346 column_names.nil? || column_names.empty?
347 end
347 end
348
348
349 def sort_criteria=(arg)
349 def sort_criteria=(arg)
350 c = []
350 c = []
351 if arg.is_a?(Hash)
351 if arg.is_a?(Hash)
352 arg = arg.keys.sort.collect {|k| arg[k]}
352 arg = arg.keys.sort.collect {|k| arg[k]}
353 end
353 end
354 c = arg.select {|k,o| !k.to_s.blank?}.slice(0,3).collect {|k,o| [k.to_s, o == 'desc' ? o : 'asc']}
354 c = arg.select {|k,o| !k.to_s.blank?}.slice(0,3).collect {|k,o| [k.to_s, o == 'desc' ? o : 'asc']}
355 write_attribute(:sort_criteria, c)
355 write_attribute(:sort_criteria, c)
356 end
356 end
357
357
358 def sort_criteria
358 def sort_criteria
359 read_attribute(:sort_criteria) || []
359 read_attribute(:sort_criteria) || []
360 end
360 end
361
361
362 def sort_criteria_key(arg)
362 def sort_criteria_key(arg)
363 sort_criteria && sort_criteria[arg] && sort_criteria[arg].first
363 sort_criteria && sort_criteria[arg] && sort_criteria[arg].first
364 end
364 end
365
365
366 def sort_criteria_order(arg)
366 def sort_criteria_order(arg)
367 sort_criteria && sort_criteria[arg] && sort_criteria[arg].last
367 sort_criteria && sort_criteria[arg] && sort_criteria[arg].last
368 end
368 end
369
369
370 # Returns the SQL sort order that should be prepended for grouping
370 # Returns the SQL sort order that should be prepended for grouping
371 def group_by_sort_order
371 def group_by_sort_order
372 if grouped? && (column = group_by_column)
372 if grouped? && (column = group_by_column)
373 column.sortable.is_a?(Array) ?
373 column.sortable.is_a?(Array) ?
374 column.sortable.collect {|s| "#{s} #{column.default_order}"}.join(',') :
374 column.sortable.collect {|s| "#{s} #{column.default_order}"}.join(',') :
375 "#{column.sortable} #{column.default_order}"
375 "#{column.sortable} #{column.default_order}"
376 end
376 end
377 end
377 end
378
378
379 # Returns true if the query is a grouped query
379 # Returns true if the query is a grouped query
380 def grouped?
380 def grouped?
381 !group_by_column.nil?
381 !group_by_column.nil?
382 end
382 end
383
383
384 def group_by_column
384 def group_by_column
385 groupable_columns.detect {|c| c.groupable && c.name.to_s == group_by}
385 groupable_columns.detect {|c| c.groupable && c.name.to_s == group_by}
386 end
386 end
387
387
388 def group_by_statement
388 def group_by_statement
389 group_by_column.try(:groupable)
389 group_by_column.try(:groupable)
390 end
390 end
391
391
392 def project_statement
392 def project_statement
393 project_clauses = []
393 project_clauses = []
394 if project && !@project.descendants.active.empty?
394 if project && !@project.descendants.active.empty?
395 ids = [project.id]
395 ids = [project.id]
396 if has_filter?("subproject_id")
396 if has_filter?("subproject_id")
397 case operator_for("subproject_id")
397 case operator_for("subproject_id")
398 when '='
398 when '='
399 # include the selected subprojects
399 # include the selected subprojects
400 ids += values_for("subproject_id").each(&:to_i)
400 ids += values_for("subproject_id").each(&:to_i)
401 when '!*'
401 when '!*'
402 # main project only
402 # main project only
403 else
403 else
404 # all subprojects
404 # all subprojects
405 ids += project.descendants.collect(&:id)
405 ids += project.descendants.collect(&:id)
406 end
406 end
407 elsif Setting.display_subprojects_issues?
407 elsif Setting.display_subprojects_issues?
408 ids += project.descendants.collect(&:id)
408 ids += project.descendants.collect(&:id)
409 end
409 end
410 project_clauses << "#{Project.table_name}.id IN (%s)" % ids.join(',')
410 project_clauses << "#{Project.table_name}.id IN (%s)" % ids.join(',')
411 elsif project
411 elsif project
412 project_clauses << "#{Project.table_name}.id = %d" % project.id
412 project_clauses << "#{Project.table_name}.id = %d" % project.id
413 end
413 end
414 project_clauses << Project.allowed_to_condition(User.current, :view_issues)
414 project_clauses << Issue.visible_condition(User.current)
415 project_clauses.join(' AND ')
415 project_clauses.join(' AND ')
416 end
416 end
417
417
418 def statement
418 def statement
419 # filters clauses
419 # filters clauses
420 filters_clauses = []
420 filters_clauses = []
421 filters.each_key do |field|
421 filters.each_key do |field|
422 next if field == "subproject_id"
422 next if field == "subproject_id"
423 v = values_for(field).clone
423 v = values_for(field).clone
424 next unless v and !v.empty?
424 next unless v and !v.empty?
425 operator = operator_for(field)
425 operator = operator_for(field)
426
426
427 # "me" value subsitution
427 # "me" value subsitution
428 if %w(assigned_to_id author_id watcher_id).include?(field)
428 if %w(assigned_to_id author_id watcher_id).include?(field)
429 v.push(User.current.logged? ? User.current.id.to_s : "0") if v.delete("me")
429 v.push(User.current.logged? ? User.current.id.to_s : "0") if v.delete("me")
430 end
430 end
431
431
432 sql = ''
432 sql = ''
433 if field =~ /^cf_(\d+)$/
433 if field =~ /^cf_(\d+)$/
434 # custom field
434 # custom field
435 db_table = CustomValue.table_name
435 db_table = CustomValue.table_name
436 db_field = 'value'
436 db_field = 'value'
437 is_custom_filter = true
437 is_custom_filter = true
438 sql << "#{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=#{$1} WHERE "
438 sql << "#{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=#{$1} WHERE "
439 sql << sql_for_field(field, operator, v, db_table, db_field, true) + ')'
439 sql << sql_for_field(field, operator, v, db_table, db_field, true) + ')'
440 elsif field == 'watcher_id'
440 elsif field == 'watcher_id'
441 db_table = Watcher.table_name
441 db_table = Watcher.table_name
442 db_field = 'user_id'
442 db_field = 'user_id'
443 sql << "#{Issue.table_name}.id #{ operator == '=' ? 'IN' : 'NOT IN' } (SELECT #{db_table}.watchable_id FROM #{db_table} WHERE #{db_table}.watchable_type='Issue' AND "
443 sql << "#{Issue.table_name}.id #{ operator == '=' ? 'IN' : 'NOT IN' } (SELECT #{db_table}.watchable_id FROM #{db_table} WHERE #{db_table}.watchable_type='Issue' AND "
444 sql << sql_for_field(field, '=', v, db_table, db_field) + ')'
444 sql << sql_for_field(field, '=', v, db_table, db_field) + ')'
445 elsif field == "member_of_group" # named field
445 elsif field == "member_of_group" # named field
446 if operator == '*' # Any group
446 if operator == '*' # Any group
447 groups = Group.all
447 groups = Group.all
448 operator = '=' # Override the operator since we want to find by assigned_to
448 operator = '=' # Override the operator since we want to find by assigned_to
449 elsif operator == "!*"
449 elsif operator == "!*"
450 groups = Group.all
450 groups = Group.all
451 operator = '!' # Override the operator since we want to find by assigned_to
451 operator = '!' # Override the operator since we want to find by assigned_to
452 else
452 else
453 groups = Group.find_all_by_id(v)
453 groups = Group.find_all_by_id(v)
454 end
454 end
455 groups ||= []
455 groups ||= []
456
456
457 members_of_groups = groups.inject([]) {|user_ids, group|
457 members_of_groups = groups.inject([]) {|user_ids, group|
458 if group && group.user_ids.present?
458 if group && group.user_ids.present?
459 user_ids << group.user_ids
459 user_ids << group.user_ids
460 end
460 end
461 user_ids.flatten.uniq.compact
461 user_ids.flatten.uniq.compact
462 }.sort.collect(&:to_s)
462 }.sort.collect(&:to_s)
463
463
464 sql << '(' + sql_for_field("assigned_to_id", operator, members_of_groups, Issue.table_name, "assigned_to_id", false) + ')'
464 sql << '(' + sql_for_field("assigned_to_id", operator, members_of_groups, Issue.table_name, "assigned_to_id", false) + ')'
465
465
466 elsif field == "assigned_to_role" # named field
466 elsif field == "assigned_to_role" # named field
467 if operator == "*" # Any Role
467 if operator == "*" # Any Role
468 roles = Role.givable
468 roles = Role.givable
469 operator = '=' # Override the operator since we want to find by assigned_to
469 operator = '=' # Override the operator since we want to find by assigned_to
470 elsif operator == "!*" # No role
470 elsif operator == "!*" # No role
471 roles = Role.givable
471 roles = Role.givable
472 operator = '!' # Override the operator since we want to find by assigned_to
472 operator = '!' # Override the operator since we want to find by assigned_to
473 else
473 else
474 roles = Role.givable.find_all_by_id(v)
474 roles = Role.givable.find_all_by_id(v)
475 end
475 end
476 roles ||= []
476 roles ||= []
477
477
478 members_of_roles = roles.inject([]) {|user_ids, role|
478 members_of_roles = roles.inject([]) {|user_ids, role|
479 if role && role.members
479 if role && role.members
480 user_ids << role.members.collect(&:user_id)
480 user_ids << role.members.collect(&:user_id)
481 end
481 end
482 user_ids.flatten.uniq.compact
482 user_ids.flatten.uniq.compact
483 }.sort.collect(&:to_s)
483 }.sort.collect(&:to_s)
484
484
485 sql << '(' + sql_for_field("assigned_to_id", operator, members_of_roles, Issue.table_name, "assigned_to_id", false) + ')'
485 sql << '(' + sql_for_field("assigned_to_id", operator, members_of_roles, Issue.table_name, "assigned_to_id", false) + ')'
486 else
486 else
487 # regular field
487 # regular field
488 db_table = Issue.table_name
488 db_table = Issue.table_name
489 db_field = field
489 db_field = field
490 sql << '(' + sql_for_field(field, operator, v, db_table, db_field) + ')'
490 sql << '(' + sql_for_field(field, operator, v, db_table, db_field) + ')'
491 end
491 end
492 filters_clauses << sql
492 filters_clauses << sql
493
493
494 end if filters and valid?
494 end if filters and valid?
495
495
496 (filters_clauses << project_statement).join(' AND ')
496 (filters_clauses << project_statement).join(' AND ')
497 end
497 end
498
498
499 # Returns the issue count
499 # Returns the issue count
500 def issue_count
500 def issue_count
501 Issue.count(:include => [:status, :project], :conditions => statement)
501 Issue.count(:include => [:status, :project], :conditions => statement)
502 rescue ::ActiveRecord::StatementInvalid => e
502 rescue ::ActiveRecord::StatementInvalid => e
503 raise StatementInvalid.new(e.message)
503 raise StatementInvalid.new(e.message)
504 end
504 end
505
505
506 # Returns the issue count by group or nil if query is not grouped
506 # Returns the issue count by group or nil if query is not grouped
507 def issue_count_by_group
507 def issue_count_by_group
508 r = nil
508 r = nil
509 if grouped?
509 if grouped?
510 begin
510 begin
511 # Rails will raise an (unexpected) RecordNotFound if there's only a nil group value
511 # Rails will raise an (unexpected) RecordNotFound if there's only a nil group value
512 r = Issue.count(:group => group_by_statement, :include => [:status, :project], :conditions => statement)
512 r = Issue.count(:group => group_by_statement, :include => [:status, :project], :conditions => statement)
513 rescue ActiveRecord::RecordNotFound
513 rescue ActiveRecord::RecordNotFound
514 r = {nil => issue_count}
514 r = {nil => issue_count}
515 end
515 end
516 c = group_by_column
516 c = group_by_column
517 if c.is_a?(QueryCustomFieldColumn)
517 if c.is_a?(QueryCustomFieldColumn)
518 r = r.keys.inject({}) {|h, k| h[c.custom_field.cast_value(k)] = r[k]; h}
518 r = r.keys.inject({}) {|h, k| h[c.custom_field.cast_value(k)] = r[k]; h}
519 end
519 end
520 end
520 end
521 r
521 r
522 rescue ::ActiveRecord::StatementInvalid => e
522 rescue ::ActiveRecord::StatementInvalid => e
523 raise StatementInvalid.new(e.message)
523 raise StatementInvalid.new(e.message)
524 end
524 end
525
525
526 # Returns the issues
526 # Returns the issues
527 # Valid options are :order, :offset, :limit, :include, :conditions
527 # Valid options are :order, :offset, :limit, :include, :conditions
528 def issues(options={})
528 def issues(options={})
529 order_option = [group_by_sort_order, options[:order]].reject {|s| s.blank?}.join(',')
529 order_option = [group_by_sort_order, options[:order]].reject {|s| s.blank?}.join(',')
530 order_option = nil if order_option.blank?
530 order_option = nil if order_option.blank?
531
531
532 Issue.find :all, :include => ([:status, :project] + (options[:include] || [])).uniq,
532 Issue.find :all, :include => ([:status, :project] + (options[:include] || [])).uniq,
533 :conditions => Query.merge_conditions(statement, options[:conditions]),
533 :conditions => Query.merge_conditions(statement, options[:conditions]),
534 :order => order_option,
534 :order => order_option,
535 :limit => options[:limit],
535 :limit => options[:limit],
536 :offset => options[:offset]
536 :offset => options[:offset]
537 rescue ::ActiveRecord::StatementInvalid => e
537 rescue ::ActiveRecord::StatementInvalid => e
538 raise StatementInvalid.new(e.message)
538 raise StatementInvalid.new(e.message)
539 end
539 end
540
540
541 # Returns the journals
541 # Returns the journals
542 # Valid options are :order, :offset, :limit
542 # Valid options are :order, :offset, :limit
543 def journals(options={})
543 def journals(options={})
544 Journal.find :all, :include => [:details, :user, {:issue => [:project, :author, :tracker, :status]}],
544 Journal.find :all, :include => [:details, :user, {:issue => [:project, :author, :tracker, :status]}],
545 :conditions => statement,
545 :conditions => statement,
546 :order => options[:order],
546 :order => options[:order],
547 :limit => options[:limit],
547 :limit => options[:limit],
548 :offset => options[:offset]
548 :offset => options[:offset]
549 rescue ::ActiveRecord::StatementInvalid => e
549 rescue ::ActiveRecord::StatementInvalid => e
550 raise StatementInvalid.new(e.message)
550 raise StatementInvalid.new(e.message)
551 end
551 end
552
552
553 # Returns the versions
553 # Returns the versions
554 # Valid options are :conditions
554 # Valid options are :conditions
555 def versions(options={})
555 def versions(options={})
556 Version.find :all, :include => :project,
556 Version.find :all, :include => :project,
557 :conditions => Query.merge_conditions(project_statement, options[:conditions])
557 :conditions => Query.merge_conditions(project_statement, options[:conditions])
558 rescue ::ActiveRecord::StatementInvalid => e
558 rescue ::ActiveRecord::StatementInvalid => e
559 raise StatementInvalid.new(e.message)
559 raise StatementInvalid.new(e.message)
560 end
560 end
561
561
562 private
562 private
563
563
564 # Helper method to generate the WHERE sql for a +field+, +operator+ and a +value+
564 # Helper method to generate the WHERE sql for a +field+, +operator+ and a +value+
565 def sql_for_field(field, operator, value, db_table, db_field, is_custom_filter=false)
565 def sql_for_field(field, operator, value, db_table, db_field, is_custom_filter=false)
566 sql = ''
566 sql = ''
567 case operator
567 case operator
568 when "="
568 when "="
569 if value.any?
569 if value.any?
570 sql = "#{db_table}.#{db_field} IN (" + value.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + ")"
570 sql = "#{db_table}.#{db_field} IN (" + value.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + ")"
571 else
571 else
572 # IN an empty set
572 # IN an empty set
573 sql = "1=0"
573 sql = "1=0"
574 end
574 end
575 when "!"
575 when "!"
576 if value.any?
576 if value.any?
577 sql = "(#{db_table}.#{db_field} IS NULL OR #{db_table}.#{db_field} NOT IN (" + value.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + "))"
577 sql = "(#{db_table}.#{db_field} IS NULL OR #{db_table}.#{db_field} NOT IN (" + value.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + "))"
578 else
578 else
579 # NOT IN an empty set
579 # NOT IN an empty set
580 sql = "1=1"
580 sql = "1=1"
581 end
581 end
582 when "!*"
582 when "!*"
583 sql = "#{db_table}.#{db_field} IS NULL"
583 sql = "#{db_table}.#{db_field} IS NULL"
584 sql << " OR #{db_table}.#{db_field} = ''" if is_custom_filter
584 sql << " OR #{db_table}.#{db_field} = ''" if is_custom_filter
585 when "*"
585 when "*"
586 sql = "#{db_table}.#{db_field} IS NOT NULL"
586 sql = "#{db_table}.#{db_field} IS NOT NULL"
587 sql << " AND #{db_table}.#{db_field} <> ''" if is_custom_filter
587 sql << " AND #{db_table}.#{db_field} <> ''" if is_custom_filter
588 when ">="
588 when ">="
589 sql = "#{db_table}.#{db_field} >= #{value.first.to_i}"
589 sql = "#{db_table}.#{db_field} >= #{value.first.to_i}"
590 when "<="
590 when "<="
591 sql = "#{db_table}.#{db_field} <= #{value.first.to_i}"
591 sql = "#{db_table}.#{db_field} <= #{value.first.to_i}"
592 when "o"
592 when "o"
593 sql = "#{IssueStatus.table_name}.is_closed=#{connection.quoted_false}" if field == "status_id"
593 sql = "#{IssueStatus.table_name}.is_closed=#{connection.quoted_false}" if field == "status_id"
594 when "c"
594 when "c"
595 sql = "#{IssueStatus.table_name}.is_closed=#{connection.quoted_true}" if field == "status_id"
595 sql = "#{IssueStatus.table_name}.is_closed=#{connection.quoted_true}" if field == "status_id"
596 when ">t-"
596 when ">t-"
597 sql = date_range_clause(db_table, db_field, - value.first.to_i, 0)
597 sql = date_range_clause(db_table, db_field, - value.first.to_i, 0)
598 when "<t-"
598 when "<t-"
599 sql = date_range_clause(db_table, db_field, nil, - value.first.to_i)
599 sql = date_range_clause(db_table, db_field, nil, - value.first.to_i)
600 when "t-"
600 when "t-"
601 sql = date_range_clause(db_table, db_field, - value.first.to_i, - value.first.to_i)
601 sql = date_range_clause(db_table, db_field, - value.first.to_i, - value.first.to_i)
602 when ">t+"
602 when ">t+"
603 sql = date_range_clause(db_table, db_field, value.first.to_i, nil)
603 sql = date_range_clause(db_table, db_field, value.first.to_i, nil)
604 when "<t+"
604 when "<t+"
605 sql = date_range_clause(db_table, db_field, 0, value.first.to_i)
605 sql = date_range_clause(db_table, db_field, 0, value.first.to_i)
606 when "t+"
606 when "t+"
607 sql = date_range_clause(db_table, db_field, value.first.to_i, value.first.to_i)
607 sql = date_range_clause(db_table, db_field, value.first.to_i, value.first.to_i)
608 when "t"
608 when "t"
609 sql = date_range_clause(db_table, db_field, 0, 0)
609 sql = date_range_clause(db_table, db_field, 0, 0)
610 when "w"
610 when "w"
611 from = l(:general_first_day_of_week) == '7' ?
611 from = l(:general_first_day_of_week) == '7' ?
612 # week starts on sunday
612 # week starts on sunday
613 ((Date.today.cwday == 7) ? Time.now.at_beginning_of_day : Time.now.at_beginning_of_week - 1.day) :
613 ((Date.today.cwday == 7) ? Time.now.at_beginning_of_day : Time.now.at_beginning_of_week - 1.day) :
614 # week starts on monday (Rails default)
614 # week starts on monday (Rails default)
615 Time.now.at_beginning_of_week
615 Time.now.at_beginning_of_week
616 sql = "#{db_table}.#{db_field} BETWEEN '%s' AND '%s'" % [connection.quoted_date(from), connection.quoted_date(from + 7.days)]
616 sql = "#{db_table}.#{db_field} BETWEEN '%s' AND '%s'" % [connection.quoted_date(from), connection.quoted_date(from + 7.days)]
617 when "~"
617 when "~"
618 sql = "LOWER(#{db_table}.#{db_field}) LIKE '%#{connection.quote_string(value.first.to_s.downcase)}%'"
618 sql = "LOWER(#{db_table}.#{db_field}) LIKE '%#{connection.quote_string(value.first.to_s.downcase)}%'"
619 when "!~"
619 when "!~"
620 sql = "LOWER(#{db_table}.#{db_field}) NOT LIKE '%#{connection.quote_string(value.first.to_s.downcase)}%'"
620 sql = "LOWER(#{db_table}.#{db_field}) NOT LIKE '%#{connection.quote_string(value.first.to_s.downcase)}%'"
621 end
621 end
622
622
623 return sql
623 return sql
624 end
624 end
625
625
626 def add_custom_fields_filters(custom_fields)
626 def add_custom_fields_filters(custom_fields)
627 @available_filters ||= {}
627 @available_filters ||= {}
628
628
629 custom_fields.select(&:is_filter?).each do |field|
629 custom_fields.select(&:is_filter?).each do |field|
630 case field.field_format
630 case field.field_format
631 when "text"
631 when "text"
632 options = { :type => :text, :order => 20 }
632 options = { :type => :text, :order => 20 }
633 when "list"
633 when "list"
634 options = { :type => :list_optional, :values => field.possible_values, :order => 20}
634 options = { :type => :list_optional, :values => field.possible_values, :order => 20}
635 when "date"
635 when "date"
636 options = { :type => :date, :order => 20 }
636 options = { :type => :date, :order => 20 }
637 when "bool"
637 when "bool"
638 options = { :type => :list, :values => [[l(:general_text_yes), "1"], [l(:general_text_no), "0"]], :order => 20 }
638 options = { :type => :list, :values => [[l(:general_text_yes), "1"], [l(:general_text_no), "0"]], :order => 20 }
639 else
639 else
640 options = { :type => :string, :order => 20 }
640 options = { :type => :string, :order => 20 }
641 end
641 end
642 @available_filters["cf_#{field.id}"] = options.merge({ :name => field.name })
642 @available_filters["cf_#{field.id}"] = options.merge({ :name => field.name })
643 end
643 end
644 end
644 end
645
645
646 # Returns a SQL clause for a date or datetime field.
646 # Returns a SQL clause for a date or datetime field.
647 def date_range_clause(table, field, from, to)
647 def date_range_clause(table, field, from, to)
648 s = []
648 s = []
649 if from
649 if from
650 s << ("#{table}.#{field} > '%s'" % [connection.quoted_date((Date.yesterday + from).to_time.end_of_day)])
650 s << ("#{table}.#{field} > '%s'" % [connection.quoted_date((Date.yesterday + from).to_time.end_of_day)])
651 end
651 end
652 if to
652 if to
653 s << ("#{table}.#{field} <= '%s'" % [connection.quoted_date((Date.today + to).to_time.end_of_day)])
653 s << ("#{table}.#{field} <= '%s'" % [connection.quoted_date((Date.today + to).to_time.end_of_day)])
654 end
654 end
655 s.join(' AND ')
655 s.join(' AND ')
656 end
656 end
657 end
657 end
General Comments 0
You need to be logged in to leave comments. Login now