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