##// END OF EJS Templates
Search custom fields and journals with different queries to take advantage of indexes on text columns if present....
Jean-Philippe Lang -
r13473:742895183aeb
parent child
Show More
@@ -1,1565 +1,1560
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2014 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 include Redmine::Utils::DateCalculation
21 21 include Redmine::I18n
22 22 before_save :set_parent_id
23 23 include Redmine::NestedSet::IssueNestedSet
24 24
25 25 belongs_to :project
26 26 belongs_to :tracker
27 27 belongs_to :status, :class_name => 'IssueStatus'
28 28 belongs_to :author, :class_name => 'User'
29 29 belongs_to :assigned_to, :class_name => 'Principal'
30 30 belongs_to :fixed_version, :class_name => 'Version'
31 31 belongs_to :priority, :class_name => 'IssuePriority'
32 32 belongs_to :category, :class_name => 'IssueCategory'
33 33
34 34 has_many :journals, :as => :journalized, :dependent => :destroy
35 35 has_many :visible_journals,
36 36 lambda {where(["(#{Journal.table_name}.private_notes = ? OR (#{Project.allowed_to_condition(User.current, :view_private_notes)}))", false])},
37 37 :class_name => 'Journal',
38 38 :as => :journalized
39 39
40 40 has_many :time_entries, :dependent => :destroy
41 41 has_and_belongs_to_many :changesets, lambda {order("#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC")}
42 42
43 43 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
44 44 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
45 45
46 46 acts_as_attachable :after_add => :attachment_added, :after_remove => :attachment_removed
47 47 acts_as_customizable
48 48 acts_as_watchable
49 acts_as_searchable :columns => ['subject', "#{table_name}.description", "#{Journal.table_name}.notes"],
50 :preload => [:project, :status, :tracker],
51 :scope => lambda { joins(:project).
52 joins("LEFT OUTER JOIN #{Journal.table_name} ON #{Journal.table_name}.journalized_type='Issue'" +
53 " AND #{Journal.table_name}.journalized_id = #{Issue.table_name}.id" +
54 " AND (#{Journal.table_name}.private_notes = #{connection.quoted_false}" +
55 " OR (#{Project.allowed_to_condition(User.current, :view_private_notes)}))") }
49 acts_as_searchable :columns => ['subject', "#{table_name}.description"],
50 :preload => [:project, :status, :tracker]
56 51
57 52 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
58 53 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
59 54 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
60 55
61 56 acts_as_activity_provider :scope => preload(:project, :author, :tracker),
62 57 :author_key => :author_id
63 58
64 59 DONE_RATIO_OPTIONS = %w(issue_field issue_status)
65 60
66 61 attr_reader :current_journal
67 62 delegate :notes, :notes=, :private_notes, :private_notes=, :to => :current_journal, :allow_nil => true
68 63
69 64 validates_presence_of :subject, :project, :tracker
70 65 validates_presence_of :priority, :if => Proc.new {|issue| issue.new_record? || issue.priority_id_changed?}
71 66 validates_presence_of :status, :if => Proc.new {|issue| issue.new_record? || issue.status_id_changed?}
72 67 validates_presence_of :author, :if => Proc.new {|issue| issue.new_record? || issue.author_id_changed?}
73 68
74 69 validates_length_of :subject, :maximum => 255
75 70 validates_inclusion_of :done_ratio, :in => 0..100
76 71 validates :estimated_hours, :numericality => {:greater_than_or_equal_to => 0, :allow_nil => true, :message => :invalid}
77 72 validates :start_date, :date => true
78 73 validates :due_date, :date => true
79 74 validate :validate_issue, :validate_required_fields
80 75 attr_protected :id
81 76
82 77 scope :visible, lambda {|*args|
83 78 includes(:project).
84 79 references(:project).
85 80 where(Issue.visible_condition(args.shift || User.current, *args))
86 81 }
87 82
88 83 scope :open, lambda {|*args|
89 84 is_closed = args.size > 0 ? !args.first : false
90 85 joins(:status).
91 86 where("#{IssueStatus.table_name}.is_closed = ?", is_closed)
92 87 }
93 88
94 89 scope :recently_updated, lambda { order("#{Issue.table_name}.updated_on DESC") }
95 90 scope :on_active_project, lambda {
96 91 joins(:project).
97 92 where("#{Project.table_name}.status = ?", Project::STATUS_ACTIVE)
98 93 }
99 94 scope :fixed_version, lambda {|versions|
100 95 ids = [versions].flatten.compact.map {|v| v.is_a?(Version) ? v.id : v}
101 96 ids.any? ? where(:fixed_version_id => ids) : where('1=0')
102 97 }
103 98
104 99 before_create :default_assign
105 100 before_save :close_duplicates, :update_done_ratio_from_issue_status,
106 101 :force_updated_on_change, :update_closed_on, :set_assigned_to_was
107 102 after_save {|issue| issue.send :after_project_change if !issue.id_changed? && issue.project_id_changed?}
108 103 after_save :reschedule_following_issues, :update_nested_set_attributes,
109 104 :update_parent_attributes, :create_journal
110 105 # Should be after_create but would be called before previous after_save callbacks
111 106 after_save :after_create_from_copy
112 107 after_destroy :update_parent_attributes
113 108 after_create :send_notification
114 109 # Keep it at the end of after_save callbacks
115 110 after_save :clear_assigned_to_was
116 111
117 112 # Returns a SQL conditions string used to find all issues visible by the specified user
118 113 def self.visible_condition(user, options={})
119 114 Project.allowed_to_condition(user, :view_issues, options) do |role, user|
120 115 if user.id && user.logged?
121 116 case role.issues_visibility
122 117 when 'all'
123 118 nil
124 119 when 'default'
125 120 user_ids = [user.id] + user.groups.map(&:id).compact
126 121 "(#{table_name}.is_private = #{connection.quoted_false} OR #{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
127 122 when 'own'
128 123 user_ids = [user.id] + user.groups.map(&:id).compact
129 124 "(#{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
130 125 else
131 126 '1=0'
132 127 end
133 128 else
134 129 "(#{table_name}.is_private = #{connection.quoted_false})"
135 130 end
136 131 end
137 132 end
138 133
139 134 # Returns true if usr or current user is allowed to view the issue
140 135 def visible?(usr=nil)
141 136 (usr || User.current).allowed_to?(:view_issues, self.project) do |role, user|
142 137 if user.logged?
143 138 case role.issues_visibility
144 139 when 'all'
145 140 true
146 141 when 'default'
147 142 !self.is_private? || (self.author == user || user.is_or_belongs_to?(assigned_to))
148 143 when 'own'
149 144 self.author == user || user.is_or_belongs_to?(assigned_to)
150 145 else
151 146 false
152 147 end
153 148 else
154 149 !self.is_private?
155 150 end
156 151 end
157 152 end
158 153
159 154 # Returns true if user or current user is allowed to edit or add a note to the issue
160 155 def editable?(user=User.current)
161 156 user.allowed_to?(:edit_issues, project) || user.allowed_to?(:add_issue_notes, project)
162 157 end
163 158
164 159 def initialize(attributes=nil, *args)
165 160 super
166 161 if new_record?
167 162 # set default values for new records only
168 163 self.priority ||= IssuePriority.default
169 164 self.watcher_user_ids = []
170 165 end
171 166 end
172 167
173 168 def create_or_update
174 169 super
175 170 ensure
176 171 @status_was = nil
177 172 end
178 173 private :create_or_update
179 174
180 175 # AR#Persistence#destroy would raise and RecordNotFound exception
181 176 # if the issue was already deleted or updated (non matching lock_version).
182 177 # This is a problem when bulk deleting issues or deleting a project
183 178 # (because an issue may already be deleted if its parent was deleted
184 179 # first).
185 180 # The issue is reloaded by the nested_set before being deleted so
186 181 # the lock_version condition should not be an issue but we handle it.
187 182 def destroy
188 183 super
189 184 rescue ActiveRecord::StaleObjectError, ActiveRecord::RecordNotFound
190 185 # Stale or already deleted
191 186 begin
192 187 reload
193 188 rescue ActiveRecord::RecordNotFound
194 189 # The issue was actually already deleted
195 190 @destroyed = true
196 191 return freeze
197 192 end
198 193 # The issue was stale, retry to destroy
199 194 super
200 195 end
201 196
202 197 alias :base_reload :reload
203 198 def reload(*args)
204 199 @workflow_rule_by_attribute = nil
205 200 @assignable_versions = nil
206 201 @relations = nil
207 202 @spent_hours = nil
208 203 base_reload(*args)
209 204 end
210 205
211 206 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
212 207 def available_custom_fields
213 208 (project && tracker) ? (project.all_issue_custom_fields & tracker.custom_fields) : []
214 209 end
215 210
216 211 def visible_custom_field_values(user=nil)
217 212 user_real = user || User.current
218 213 custom_field_values.select do |value|
219 214 value.custom_field.visible_by?(project, user_real)
220 215 end
221 216 end
222 217
223 218 # Copies attributes from another issue, arg can be an id or an Issue
224 219 def copy_from(arg, options={})
225 220 issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg)
226 221 self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
227 222 self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
228 223 self.status = issue.status
229 224 self.author = User.current
230 225 unless options[:attachments] == false
231 226 self.attachments = issue.attachments.map do |attachement|
232 227 attachement.copy(:container => self)
233 228 end
234 229 end
235 230 @copied_from = issue
236 231 @copy_options = options
237 232 self
238 233 end
239 234
240 235 # Returns an unsaved copy of the issue
241 236 def copy(attributes=nil, copy_options={})
242 237 copy = self.class.new.copy_from(self, copy_options)
243 238 copy.attributes = attributes if attributes
244 239 copy
245 240 end
246 241
247 242 # Returns true if the issue is a copy
248 243 def copy?
249 244 @copied_from.present?
250 245 end
251 246
252 247 def status_id=(status_id)
253 248 if status_id.to_s != self.status_id.to_s
254 249 self.status = (status_id.present? ? IssueStatus.find_by_id(status_id) : nil)
255 250 end
256 251 self.status_id
257 252 end
258 253
259 254 # Sets the status.
260 255 def status=(status)
261 256 if status != self.status
262 257 @workflow_rule_by_attribute = nil
263 258 end
264 259 association(:status).writer(status)
265 260 end
266 261
267 262 def priority_id=(pid)
268 263 self.priority = nil
269 264 write_attribute(:priority_id, pid)
270 265 end
271 266
272 267 def category_id=(cid)
273 268 self.category = nil
274 269 write_attribute(:category_id, cid)
275 270 end
276 271
277 272 def fixed_version_id=(vid)
278 273 self.fixed_version = nil
279 274 write_attribute(:fixed_version_id, vid)
280 275 end
281 276
282 277 def tracker_id=(tracker_id)
283 278 if tracker_id.to_s != self.tracker_id.to_s
284 279 self.tracker = (tracker_id.present? ? Tracker.find_by_id(tracker_id) : nil)
285 280 end
286 281 self.tracker_id
287 282 end
288 283
289 284 # Sets the tracker.
290 285 # This will set the status to the default status of the new tracker if:
291 286 # * the status was the default for the previous tracker
292 287 # * or if the status was not part of the new tracker statuses
293 288 # * or the status was nil
294 289 def tracker=(tracker)
295 290 if tracker != self.tracker
296 291 if status == default_status
297 292 self.status = nil
298 293 elsif status && tracker && !tracker.issue_status_ids.include?(status.id)
299 294 self.status = nil
300 295 end
301 296 @custom_field_values = nil
302 297 @workflow_rule_by_attribute = nil
303 298 end
304 299 association(:tracker).writer(tracker)
305 300 self.status ||= default_status
306 301 self.tracker
307 302 end
308 303
309 304 def project_id=(project_id)
310 305 if project_id.to_s != self.project_id.to_s
311 306 self.project = (project_id.present? ? Project.find_by_id(project_id) : nil)
312 307 end
313 308 self.project_id
314 309 end
315 310
316 311 # Sets the project.
317 312 # Unless keep_tracker argument is set to true, this will change the tracker
318 313 # to the first tracker of the new project if the previous tracker is not part
319 314 # of the new project trackers.
320 315 # This will clear the fixed_version is it's no longer valid for the new project.
321 316 # This will clear the parent issue if it's no longer valid for the new project.
322 317 # This will set the category to the category with the same name in the new
323 318 # project if it exists, or clear it if it doesn't.
324 319 def project=(project, keep_tracker=false)
325 320 project_was = self.project
326 321 association(:project).writer(project)
327 322 if project_was && project && project_was != project
328 323 @assignable_versions = nil
329 324
330 325 unless keep_tracker || project.trackers.include?(tracker)
331 326 self.tracker = project.trackers.first
332 327 end
333 328 # Reassign to the category with same name if any
334 329 if category
335 330 self.category = project.issue_categories.find_by_name(category.name)
336 331 end
337 332 # Keep the fixed_version if it's still valid in the new_project
338 333 if fixed_version && fixed_version.project != project && !project.shared_versions.include?(fixed_version)
339 334 self.fixed_version = nil
340 335 end
341 336 # Clear the parent task if it's no longer valid
342 337 unless valid_parent_project?
343 338 self.parent_issue_id = nil
344 339 end
345 340 @custom_field_values = nil
346 341 @workflow_rule_by_attribute = nil
347 342 end
348 343 self.project
349 344 end
350 345
351 346 def description=(arg)
352 347 if arg.is_a?(String)
353 348 arg = arg.gsub(/(\r\n|\n|\r)/, "\r\n")
354 349 end
355 350 write_attribute(:description, arg)
356 351 end
357 352
358 353 # Overrides assign_attributes so that project and tracker get assigned first
359 354 def assign_attributes_with_project_and_tracker_first(new_attributes, *args)
360 355 return if new_attributes.nil?
361 356 attrs = new_attributes.dup
362 357 attrs.stringify_keys!
363 358
364 359 %w(project project_id tracker tracker_id).each do |attr|
365 360 if attrs.has_key?(attr)
366 361 send "#{attr}=", attrs.delete(attr)
367 362 end
368 363 end
369 364 send :assign_attributes_without_project_and_tracker_first, attrs, *args
370 365 end
371 366 # Do not redefine alias chain on reload (see #4838)
372 367 alias_method_chain(:assign_attributes, :project_and_tracker_first) unless method_defined?(:assign_attributes_without_project_and_tracker_first)
373 368
374 369 def attributes=(new_attributes)
375 370 assign_attributes new_attributes
376 371 end
377 372
378 373 def estimated_hours=(h)
379 374 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
380 375 end
381 376
382 377 safe_attributes 'project_id',
383 378 :if => lambda {|issue, user|
384 379 if issue.new_record?
385 380 issue.copy?
386 381 elsif user.allowed_to?(:move_issues, issue.project)
387 382 Issue.allowed_target_projects_on_move.count > 1
388 383 end
389 384 }
390 385
391 386 safe_attributes 'tracker_id',
392 387 'status_id',
393 388 'category_id',
394 389 'assigned_to_id',
395 390 'priority_id',
396 391 'fixed_version_id',
397 392 'subject',
398 393 'description',
399 394 'start_date',
400 395 'due_date',
401 396 'done_ratio',
402 397 'estimated_hours',
403 398 'custom_field_values',
404 399 'custom_fields',
405 400 'lock_version',
406 401 'notes',
407 402 :if => lambda {|issue, user| issue.new_record? || user.allowed_to?(:edit_issues, issue.project) }
408 403
409 404 safe_attributes 'notes',
410 405 :if => lambda {|issue, user| user.allowed_to?(:add_issue_notes, issue.project)}
411 406
412 407 safe_attributes 'private_notes',
413 408 :if => lambda {|issue, user| !issue.new_record? && user.allowed_to?(:set_notes_private, issue.project)}
414 409
415 410 safe_attributes 'watcher_user_ids',
416 411 :if => lambda {|issue, user| issue.new_record? && user.allowed_to?(:add_issue_watchers, issue.project)}
417 412
418 413 safe_attributes 'is_private',
419 414 :if => lambda {|issue, user|
420 415 user.allowed_to?(:set_issues_private, issue.project) ||
421 416 (issue.author_id == user.id && user.allowed_to?(:set_own_issues_private, issue.project))
422 417 }
423 418
424 419 safe_attributes 'parent_issue_id',
425 420 :if => lambda {|issue, user| (issue.new_record? || user.allowed_to?(:edit_issues, issue.project)) &&
426 421 user.allowed_to?(:manage_subtasks, issue.project)}
427 422
428 423 def safe_attribute_names(user=nil)
429 424 names = super
430 425 names -= disabled_core_fields
431 426 names -= read_only_attribute_names(user)
432 427 names
433 428 end
434 429
435 430 # Safely sets attributes
436 431 # Should be called from controllers instead of #attributes=
437 432 # attr_accessible is too rough because we still want things like
438 433 # Issue.new(:project => foo) to work
439 434 def safe_attributes=(attrs, user=User.current)
440 435 return unless attrs.is_a?(Hash)
441 436
442 437 attrs = attrs.deep_dup
443 438
444 439 # Project and Tracker must be set before since new_statuses_allowed_to depends on it.
445 440 if (p = attrs.delete('project_id')) && safe_attribute?('project_id')
446 441 if allowed_target_projects(user).where(:id => p.to_i).exists?
447 442 self.project_id = p
448 443 end
449 444 end
450 445
451 446 if (t = attrs.delete('tracker_id')) && safe_attribute?('tracker_id')
452 447 self.tracker_id = t
453 448 end
454 449
455 450 if (s = attrs.delete('status_id')) && safe_attribute?('status_id')
456 451 if new_statuses_allowed_to(user).collect(&:id).include?(s.to_i)
457 452 self.status_id = s
458 453 end
459 454 end
460 455
461 456 attrs = delete_unsafe_attributes(attrs, user)
462 457 return if attrs.empty?
463 458
464 459 unless leaf?
465 460 attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
466 461 end
467 462
468 463 if attrs['parent_issue_id'].present?
469 464 s = attrs['parent_issue_id'].to_s
470 465 unless (m = s.match(%r{\A#?(\d+)\z})) && (m[1] == parent_id.to_s || Issue.visible(user).exists?(m[1]))
471 466 @invalid_parent_issue_id = attrs.delete('parent_issue_id')
472 467 end
473 468 end
474 469
475 470 if attrs['custom_field_values'].present?
476 471 editable_custom_field_ids = editable_custom_field_values(user).map {|v| v.custom_field_id.to_s}
477 472 attrs['custom_field_values'].select! {|k, v| editable_custom_field_ids.include?(k.to_s)}
478 473 end
479 474
480 475 if attrs['custom_fields'].present?
481 476 editable_custom_field_ids = editable_custom_field_values(user).map {|v| v.custom_field_id.to_s}
482 477 attrs['custom_fields'].select! {|c| editable_custom_field_ids.include?(c['id'].to_s)}
483 478 end
484 479
485 480 # mass-assignment security bypass
486 481 assign_attributes attrs, :without_protection => true
487 482 end
488 483
489 484 def disabled_core_fields
490 485 tracker ? tracker.disabled_core_fields : []
491 486 end
492 487
493 488 # Returns the custom_field_values that can be edited by the given user
494 489 def editable_custom_field_values(user=nil)
495 490 visible_custom_field_values(user).reject do |value|
496 491 read_only_attribute_names(user).include?(value.custom_field_id.to_s)
497 492 end
498 493 end
499 494
500 495 # Returns the custom fields that can be edited by the given user
501 496 def editable_custom_fields(user=nil)
502 497 editable_custom_field_values(user).map(&:custom_field).uniq
503 498 end
504 499
505 500 # Returns the names of attributes that are read-only for user or the current user
506 501 # For users with multiple roles, the read-only fields are the intersection of
507 502 # read-only fields of each role
508 503 # The result is an array of strings where sustom fields are represented with their ids
509 504 #
510 505 # Examples:
511 506 # issue.read_only_attribute_names # => ['due_date', '2']
512 507 # issue.read_only_attribute_names(user) # => []
513 508 def read_only_attribute_names(user=nil)
514 509 workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'readonly'}.keys
515 510 end
516 511
517 512 # Returns the names of required attributes for user or the current user
518 513 # For users with multiple roles, the required fields are the intersection of
519 514 # required fields of each role
520 515 # The result is an array of strings where sustom fields are represented with their ids
521 516 #
522 517 # Examples:
523 518 # issue.required_attribute_names # => ['due_date', '2']
524 519 # issue.required_attribute_names(user) # => []
525 520 def required_attribute_names(user=nil)
526 521 workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'required'}.keys
527 522 end
528 523
529 524 # Returns true if the attribute is required for user
530 525 def required_attribute?(name, user=nil)
531 526 required_attribute_names(user).include?(name.to_s)
532 527 end
533 528
534 529 # Returns a hash of the workflow rule by attribute for the given user
535 530 #
536 531 # Examples:
537 532 # issue.workflow_rule_by_attribute # => {'due_date' => 'required', 'start_date' => 'readonly'}
538 533 def workflow_rule_by_attribute(user=nil)
539 534 return @workflow_rule_by_attribute if @workflow_rule_by_attribute && user.nil?
540 535
541 536 user_real = user || User.current
542 537 roles = user_real.admin ? Role.all.to_a : user_real.roles_for_project(project)
543 538 roles = roles.select(&:consider_workflow?)
544 539 return {} if roles.empty?
545 540
546 541 result = {}
547 542 workflow_permissions = WorkflowPermission.where(:tracker_id => tracker_id, :old_status_id => status_id, :role_id => roles.map(&:id)).to_a
548 543 if workflow_permissions.any?
549 544 workflow_rules = workflow_permissions.inject({}) do |h, wp|
550 545 h[wp.field_name] ||= []
551 546 h[wp.field_name] << wp.rule
552 547 h
553 548 end
554 549 workflow_rules.each do |attr, rules|
555 550 next if rules.size < roles.size
556 551 uniq_rules = rules.uniq
557 552 if uniq_rules.size == 1
558 553 result[attr] = uniq_rules.first
559 554 else
560 555 result[attr] = 'required'
561 556 end
562 557 end
563 558 end
564 559 @workflow_rule_by_attribute = result if user.nil?
565 560 result
566 561 end
567 562 private :workflow_rule_by_attribute
568 563
569 564 def done_ratio
570 565 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
571 566 status.default_done_ratio
572 567 else
573 568 read_attribute(:done_ratio)
574 569 end
575 570 end
576 571
577 572 def self.use_status_for_done_ratio?
578 573 Setting.issue_done_ratio == 'issue_status'
579 574 end
580 575
581 576 def self.use_field_for_done_ratio?
582 577 Setting.issue_done_ratio == 'issue_field'
583 578 end
584 579
585 580 def validate_issue
586 581 if due_date && start_date && (start_date_changed? || due_date_changed?) && due_date < start_date
587 582 errors.add :due_date, :greater_than_start_date
588 583 end
589 584
590 585 if start_date && start_date_changed? && soonest_start && start_date < soonest_start
591 586 errors.add :start_date, :earlier_than_minimum_start_date, :date => format_date(soonest_start)
592 587 end
593 588
594 589 if fixed_version
595 590 if !assignable_versions.include?(fixed_version)
596 591 errors.add :fixed_version_id, :inclusion
597 592 elsif reopening? && fixed_version.closed?
598 593 errors.add :base, I18n.t(:error_can_not_reopen_issue_on_closed_version)
599 594 end
600 595 end
601 596
602 597 # Checks that the issue can not be added/moved to a disabled tracker
603 598 if project && (tracker_id_changed? || project_id_changed?)
604 599 unless project.trackers.include?(tracker)
605 600 errors.add :tracker_id, :inclusion
606 601 end
607 602 end
608 603
609 604 # Checks parent issue assignment
610 605 if @invalid_parent_issue_id.present?
611 606 errors.add :parent_issue_id, :invalid
612 607 elsif @parent_issue
613 608 if !valid_parent_project?(@parent_issue)
614 609 errors.add :parent_issue_id, :invalid
615 610 elsif (@parent_issue != parent) && (all_dependent_issues.include?(@parent_issue) || @parent_issue.all_dependent_issues.include?(self))
616 611 errors.add :parent_issue_id, :invalid
617 612 elsif !new_record?
618 613 # moving an existing issue
619 614 if move_possible?(@parent_issue)
620 615 # move accepted
621 616 else
622 617 errors.add :parent_issue_id, :invalid
623 618 end
624 619 end
625 620 end
626 621 end
627 622
628 623 # Validates the issue against additional workflow requirements
629 624 def validate_required_fields
630 625 user = new_record? ? author : current_journal.try(:user)
631 626
632 627 required_attribute_names(user).each do |attribute|
633 628 if attribute =~ /^\d+$/
634 629 attribute = attribute.to_i
635 630 v = custom_field_values.detect {|v| v.custom_field_id == attribute }
636 631 if v && v.value.blank?
637 632 errors.add :base, v.custom_field.name + ' ' + l('activerecord.errors.messages.blank')
638 633 end
639 634 else
640 635 if respond_to?(attribute) && send(attribute).blank? && !disabled_core_fields.include?(attribute)
641 636 errors.add attribute, :blank
642 637 end
643 638 end
644 639 end
645 640 end
646 641
647 642 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
648 643 # even if the user turns off the setting later
649 644 def update_done_ratio_from_issue_status
650 645 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
651 646 self.done_ratio = status.default_done_ratio
652 647 end
653 648 end
654 649
655 650 def init_journal(user, notes = "")
656 651 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
657 652 end
658 653
659 654 # Returns the current journal or nil if it's not initialized
660 655 def current_journal
661 656 @current_journal
662 657 end
663 658
664 659 # Returns the names of attributes that are journalized when updating the issue
665 660 def journalized_attribute_names
666 661 Issue.column_names - %w(id root_id lft rgt lock_version created_on updated_on closed_on)
667 662 end
668 663
669 664 # Returns the id of the last journal or nil
670 665 def last_journal_id
671 666 if new_record?
672 667 nil
673 668 else
674 669 journals.maximum(:id)
675 670 end
676 671 end
677 672
678 673 # Returns a scope for journals that have an id greater than journal_id
679 674 def journals_after(journal_id)
680 675 scope = journals.reorder("#{Journal.table_name}.id ASC")
681 676 if journal_id.present?
682 677 scope = scope.where("#{Journal.table_name}.id > ?", journal_id.to_i)
683 678 end
684 679 scope
685 680 end
686 681
687 682 # Returns the initial status of the issue
688 683 # Returns nil for a new issue
689 684 def status_was
690 685 if status_id_changed?
691 686 if status_id_was.to_i > 0
692 687 @status_was ||= IssueStatus.find_by_id(status_id_was)
693 688 end
694 689 else
695 690 @status_was ||= status
696 691 end
697 692 end
698 693
699 694 # Return true if the issue is closed, otherwise false
700 695 def closed?
701 696 status.present? && status.is_closed?
702 697 end
703 698
704 699 # Returns true if the issue was closed when loaded
705 700 def was_closed?
706 701 status_was.present? && status_was.is_closed?
707 702 end
708 703
709 704 # Return true if the issue is being reopened
710 705 def reopening?
711 706 if new_record?
712 707 false
713 708 else
714 709 status_id_changed? && !closed? && was_closed?
715 710 end
716 711 end
717 712 alias :reopened? :reopening?
718 713
719 714 # Return true if the issue is being closed
720 715 def closing?
721 716 if new_record?
722 717 closed?
723 718 else
724 719 status_id_changed? && closed? && !was_closed?
725 720 end
726 721 end
727 722
728 723 # Returns true if the issue is overdue
729 724 def overdue?
730 725 due_date.present? && (due_date < Date.today) && !closed?
731 726 end
732 727
733 728 # Is the amount of work done less than it should for the due date
734 729 def behind_schedule?
735 730 return false if start_date.nil? || due_date.nil?
736 731 done_date = start_date + ((due_date - start_date + 1) * done_ratio / 100).floor
737 732 return done_date <= Date.today
738 733 end
739 734
740 735 # Does this issue have children?
741 736 def children?
742 737 !leaf?
743 738 end
744 739
745 740 # Users the issue can be assigned to
746 741 def assignable_users
747 742 users = project.assignable_users.to_a
748 743 users << author if author
749 744 users << assigned_to if assigned_to
750 745 users.uniq.sort
751 746 end
752 747
753 748 # Versions that the issue can be assigned to
754 749 def assignable_versions
755 750 return @assignable_versions if @assignable_versions
756 751
757 752 versions = project.shared_versions.open.to_a
758 753 if fixed_version
759 754 if fixed_version_id_changed?
760 755 # nothing to do
761 756 elsif project_id_changed?
762 757 if project.shared_versions.include?(fixed_version)
763 758 versions << fixed_version
764 759 end
765 760 else
766 761 versions << fixed_version
767 762 end
768 763 end
769 764 @assignable_versions = versions.uniq.sort
770 765 end
771 766
772 767 # Returns true if this issue is blocked by another issue that is still open
773 768 def blocked?
774 769 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
775 770 end
776 771
777 772 # Returns the default status of the issue based on its tracker
778 773 # Returns nil if tracker is nil
779 774 def default_status
780 775 tracker.try(:default_status)
781 776 end
782 777
783 778 # Returns an array of statuses that user is able to apply
784 779 def new_statuses_allowed_to(user=User.current, include_default=false)
785 780 if new_record? && @copied_from
786 781 [default_status, @copied_from.status].compact.uniq.sort
787 782 else
788 783 initial_status = nil
789 784 if new_record?
790 785 initial_status = default_status
791 786 elsif tracker_id_changed?
792 787 if Tracker.where(:id => tracker_id_was, :default_status_id => status_id_was).any?
793 788 initial_status = default_status
794 789 elsif tracker.issue_status_ids.include?(status_id_was)
795 790 initial_status = IssueStatus.find_by_id(status_id_was)
796 791 else
797 792 initial_status = default_status
798 793 end
799 794 else
800 795 initial_status = status_was
801 796 end
802 797
803 798 initial_assigned_to_id = assigned_to_id_changed? ? assigned_to_id_was : assigned_to_id
804 799 assignee_transitions_allowed = initial_assigned_to_id.present? &&
805 800 (user.id == initial_assigned_to_id || user.group_ids.include?(initial_assigned_to_id))
806 801
807 802 statuses = []
808 803 if initial_status
809 804 statuses += initial_status.find_new_statuses_allowed_to(
810 805 user.admin ? Role.all.to_a : user.roles_for_project(project),
811 806 tracker,
812 807 author == user,
813 808 assignee_transitions_allowed
814 809 )
815 810 end
816 811 statuses << initial_status unless statuses.empty?
817 812 statuses << default_status if include_default
818 813 statuses = statuses.compact.uniq.sort
819 814 if blocked?
820 815 statuses.reject!(&:is_closed?)
821 816 end
822 817 statuses
823 818 end
824 819 end
825 820
826 821 # Returns the previous assignee if changed
827 822 def assigned_to_was
828 823 # assigned_to_id_was is reset before after_save callbacks
829 824 user_id = @previous_assigned_to_id || assigned_to_id_was
830 825 if user_id && user_id != assigned_to_id
831 826 @assigned_to_was ||= User.find_by_id(user_id)
832 827 end
833 828 end
834 829
835 830 # Returns the users that should be notified
836 831 def notified_users
837 832 notified = []
838 833 # Author and assignee are always notified unless they have been
839 834 # locked or don't want to be notified
840 835 notified << author if author
841 836 if assigned_to
842 837 notified += (assigned_to.is_a?(Group) ? assigned_to.users : [assigned_to])
843 838 end
844 839 if assigned_to_was
845 840 notified += (assigned_to_was.is_a?(Group) ? assigned_to_was.users : [assigned_to_was])
846 841 end
847 842 notified = notified.select {|u| u.active? && u.notify_about?(self)}
848 843
849 844 notified += project.notified_users
850 845 notified.uniq!
851 846 # Remove users that can not view the issue
852 847 notified.reject! {|user| !visible?(user)}
853 848 notified
854 849 end
855 850
856 851 # Returns the email addresses that should be notified
857 852 def recipients
858 853 notified_users.collect(&:mail)
859 854 end
860 855
861 856 def each_notification(users, &block)
862 857 if users.any?
863 858 if custom_field_values.detect {|value| !value.custom_field.visible?}
864 859 users_by_custom_field_visibility = users.group_by do |user|
865 860 visible_custom_field_values(user).map(&:custom_field_id).sort
866 861 end
867 862 users_by_custom_field_visibility.values.each do |users|
868 863 yield(users)
869 864 end
870 865 else
871 866 yield(users)
872 867 end
873 868 end
874 869 end
875 870
876 871 # Returns the number of hours spent on this issue
877 872 def spent_hours
878 873 @spent_hours ||= time_entries.sum(:hours) || 0
879 874 end
880 875
881 876 # Returns the total number of hours spent on this issue and its descendants
882 877 #
883 878 # Example:
884 879 # spent_hours => 0.0
885 880 # spent_hours => 50.2
886 881 def total_spent_hours
887 882 @total_spent_hours ||=
888 883 self_and_descendants.
889 884 joins("LEFT JOIN #{TimeEntry.table_name} ON #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id").
890 885 sum("#{TimeEntry.table_name}.hours").to_f || 0.0
891 886 end
892 887
893 888 def relations
894 889 @relations ||= IssueRelation::Relations.new(self, (relations_from + relations_to).sort)
895 890 end
896 891
897 892 # Preloads relations for a collection of issues
898 893 def self.load_relations(issues)
899 894 if issues.any?
900 895 relations = IssueRelation.where("issue_from_id IN (:ids) OR issue_to_id IN (:ids)", :ids => issues.map(&:id)).all
901 896 issues.each do |issue|
902 897 issue.instance_variable_set "@relations", relations.select {|r| r.issue_from_id == issue.id || r.issue_to_id == issue.id}
903 898 end
904 899 end
905 900 end
906 901
907 902 # Preloads visible spent time for a collection of issues
908 903 def self.load_visible_spent_hours(issues, user=User.current)
909 904 if issues.any?
910 905 hours_by_issue_id = TimeEntry.visible(user).group(:issue_id).sum(:hours)
911 906 issues.each do |issue|
912 907 issue.instance_variable_set "@spent_hours", (hours_by_issue_id[issue.id] || 0)
913 908 end
914 909 end
915 910 end
916 911
917 912 # Preloads visible relations for a collection of issues
918 913 def self.load_visible_relations(issues, user=User.current)
919 914 if issues.any?
920 915 issue_ids = issues.map(&:id)
921 916 # Relations with issue_from in given issues and visible issue_to
922 917 relations_from = IssueRelation.joins(:issue_to => :project).
923 918 where(visible_condition(user)).where(:issue_from_id => issue_ids).to_a
924 919 # Relations with issue_to in given issues and visible issue_from
925 920 relations_to = IssueRelation.joins(:issue_from => :project).
926 921 where(visible_condition(user)).
927 922 where(:issue_to_id => issue_ids).to_a
928 923 issues.each do |issue|
929 924 relations =
930 925 relations_from.select {|relation| relation.issue_from_id == issue.id} +
931 926 relations_to.select {|relation| relation.issue_to_id == issue.id}
932 927
933 928 issue.instance_variable_set "@relations", IssueRelation::Relations.new(issue, relations.sort)
934 929 end
935 930 end
936 931 end
937 932
938 933 # Finds an issue relation given its id.
939 934 def find_relation(relation_id)
940 935 IssueRelation.where("issue_to_id = ? OR issue_from_id = ?", id, id).find(relation_id)
941 936 end
942 937
943 938 # Returns all the other issues that depend on the issue
944 939 # The algorithm is a modified breadth first search (bfs)
945 940 def all_dependent_issues(except=[])
946 941 # The found dependencies
947 942 dependencies = []
948 943
949 944 # The visited flag for every node (issue) used by the breadth first search
950 945 eNOT_DISCOVERED = 0 # The issue is "new" to the algorithm, it has not seen it before.
951 946
952 947 ePROCESS_ALL = 1 # The issue is added to the queue. Process both children and relations of
953 948 # the issue when it is processed.
954 949
955 950 ePROCESS_RELATIONS_ONLY = 2 # The issue was added to the queue and will be output as dependent issue,
956 951 # but its children will not be added to the queue when it is processed.
957 952
958 953 eRELATIONS_PROCESSED = 3 # The related issues, the parent issue and the issue itself have been added to
959 954 # the queue, but its children have not been added.
960 955
961 956 ePROCESS_CHILDREN_ONLY = 4 # The relations and the parent of the issue have been added to the queue, but
962 957 # the children still need to be processed.
963 958
964 959 eALL_PROCESSED = 5 # The issue and all its children, its parent and its related issues have been
965 960 # added as dependent issues. It needs no further processing.
966 961
967 962 issue_status = Hash.new(eNOT_DISCOVERED)
968 963
969 964 # The queue
970 965 queue = []
971 966
972 967 # Initialize the bfs, add start node (self) to the queue
973 968 queue << self
974 969 issue_status[self] = ePROCESS_ALL
975 970
976 971 while (!queue.empty?) do
977 972 current_issue = queue.shift
978 973 current_issue_status = issue_status[current_issue]
979 974 dependencies << current_issue
980 975
981 976 # Add parent to queue, if not already in it.
982 977 parent = current_issue.parent
983 978 parent_status = issue_status[parent]
984 979
985 980 if parent && (parent_status == eNOT_DISCOVERED) && !except.include?(parent)
986 981 queue << parent
987 982 issue_status[parent] = ePROCESS_RELATIONS_ONLY
988 983 end
989 984
990 985 # Add children to queue, but only if they are not already in it and
991 986 # the children of the current node need to be processed.
992 987 if (current_issue_status == ePROCESS_CHILDREN_ONLY || current_issue_status == ePROCESS_ALL)
993 988 current_issue.children.each do |child|
994 989 next if except.include?(child)
995 990
996 991 if (issue_status[child] == eNOT_DISCOVERED)
997 992 queue << child
998 993 issue_status[child] = ePROCESS_ALL
999 994 elsif (issue_status[child] == eRELATIONS_PROCESSED)
1000 995 queue << child
1001 996 issue_status[child] = ePROCESS_CHILDREN_ONLY
1002 997 elsif (issue_status[child] == ePROCESS_RELATIONS_ONLY)
1003 998 queue << child
1004 999 issue_status[child] = ePROCESS_ALL
1005 1000 end
1006 1001 end
1007 1002 end
1008 1003
1009 1004 # Add related issues to the queue, if they are not already in it.
1010 1005 current_issue.relations_from.map(&:issue_to).each do |related_issue|
1011 1006 next if except.include?(related_issue)
1012 1007
1013 1008 if (issue_status[related_issue] == eNOT_DISCOVERED)
1014 1009 queue << related_issue
1015 1010 issue_status[related_issue] = ePROCESS_ALL
1016 1011 elsif (issue_status[related_issue] == eRELATIONS_PROCESSED)
1017 1012 queue << related_issue
1018 1013 issue_status[related_issue] = ePROCESS_CHILDREN_ONLY
1019 1014 elsif (issue_status[related_issue] == ePROCESS_RELATIONS_ONLY)
1020 1015 queue << related_issue
1021 1016 issue_status[related_issue] = ePROCESS_ALL
1022 1017 end
1023 1018 end
1024 1019
1025 1020 # Set new status for current issue
1026 1021 if (current_issue_status == ePROCESS_ALL) || (current_issue_status == ePROCESS_CHILDREN_ONLY)
1027 1022 issue_status[current_issue] = eALL_PROCESSED
1028 1023 elsif (current_issue_status == ePROCESS_RELATIONS_ONLY)
1029 1024 issue_status[current_issue] = eRELATIONS_PROCESSED
1030 1025 end
1031 1026 end # while
1032 1027
1033 1028 # Remove the issues from the "except" parameter from the result array
1034 1029 dependencies -= except
1035 1030 dependencies.delete(self)
1036 1031
1037 1032 dependencies
1038 1033 end
1039 1034
1040 1035 # Returns an array of issues that duplicate this one
1041 1036 def duplicates
1042 1037 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
1043 1038 end
1044 1039
1045 1040 # Returns the due date or the target due date if any
1046 1041 # Used on gantt chart
1047 1042 def due_before
1048 1043 due_date || (fixed_version ? fixed_version.effective_date : nil)
1049 1044 end
1050 1045
1051 1046 # Returns the time scheduled for this issue.
1052 1047 #
1053 1048 # Example:
1054 1049 # Start Date: 2/26/09, End Date: 3/04/09
1055 1050 # duration => 6
1056 1051 def duration
1057 1052 (start_date && due_date) ? due_date - start_date : 0
1058 1053 end
1059 1054
1060 1055 # Returns the duration in working days
1061 1056 def working_duration
1062 1057 (start_date && due_date) ? working_days(start_date, due_date) : 0
1063 1058 end
1064 1059
1065 1060 def soonest_start(reload=false)
1066 1061 @soonest_start = nil if reload
1067 1062 @soonest_start ||= (
1068 1063 relations_to(reload).collect{|relation| relation.successor_soonest_start} +
1069 1064 [(@parent_issue || parent).try(:soonest_start)]
1070 1065 ).compact.max
1071 1066 end
1072 1067
1073 1068 # Sets start_date on the given date or the next working day
1074 1069 # and changes due_date to keep the same working duration.
1075 1070 def reschedule_on(date)
1076 1071 wd = working_duration
1077 1072 date = next_working_date(date)
1078 1073 self.start_date = date
1079 1074 self.due_date = add_working_days(date, wd)
1080 1075 end
1081 1076
1082 1077 # Reschedules the issue on the given date or the next working day and saves the record.
1083 1078 # If the issue is a parent task, this is done by rescheduling its subtasks.
1084 1079 def reschedule_on!(date)
1085 1080 return if date.nil?
1086 1081 if leaf?
1087 1082 if start_date.nil? || start_date != date
1088 1083 if start_date && start_date > date
1089 1084 # Issue can not be moved earlier than its soonest start date
1090 1085 date = [soonest_start(true), date].compact.max
1091 1086 end
1092 1087 reschedule_on(date)
1093 1088 begin
1094 1089 save
1095 1090 rescue ActiveRecord::StaleObjectError
1096 1091 reload
1097 1092 reschedule_on(date)
1098 1093 save
1099 1094 end
1100 1095 end
1101 1096 else
1102 1097 leaves.each do |leaf|
1103 1098 if leaf.start_date
1104 1099 # Only move subtask if it starts at the same date as the parent
1105 1100 # or if it starts before the given date
1106 1101 if start_date == leaf.start_date || date > leaf.start_date
1107 1102 leaf.reschedule_on!(date)
1108 1103 end
1109 1104 else
1110 1105 leaf.reschedule_on!(date)
1111 1106 end
1112 1107 end
1113 1108 end
1114 1109 end
1115 1110
1116 1111 def <=>(issue)
1117 1112 if issue.nil?
1118 1113 -1
1119 1114 elsif root_id != issue.root_id
1120 1115 (root_id || 0) <=> (issue.root_id || 0)
1121 1116 else
1122 1117 (lft || 0) <=> (issue.lft || 0)
1123 1118 end
1124 1119 end
1125 1120
1126 1121 def to_s
1127 1122 "#{tracker} ##{id}: #{subject}"
1128 1123 end
1129 1124
1130 1125 # Returns a string of css classes that apply to the issue
1131 1126 def css_classes(user=User.current)
1132 1127 s = "issue tracker-#{tracker_id} status-#{status_id} #{priority.try(:css_classes)}"
1133 1128 s << ' closed' if closed?
1134 1129 s << ' overdue' if overdue?
1135 1130 s << ' child' if child?
1136 1131 s << ' parent' unless leaf?
1137 1132 s << ' private' if is_private?
1138 1133 if user.logged?
1139 1134 s << ' created-by-me' if author_id == user.id
1140 1135 s << ' assigned-to-me' if assigned_to_id == user.id
1141 1136 s << ' assigned-to-my-group' if user.groups.any? {|g| g.id == assigned_to_id}
1142 1137 end
1143 1138 s
1144 1139 end
1145 1140
1146 1141 # Unassigns issues from +version+ if it's no longer shared with issue's project
1147 1142 def self.update_versions_from_sharing_change(version)
1148 1143 # Update issues assigned to the version
1149 1144 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
1150 1145 end
1151 1146
1152 1147 # Unassigns issues from versions that are no longer shared
1153 1148 # after +project+ was moved
1154 1149 def self.update_versions_from_hierarchy_change(project)
1155 1150 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
1156 1151 # Update issues of the moved projects and issues assigned to a version of a moved project
1157 1152 Issue.update_versions(
1158 1153 ["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)",
1159 1154 moved_project_ids, moved_project_ids]
1160 1155 )
1161 1156 end
1162 1157
1163 1158 def parent_issue_id=(arg)
1164 1159 s = arg.to_s.strip.presence
1165 1160 if s && (m = s.match(%r{\A#?(\d+)\z})) && (@parent_issue = Issue.find_by_id(m[1]))
1166 1161 @invalid_parent_issue_id = nil
1167 1162 elsif s.blank?
1168 1163 @parent_issue = nil
1169 1164 @invalid_parent_issue_id = nil
1170 1165 else
1171 1166 @parent_issue = nil
1172 1167 @invalid_parent_issue_id = arg
1173 1168 end
1174 1169 end
1175 1170
1176 1171 def parent_issue_id
1177 1172 if @invalid_parent_issue_id
1178 1173 @invalid_parent_issue_id
1179 1174 elsif instance_variable_defined? :@parent_issue
1180 1175 @parent_issue.nil? ? nil : @parent_issue.id
1181 1176 else
1182 1177 parent_id
1183 1178 end
1184 1179 end
1185 1180
1186 1181 def set_parent_id
1187 1182 self.parent_id = parent_issue_id
1188 1183 end
1189 1184
1190 1185 # Returns true if issue's project is a valid
1191 1186 # parent issue project
1192 1187 def valid_parent_project?(issue=parent)
1193 1188 return true if issue.nil? || issue.project_id == project_id
1194 1189
1195 1190 case Setting.cross_project_subtasks
1196 1191 when 'system'
1197 1192 true
1198 1193 when 'tree'
1199 1194 issue.project.root == project.root
1200 1195 when 'hierarchy'
1201 1196 issue.project.is_or_is_ancestor_of?(project) || issue.project.is_descendant_of?(project)
1202 1197 when 'descendants'
1203 1198 issue.project.is_or_is_ancestor_of?(project)
1204 1199 else
1205 1200 false
1206 1201 end
1207 1202 end
1208 1203
1209 1204 # Returns an issue scope based on project and scope
1210 1205 def self.cross_project_scope(project, scope=nil)
1211 1206 if project.nil?
1212 1207 return Issue
1213 1208 end
1214 1209 case scope
1215 1210 when 'all', 'system'
1216 1211 Issue
1217 1212 when 'tree'
1218 1213 Issue.joins(:project).where("(#{Project.table_name}.lft >= :lft AND #{Project.table_name}.rgt <= :rgt)",
1219 1214 :lft => project.root.lft, :rgt => project.root.rgt)
1220 1215 when 'hierarchy'
1221 1216 Issue.joins(:project).where("(#{Project.table_name}.lft >= :lft AND #{Project.table_name}.rgt <= :rgt) OR (#{Project.table_name}.lft < :lft AND #{Project.table_name}.rgt > :rgt)",
1222 1217 :lft => project.lft, :rgt => project.rgt)
1223 1218 when 'descendants'
1224 1219 Issue.joins(:project).where("(#{Project.table_name}.lft >= :lft AND #{Project.table_name}.rgt <= :rgt)",
1225 1220 :lft => project.lft, :rgt => project.rgt)
1226 1221 else
1227 1222 Issue.where(:project_id => project.id)
1228 1223 end
1229 1224 end
1230 1225
1231 1226 def self.by_tracker(project)
1232 1227 count_and_group_by(:project => project, :association => :tracker)
1233 1228 end
1234 1229
1235 1230 def self.by_version(project)
1236 1231 count_and_group_by(:project => project, :association => :fixed_version)
1237 1232 end
1238 1233
1239 1234 def self.by_priority(project)
1240 1235 count_and_group_by(:project => project, :association => :priority)
1241 1236 end
1242 1237
1243 1238 def self.by_category(project)
1244 1239 count_and_group_by(:project => project, :association => :category)
1245 1240 end
1246 1241
1247 1242 def self.by_assigned_to(project)
1248 1243 count_and_group_by(:project => project, :association => :assigned_to)
1249 1244 end
1250 1245
1251 1246 def self.by_author(project)
1252 1247 count_and_group_by(:project => project, :association => :author)
1253 1248 end
1254 1249
1255 1250 def self.by_subproject(project)
1256 1251 r = count_and_group_by(:project => project, :with_subprojects => true, :association => :project)
1257 1252 r.reject {|r| r["project_id"] == project.id.to_s}
1258 1253 end
1259 1254
1260 1255 # Query generator for selecting groups of issue counts for a project
1261 1256 # based on specific criteria
1262 1257 #
1263 1258 # Options
1264 1259 # * project - Project to search in.
1265 1260 # * with_subprojects - Includes subprojects issues if set to true.
1266 1261 # * association - Symbol. Association for grouping.
1267 1262 def self.count_and_group_by(options)
1268 1263 assoc = reflect_on_association(options[:association])
1269 1264 select_field = assoc.foreign_key
1270 1265
1271 1266 Issue.
1272 1267 visible(User.current, :project => options[:project], :with_subprojects => options[:with_subprojects]).
1273 1268 joins(:status, assoc.name).
1274 1269 group(:status_id, :is_closed, select_field).
1275 1270 count.
1276 1271 map do |columns, total|
1277 1272 status_id, is_closed, field_value = columns
1278 1273 is_closed = ['t', 'true', '1'].include?(is_closed.to_s)
1279 1274 {
1280 1275 "status_id" => status_id.to_s,
1281 1276 "closed" => is_closed,
1282 1277 select_field => field_value.to_s,
1283 1278 "total" => total.to_s
1284 1279 }
1285 1280 end
1286 1281 end
1287 1282
1288 1283 # Returns a scope of projects that user can assign the issue to
1289 1284 def allowed_target_projects(user=User.current)
1290 1285 if new_record?
1291 1286 Project.where(Project.allowed_to_condition(user, :add_issues))
1292 1287 else
1293 1288 self.class.allowed_target_projects_on_move(user)
1294 1289 end
1295 1290 end
1296 1291
1297 1292 # Returns a scope of projects that user can move issues to
1298 1293 def self.allowed_target_projects_on_move(user=User.current)
1299 1294 Project.where(Project.allowed_to_condition(user, :move_issues))
1300 1295 end
1301 1296
1302 1297 private
1303 1298
1304 1299 def after_project_change
1305 1300 # Update project_id on related time entries
1306 1301 TimeEntry.where({:issue_id => id}).update_all(["project_id = ?", project_id])
1307 1302
1308 1303 # Delete issue relations
1309 1304 unless Setting.cross_project_issue_relations?
1310 1305 relations_from.clear
1311 1306 relations_to.clear
1312 1307 end
1313 1308
1314 1309 # Move subtasks that were in the same project
1315 1310 children.each do |child|
1316 1311 next unless child.project_id == project_id_was
1317 1312 # Change project and keep project
1318 1313 child.send :project=, project, true
1319 1314 unless child.save
1320 1315 raise ActiveRecord::Rollback
1321 1316 end
1322 1317 end
1323 1318 end
1324 1319
1325 1320 # Callback for after the creation of an issue by copy
1326 1321 # * adds a "copied to" relation with the copied issue
1327 1322 # * copies subtasks from the copied issue
1328 1323 def after_create_from_copy
1329 1324 return unless copy? && !@after_create_from_copy_handled
1330 1325
1331 1326 if (@copied_from.project_id == project_id || Setting.cross_project_issue_relations?) && @copy_options[:link] != false
1332 1327 if @current_journal
1333 1328 @copied_from.init_journal(@current_journal.user)
1334 1329 end
1335 1330 relation = IssueRelation.new(:issue_from => @copied_from, :issue_to => self, :relation_type => IssueRelation::TYPE_COPIED_TO)
1336 1331 unless relation.save
1337 1332 logger.error "Could not create relation while copying ##{@copied_from.id} to ##{id} due to validation errors: #{relation.errors.full_messages.join(', ')}" if logger
1338 1333 end
1339 1334 end
1340 1335
1341 1336 unless @copied_from.leaf? || @copy_options[:subtasks] == false
1342 1337 copy_options = (@copy_options || {}).merge(:subtasks => false)
1343 1338 copied_issue_ids = {@copied_from.id => self.id}
1344 1339 @copied_from.reload.descendants.reorder("#{Issue.table_name}.lft").each do |child|
1345 1340 # Do not copy self when copying an issue as a descendant of the copied issue
1346 1341 next if child == self
1347 1342 # Do not copy subtasks of issues that were not copied
1348 1343 next unless copied_issue_ids[child.parent_id]
1349 1344 # Do not copy subtasks that are not visible to avoid potential disclosure of private data
1350 1345 unless child.visible?
1351 1346 logger.error "Subtask ##{child.id} was not copied during ##{@copied_from.id} copy because it is not visible to the current user" if logger
1352 1347 next
1353 1348 end
1354 1349 copy = Issue.new.copy_from(child, copy_options)
1355 1350 if @current_journal
1356 1351 copy.init_journal(@current_journal.user)
1357 1352 end
1358 1353 copy.author = author
1359 1354 copy.project = project
1360 1355 copy.parent_issue_id = copied_issue_ids[child.parent_id]
1361 1356 unless copy.save
1362 1357 logger.error "Could not copy subtask ##{child.id} while copying ##{@copied_from.id} to ##{id} due to validation errors: #{copy.errors.full_messages.join(', ')}" if logger
1363 1358 next
1364 1359 end
1365 1360 copied_issue_ids[child.id] = copy.id
1366 1361 end
1367 1362 end
1368 1363 @after_create_from_copy_handled = true
1369 1364 end
1370 1365
1371 1366 def update_nested_set_attributes
1372 1367 if parent_id_changed?
1373 1368 update_nested_set_attributes_on_parent_change
1374 1369 end
1375 1370 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
1376 1371 end
1377 1372
1378 1373 # Updates the nested set for when an existing issue is moved
1379 1374 def update_nested_set_attributes_on_parent_change
1380 1375 former_parent_id = parent_id_was
1381 1376 # delete invalid relations of all descendants
1382 1377 self_and_descendants.each do |issue|
1383 1378 issue.relations.each do |relation|
1384 1379 relation.destroy unless relation.valid?
1385 1380 end
1386 1381 end
1387 1382 # update former parent
1388 1383 recalculate_attributes_for(former_parent_id) if former_parent_id
1389 1384 end
1390 1385
1391 1386 def update_parent_attributes
1392 1387 if parent_id
1393 1388 recalculate_attributes_for(parent_id)
1394 1389 association(:parent).reset
1395 1390 end
1396 1391 end
1397 1392
1398 1393 def recalculate_attributes_for(issue_id)
1399 1394 if issue_id && p = Issue.find_by_id(issue_id)
1400 1395 # priority = highest priority of children
1401 1396 if priority_position = p.children.joins(:priority).maximum("#{IssuePriority.table_name}.position")
1402 1397 p.priority = IssuePriority.find_by_position(priority_position)
1403 1398 end
1404 1399
1405 1400 # start/due dates = lowest/highest dates of children
1406 1401 p.start_date = p.children.minimum(:start_date)
1407 1402 p.due_date = p.children.maximum(:due_date)
1408 1403 if p.start_date && p.due_date && p.due_date < p.start_date
1409 1404 p.start_date, p.due_date = p.due_date, p.start_date
1410 1405 end
1411 1406
1412 1407 # done ratio = weighted average ratio of leaves
1413 1408 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
1414 1409 leaves_count = p.leaves.count
1415 1410 if leaves_count > 0
1416 1411 average = p.leaves.where("estimated_hours > 0").average(:estimated_hours).to_f
1417 1412 if average == 0
1418 1413 average = 1
1419 1414 end
1420 1415 done = p.leaves.joins(:status).
1421 1416 sum("COALESCE(CASE WHEN estimated_hours > 0 THEN estimated_hours ELSE NULL END, #{average}) " +
1422 1417 "* (CASE WHEN is_closed = #{self.class.connection.quoted_true} THEN 100 ELSE COALESCE(done_ratio, 0) END)").to_f
1423 1418 progress = done / (average * leaves_count)
1424 1419 p.done_ratio = progress.round
1425 1420 end
1426 1421 end
1427 1422
1428 1423 # estimate = sum of leaves estimates
1429 1424 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
1430 1425 p.estimated_hours = nil if p.estimated_hours == 0.0
1431 1426
1432 1427 # ancestors will be recursively updated
1433 1428 p.save(:validate => false)
1434 1429 end
1435 1430 end
1436 1431
1437 1432 # Update issues so their versions are not pointing to a
1438 1433 # fixed_version that is not shared with the issue's project
1439 1434 def self.update_versions(conditions=nil)
1440 1435 # Only need to update issues with a fixed_version from
1441 1436 # a different project and that is not systemwide shared
1442 1437 Issue.joins(:project, :fixed_version).
1443 1438 where("#{Issue.table_name}.fixed_version_id IS NOT NULL" +
1444 1439 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
1445 1440 " AND #{Version.table_name}.sharing <> 'system'").
1446 1441 where(conditions).each do |issue|
1447 1442 next if issue.project.nil? || issue.fixed_version.nil?
1448 1443 unless issue.project.shared_versions.include?(issue.fixed_version)
1449 1444 issue.init_journal(User.current)
1450 1445 issue.fixed_version = nil
1451 1446 issue.save
1452 1447 end
1453 1448 end
1454 1449 end
1455 1450
1456 1451 # Callback on file attachment
1457 1452 def attachment_added(attachment)
1458 1453 if current_journal && !attachment.new_record?
1459 1454 current_journal.journalize_attachment(attachment, :added)
1460 1455 end
1461 1456 end
1462 1457
1463 1458 # Callback on attachment deletion
1464 1459 def attachment_removed(attachment)
1465 1460 if current_journal && !attachment.new_record?
1466 1461 current_journal.journalize_attachment(attachment, :removed)
1467 1462 current_journal.save
1468 1463 end
1469 1464 end
1470 1465
1471 1466 # Called after a relation is added
1472 1467 def relation_added(relation)
1473 1468 if current_journal
1474 1469 current_journal.journalize_relation(relation, :added)
1475 1470 current_journal.save
1476 1471 end
1477 1472 end
1478 1473
1479 1474 # Called after a relation is removed
1480 1475 def relation_removed(relation)
1481 1476 if current_journal
1482 1477 current_journal.journalize_relation(relation, :removed)
1483 1478 current_journal.save
1484 1479 end
1485 1480 end
1486 1481
1487 1482 # Default assignment based on category
1488 1483 def default_assign
1489 1484 if assigned_to.nil? && category && category.assigned_to
1490 1485 self.assigned_to = category.assigned_to
1491 1486 end
1492 1487 end
1493 1488
1494 1489 # Updates start/due dates of following issues
1495 1490 def reschedule_following_issues
1496 1491 if start_date_changed? || due_date_changed?
1497 1492 relations_from.each do |relation|
1498 1493 relation.set_issue_to_dates
1499 1494 end
1500 1495 end
1501 1496 end
1502 1497
1503 1498 # Closes duplicates if the issue is being closed
1504 1499 def close_duplicates
1505 1500 if closing?
1506 1501 duplicates.each do |duplicate|
1507 1502 # Reload is needed in case the duplicate was updated by a previous duplicate
1508 1503 duplicate.reload
1509 1504 # Don't re-close it if it's already closed
1510 1505 next if duplicate.closed?
1511 1506 # Same user and notes
1512 1507 if @current_journal
1513 1508 duplicate.init_journal(@current_journal.user, @current_journal.notes)
1514 1509 end
1515 1510 duplicate.update_attribute :status, self.status
1516 1511 end
1517 1512 end
1518 1513 end
1519 1514
1520 1515 # Make sure updated_on is updated when adding a note and set updated_on now
1521 1516 # so we can set closed_on with the same value on closing
1522 1517 def force_updated_on_change
1523 1518 if @current_journal || changed?
1524 1519 self.updated_on = current_time_from_proper_timezone
1525 1520 if new_record?
1526 1521 self.created_on = updated_on
1527 1522 end
1528 1523 end
1529 1524 end
1530 1525
1531 1526 # Callback for setting closed_on when the issue is closed.
1532 1527 # The closed_on attribute stores the time of the last closing
1533 1528 # and is preserved when the issue is reopened.
1534 1529 def update_closed_on
1535 1530 if closing?
1536 1531 self.closed_on = updated_on
1537 1532 end
1538 1533 end
1539 1534
1540 1535 # Saves the changes in a Journal
1541 1536 # Called after_save
1542 1537 def create_journal
1543 1538 if current_journal
1544 1539 current_journal.save
1545 1540 end
1546 1541 end
1547 1542
1548 1543 def send_notification
1549 1544 if Setting.notified_events.include?('issue_added')
1550 1545 Mailer.deliver_issue_add(self)
1551 1546 end
1552 1547 end
1553 1548
1554 1549 # Stores the previous assignee so we can still have access
1555 1550 # to it during after_save callbacks (assigned_to_id_was is reset)
1556 1551 def set_assigned_to_was
1557 1552 @previous_assigned_to_id = assigned_to_id_was
1558 1553 end
1559 1554
1560 1555 # Clears the previous assignee at the end of after_save callbacks
1561 1556 def clear_assigned_to_was
1562 1557 @assigned_to_was = nil
1563 1558 @previous_assigned_to_id = nil
1564 1559 end
1565 1560 end
@@ -1,167 +1,206
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2014 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 module Redmine
19 19 module Acts
20 20 module Searchable
21 21 def self.included(base)
22 22 base.extend ClassMethods
23 23 end
24 24
25 25 module ClassMethods
26 26 # Adds the search methods to the class.
27 27 #
28 28 # Options:
29 29 # * :columns - a column or an array of columns to search
30 30 # * :project_key - project foreign key (default to project_id)
31 31 # * :date_column - name of the datetime column used to sort results (default to :created_on)
32 32 # * :permission - permission required to search the model
33 33 # * :scope - scope used to search results
34 34 # * :preload - associations to preload when loading results for display
35 35 def acts_as_searchable(options = {})
36 36 return if self.included_modules.include?(Redmine::Acts::Searchable::InstanceMethods)
37 37 options.assert_valid_keys(:columns, :project_key, :date_column, :permission, :scope, :preload)
38 38
39 39 cattr_accessor :searchable_options
40 40 self.searchable_options = options
41 41
42 42 if searchable_options[:columns].nil?
43 43 raise 'No searchable column defined.'
44 44 elsif !searchable_options[:columns].is_a?(Array)
45 45 searchable_options[:columns] = [] << searchable_options[:columns]
46 46 end
47 47
48 48 searchable_options[:project_key] ||= "#{table_name}.project_id"
49 49 searchable_options[:date_column] ||= :created_on
50 50
51 # Should we search custom fields on this model ?
52 searchable_options[:search_custom_fields] = !reflect_on_association(:custom_values).nil?
51 # Should we search additional associations on this model ?
52 searchable_options[:search_custom_fields] = reflect_on_association(:custom_values).present?
53 searchable_options[:search_journals] = reflect_on_association(:journals).present?
53 54
54 55 send :include, Redmine::Acts::Searchable::InstanceMethods
55 56 end
56 57 end
57 58
58 59 module InstanceMethods
59 60 def self.included(base)
60 61 base.extend ClassMethods
61 62 end
62 63
63 64 module ClassMethods
64 65 # Searches the model for the given tokens and user visibility.
65 66 # The projects argument can be either nil (will search all projects), a project or an array of projects.
66 67 # Returns an array that contains the rank and id of all results.
67 68 # In current implementation, the rank is the record timestamp converted as an integer.
68 69 #
69 70 # Valid options:
70 71 # * :titles_only - searches tokens in the first searchable column only
71 72 # * :all_words - searches results that match all token
72 73 # * :limit - maximum number of results to return
73 74 #
74 75 # Example:
75 76 # Issue.search_result_ranks_and_ids("foo")
76 77 # # => [[1419595329, 69], [1419595622, 123]]
77 78 def search_result_ranks_and_ids(tokens, user=User.current, projects=nil, options={})
78 if projects.is_a?(Array) && projects.empty?
79 # no results
80 return []
81 end
82
83 79 tokens = [] << tokens unless tokens.is_a?(Array)
84 80 projects = [] << projects if projects.is_a?(Project)
85 81
86 82 columns = searchable_options[:columns]
87 83 columns = columns[0..0] if options[:titles_only]
88 84
89 85 token_clauses = columns.collect {|column| "(#{search_token_match_statement(column)})"}
86 sql = (['(' + token_clauses.join(' OR ') + ')'] * tokens.size).join(options[:all_words] ? ' AND ' : ' OR ')
87 tokens_conditions = [sql, * (tokens.collect {|w| "%#{w}%"} * token_clauses.size).sort]
88
89 r = fetch_ranks_and_ids(search_scope(user, projects).where(tokens_conditions), options[:limit])
90 sort_and_limit_results = false
90 91
91 92 if !options[:titles_only] && searchable_options[:search_custom_fields]
92 searchable_custom_fields = CustomField.where(:type => "#{self.name}CustomField", :searchable => true)
93 fields_by_visibility = searchable_custom_fields.group_by {|field|
94 field.visibility_by_project_condition(searchable_options[:project_key], user, "cfs.custom_field_id")
95 }
96 # only 1 subquery for all custom fields with the same visibility statement
97 fields_by_visibility.each do |visibility, fields|
98 ids = fields.map(&:id).join(',')
99 sql = "#{table_name}.id IN (SELECT cfs.customized_id FROM #{CustomValue.table_name} cfs" +
100 " WHERE cfs.customized_type='#{self.name}' AND cfs.customized_id=#{table_name}.id" +
101 " AND cfs.custom_field_id IN (#{ids})" +
102 " AND #{search_token_match_statement('cfs.value')}" +
103 " AND #{visibility})"
104 token_clauses << sql
93 searchable_custom_fields = CustomField.where(:type => "#{self.name}CustomField", :searchable => true).to_a
94
95 if searchable_custom_fields.any?
96 fields_by_visibility = searchable_custom_fields.group_by {|field|
97 field.visibility_by_project_condition(searchable_options[:project_key], user, "#{CustomValue.table_name}.custom_field_id")
98 }
99 clauses = []
100 fields_by_visibility.each do |visibility, fields|
101 clauses << "(#{CustomValue.table_name}.custom_field_id IN (#{fields.map(&:id).join(',')}) AND (#{visibility}))"
102 end
103 visibility = clauses.join(' OR ')
104
105 sql = ([search_token_match_statement("#{CustomValue.table_name}.value")] * tokens.size).join(options[:all_words] ? ' AND ' : ' OR ')
106 tokens_conditions = [sql, * tokens.collect {|w| "%#{w}%"}.sort]
107
108 r |= fetch_ranks_and_ids(
109 search_scope(user, projects).
110 joins(:custom_values).
111 where(visibility).
112 where(tokens_conditions),
113 options[:limit]
114 )
115
116 sort_and_limit_results = true
105 117 end
106 118 end
107 119
108 sql = (['(' + token_clauses.join(' OR ') + ')'] * tokens.size).join(options[:all_words] ? ' AND ' : ' OR ')
120 if !options[:titles_only] && searchable_options[:search_journals]
121 sql = ([search_token_match_statement("#{Journal.table_name}.notes")] * tokens.size).join(options[:all_words] ? ' AND ' : ' OR ')
122 tokens_conditions = [sql, * tokens.collect {|w| "%#{w}%"}.sort]
109 123
110 tokens_conditions = [sql, * (tokens.collect {|w| "%#{w}%"} * token_clauses.size).sort]
124 r |= fetch_ranks_and_ids(
125 search_scope(user, projects).
126 joins(:journals).
127 where("#{Journal.table_name}.private_notes = ? OR (#{Project.allowed_to_condition(user, :view_private_notes)})", false).
128 where(tokens_conditions),
129 options[:limit]
130 )
111 131
112 search_scope(user, projects).
113 reorder(searchable_options[:date_column] => :desc, :id => :desc).
114 where(tokens_conditions).
115 limit(options[:limit]).
116 uniq.
117 pluck(searchable_options[:date_column], :id).
118 # converts timestamps to integers for faster sort
119 map {|timestamp, id| [timestamp.to_i, id]}
132 sort_and_limit_results = true
133 end
134
135 if sort_and_limit_results
136 r = r.sort.reverse
137 if options[:limit] && r.size > options[:limit]
138 r = r[0, options[:limit]]
139 end
140 end
141
142 r
120 143 end
121 144
122 145 def search_token_match_statement(column, value='?')
123 146 case connection.adapter_name
124 147 when /postgresql/i
125 148 "#{column} ILIKE #{value}"
126 149 else
127 150 "#{column} LIKE #{value}"
128 151 end
129 152 end
130 153 private :search_token_match_statement
131 154
155 def fetch_ranks_and_ids(scope, limit)
156 scope.
157 reorder(searchable_options[:date_column] => :desc, :id => :desc).
158 limit(limit).
159 uniq.
160 pluck(searchable_options[:date_column], :id).
161 # converts timestamps to integers for faster sort
162 map {|timestamp, id| [timestamp.to_i, id]}
163 end
164 private :fetch_ranks_and_ids
165
132 166 # Returns the search scope for user and projects
133 167 def search_scope(user, projects)
168 if projects.is_a?(Array) && projects.empty?
169 # no results
170 return none
171 end
172
134 173 scope = (searchable_options[:scope] || self)
135 174 if scope.is_a? Proc
136 175 scope = scope.call
137 176 end
138 177
139 178 if respond_to?(:visible) && !searchable_options.has_key?(:permission)
140 179 scope = scope.visible(user)
141 180 else
142 181 permission = searchable_options[:permission] || :view_project
143 182 scope = scope.where(Project.allowed_to_condition(user, permission))
144 183 end
145 184
146 185 if projects
147 186 scope = scope.where("#{searchable_options[:project_key]} IN (?)", projects.map(&:id))
148 187 end
149 188 scope
150 189 end
151 190 private :search_scope
152 191
153 192 # Returns search results of given ids
154 193 def search_results_from_ids(ids)
155 194 where(:id => ids).preload(searchable_options[:preload]).to_a
156 195 end
157 196
158 197 # Returns search results with same arguments as search_result_ranks_and_ids
159 198 def search_results(*args)
160 199 ranks_and_ids = search_result_ranks_and_ids(*args)
161 200 search_results_from_ids(ranks_and_ids.map(&:last))
162 201 end
163 202 end
164 203 end
165 204 end
166 205 end
167 206 end
General Comments 0
You need to be logged in to leave comments. Login now