You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

issue.rb 38KB

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