##// END OF EJS Templates
Don't store total estimated hours on parent issues (#16092)....
Jean-Philippe Lang -
r13890:481f70d12537
parent child
Show More
@@ -0,0 +1,15
1 class ClearEstimatedHoursOnParentIssues < ActiveRecord::Migration
2 def self.up
3 # Clears estimated hours on parent issues
4 Issue.where("rgt > lft + 1 AND estimated_hours > 0").update_all :estimated_hours => nil
5 end
6
7 def self.down
8 table_name = Issue.table_name
9 leaves_sum_select = "SELECT SUM(leaves.estimated_hours) FROM #{table_name} leaves" +
10 " WHERE leaves.root_id = #{table_name}.root_id AND leaves.lft > #{table_name}.lft AND leaves.rgt < #{table_name}.rgt" +
11 " AND leaves.rgt = leaves.lft + 1"
12
13 Issue.where("rgt > lft + 1").update_all "estimated_hours = (#{leaves_sum_select})"
14 end
15 end
@@ -1,492 +1,500
1 # encoding: utf-8
1 # encoding: utf-8
2 #
2 #
3 # Redmine - project management software
3 # Redmine - project management software
4 # Copyright (C) 2006-2015 Jean-Philippe Lang
4 # Copyright (C) 2006-2015 Jean-Philippe Lang
5 #
5 #
6 # This program is free software; you can redistribute it and/or
6 # This program is free software; you can redistribute it and/or
7 # modify it under the terms of the GNU General Public License
7 # modify it under the terms of the GNU General Public License
8 # as published by the Free Software Foundation; either version 2
8 # as published by the Free Software Foundation; either version 2
9 # of the License, or (at your option) any later version.
9 # of the License, or (at your option) any later version.
10 #
10 #
11 # This program is distributed in the hope that it will be useful,
11 # This program is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU General Public License for more details.
14 # GNU General Public License for more details.
15 #
15 #
16 # You should have received a copy of the GNU General Public License
16 # You should have received a copy of the GNU General Public License
17 # along with this program; if not, write to the Free Software
17 # along with this program; if not, write to the Free Software
18 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19
19
20 module IssuesHelper
20 module IssuesHelper
21 include ApplicationHelper
21 include ApplicationHelper
22 include Redmine::Export::PDF::IssuesPdfHelper
22 include Redmine::Export::PDF::IssuesPdfHelper
23
23
24 def issue_list(issues, &block)
24 def issue_list(issues, &block)
25 ancestors = []
25 ancestors = []
26 issues.each do |issue|
26 issues.each do |issue|
27 while (ancestors.any? && !issue.is_descendant_of?(ancestors.last))
27 while (ancestors.any? && !issue.is_descendant_of?(ancestors.last))
28 ancestors.pop
28 ancestors.pop
29 end
29 end
30 yield issue, ancestors.size
30 yield issue, ancestors.size
31 ancestors << issue unless issue.leaf?
31 ancestors << issue unless issue.leaf?
32 end
32 end
33 end
33 end
34
34
35 def grouped_issue_list(issues, query, issue_count_by_group, &block)
35 def grouped_issue_list(issues, query, issue_count_by_group, &block)
36 previous_group, first = false, true
36 previous_group, first = false, true
37 issue_list(issues) do |issue, level|
37 issue_list(issues) do |issue, level|
38 group_name = group_count = nil
38 group_name = group_count = nil
39 if query.grouped? && ((group = query.group_by_column.value(issue)) != previous_group || first)
39 if query.grouped? && ((group = query.group_by_column.value(issue)) != previous_group || first)
40 if group.blank? && group != false
40 if group.blank? && group != false
41 group_name = "(#{l(:label_blank_value)})"
41 group_name = "(#{l(:label_blank_value)})"
42 else
42 else
43 group_name = column_content(query.group_by_column, issue)
43 group_name = column_content(query.group_by_column, issue)
44 end
44 end
45 group_name ||= ""
45 group_name ||= ""
46 group_count = issue_count_by_group[group]
46 group_count = issue_count_by_group[group]
47 end
47 end
48 yield issue, level, group_name, group_count
48 yield issue, level, group_name, group_count
49 previous_group, first = group, false
49 previous_group, first = group, false
50 end
50 end
51 end
51 end
52
52
53 # Renders a HTML/CSS tooltip
53 # Renders a HTML/CSS tooltip
54 #
54 #
55 # To use, a trigger div is needed. This is a div with the class of "tooltip"
55 # To use, a trigger div is needed. This is a div with the class of "tooltip"
56 # that contains this method wrapped in a span with the class of "tip"
56 # that contains this method wrapped in a span with the class of "tip"
57 #
57 #
58 # <div class="tooltip"><%= link_to_issue(issue) %>
58 # <div class="tooltip"><%= link_to_issue(issue) %>
59 # <span class="tip"><%= render_issue_tooltip(issue) %></span>
59 # <span class="tip"><%= render_issue_tooltip(issue) %></span>
60 # </div>
60 # </div>
61 #
61 #
62 def render_issue_tooltip(issue)
62 def render_issue_tooltip(issue)
63 @cached_label_status ||= l(:field_status)
63 @cached_label_status ||= l(:field_status)
64 @cached_label_start_date ||= l(:field_start_date)
64 @cached_label_start_date ||= l(:field_start_date)
65 @cached_label_due_date ||= l(:field_due_date)
65 @cached_label_due_date ||= l(:field_due_date)
66 @cached_label_assigned_to ||= l(:field_assigned_to)
66 @cached_label_assigned_to ||= l(:field_assigned_to)
67 @cached_label_priority ||= l(:field_priority)
67 @cached_label_priority ||= l(:field_priority)
68 @cached_label_project ||= l(:field_project)
68 @cached_label_project ||= l(:field_project)
69
69
70 link_to_issue(issue) + "<br /><br />".html_safe +
70 link_to_issue(issue) + "<br /><br />".html_safe +
71 "<strong>#{@cached_label_project}</strong>: #{link_to_project(issue.project)}<br />".html_safe +
71 "<strong>#{@cached_label_project}</strong>: #{link_to_project(issue.project)}<br />".html_safe +
72 "<strong>#{@cached_label_status}</strong>: #{h(issue.status.name)}<br />".html_safe +
72 "<strong>#{@cached_label_status}</strong>: #{h(issue.status.name)}<br />".html_safe +
73 "<strong>#{@cached_label_start_date}</strong>: #{format_date(issue.start_date)}<br />".html_safe +
73 "<strong>#{@cached_label_start_date}</strong>: #{format_date(issue.start_date)}<br />".html_safe +
74 "<strong>#{@cached_label_due_date}</strong>: #{format_date(issue.due_date)}<br />".html_safe +
74 "<strong>#{@cached_label_due_date}</strong>: #{format_date(issue.due_date)}<br />".html_safe +
75 "<strong>#{@cached_label_assigned_to}</strong>: #{h(issue.assigned_to)}<br />".html_safe +
75 "<strong>#{@cached_label_assigned_to}</strong>: #{h(issue.assigned_to)}<br />".html_safe +
76 "<strong>#{@cached_label_priority}</strong>: #{h(issue.priority.name)}".html_safe
76 "<strong>#{@cached_label_priority}</strong>: #{h(issue.priority.name)}".html_safe
77 end
77 end
78
78
79 def issue_heading(issue)
79 def issue_heading(issue)
80 h("#{issue.tracker} ##{issue.id}")
80 h("#{issue.tracker} ##{issue.id}")
81 end
81 end
82
82
83 def render_issue_subject_with_tree(issue)
83 def render_issue_subject_with_tree(issue)
84 s = ''
84 s = ''
85 ancestors = issue.root? ? [] : issue.ancestors.visible.to_a
85 ancestors = issue.root? ? [] : issue.ancestors.visible.to_a
86 ancestors.each do |ancestor|
86 ancestors.each do |ancestor|
87 s << '<div>' + content_tag('p', link_to_issue(ancestor, :project => (issue.project_id != ancestor.project_id)))
87 s << '<div>' + content_tag('p', link_to_issue(ancestor, :project => (issue.project_id != ancestor.project_id)))
88 end
88 end
89 s << '<div>'
89 s << '<div>'
90 subject = h(issue.subject)
90 subject = h(issue.subject)
91 if issue.is_private?
91 if issue.is_private?
92 subject = content_tag('span', l(:field_is_private), :class => 'private') + ' ' + subject
92 subject = content_tag('span', l(:field_is_private), :class => 'private') + ' ' + subject
93 end
93 end
94 s << content_tag('h3', subject)
94 s << content_tag('h3', subject)
95 s << '</div>' * (ancestors.size + 1)
95 s << '</div>' * (ancestors.size + 1)
96 s.html_safe
96 s.html_safe
97 end
97 end
98
98
99 def render_descendants_tree(issue)
99 def render_descendants_tree(issue)
100 s = '<form><table class="list issues">'
100 s = '<form><table class="list issues">'
101 issue_list(issue.descendants.visible.preload(:status, :priority, :tracker).sort_by(&:lft)) do |child, level|
101 issue_list(issue.descendants.visible.preload(:status, :priority, :tracker).sort_by(&:lft)) do |child, level|
102 css = "issue issue-#{child.id} hascontextmenu"
102 css = "issue issue-#{child.id} hascontextmenu"
103 css << " idnt idnt-#{level}" if level > 0
103 css << " idnt idnt-#{level}" if level > 0
104 s << content_tag('tr',
104 s << content_tag('tr',
105 content_tag('td', check_box_tag("ids[]", child.id, false, :id => nil), :class => 'checkbox') +
105 content_tag('td', check_box_tag("ids[]", child.id, false, :id => nil), :class => 'checkbox') +
106 content_tag('td', link_to_issue(child, :project => (issue.project_id != child.project_id)), :class => 'subject', :style => 'width: 50%') +
106 content_tag('td', link_to_issue(child, :project => (issue.project_id != child.project_id)), :class => 'subject', :style => 'width: 50%') +
107 content_tag('td', h(child.status)) +
107 content_tag('td', h(child.status)) +
108 content_tag('td', link_to_user(child.assigned_to)) +
108 content_tag('td', link_to_user(child.assigned_to)) +
109 content_tag('td', progress_bar(child.done_ratio, :width => '80px')),
109 content_tag('td', progress_bar(child.done_ratio, :width => '80px')),
110 :class => css)
110 :class => css)
111 end
111 end
112 s << '</table></form>'
112 s << '</table></form>'
113 s.html_safe
113 s.html_safe
114 end
114 end
115
115
116 def issue_estimated_hours_details(issue)
117 s = issue.estimated_hours.present? ? l_hours(issue.estimated_hours) : ""
118 unless issue.leaf? || issue.total_estimated_hours.nil?
119 s << " (#{l(:label_total)}: #{l_hours(issue.total_estimated_hours)})"
120 end
121 s.html_safe
122 end
123
116 # Returns an array of error messages for bulk edited issues
124 # Returns an array of error messages for bulk edited issues
117 def bulk_edit_error_messages(issues)
125 def bulk_edit_error_messages(issues)
118 messages = {}
126 messages = {}
119 issues.each do |issue|
127 issues.each do |issue|
120 issue.errors.full_messages.each do |message|
128 issue.errors.full_messages.each do |message|
121 messages[message] ||= []
129 messages[message] ||= []
122 messages[message] << issue
130 messages[message] << issue
123 end
131 end
124 end
132 end
125 messages.map { |message, issues|
133 messages.map { |message, issues|
126 "#{message}: " + issues.map {|i| "##{i.id}"}.join(', ')
134 "#{message}: " + issues.map {|i| "##{i.id}"}.join(', ')
127 }
135 }
128 end
136 end
129
137
130 # Returns a link for adding a new subtask to the given issue
138 # Returns a link for adding a new subtask to the given issue
131 def link_to_new_subtask(issue)
139 def link_to_new_subtask(issue)
132 attrs = {
140 attrs = {
133 :tracker_id => issue.tracker,
141 :tracker_id => issue.tracker,
134 :parent_issue_id => issue
142 :parent_issue_id => issue
135 }
143 }
136 link_to(l(:button_add), new_project_issue_path(issue.project, :issue => attrs))
144 link_to(l(:button_add), new_project_issue_path(issue.project, :issue => attrs))
137 end
145 end
138
146
139 class IssueFieldsRows
147 class IssueFieldsRows
140 include ActionView::Helpers::TagHelper
148 include ActionView::Helpers::TagHelper
141
149
142 def initialize
150 def initialize
143 @left = []
151 @left = []
144 @right = []
152 @right = []
145 end
153 end
146
154
147 def left(*args)
155 def left(*args)
148 args.any? ? @left << cells(*args) : @left
156 args.any? ? @left << cells(*args) : @left
149 end
157 end
150
158
151 def right(*args)
159 def right(*args)
152 args.any? ? @right << cells(*args) : @right
160 args.any? ? @right << cells(*args) : @right
153 end
161 end
154
162
155 def size
163 def size
156 @left.size > @right.size ? @left.size : @right.size
164 @left.size > @right.size ? @left.size : @right.size
157 end
165 end
158
166
159 def to_html
167 def to_html
160 html = ''.html_safe
168 html = ''.html_safe
161 blank = content_tag('th', '') + content_tag('td', '')
169 blank = content_tag('th', '') + content_tag('td', '')
162 size.times do |i|
170 size.times do |i|
163 left = @left[i] || blank
171 left = @left[i] || blank
164 right = @right[i] || blank
172 right = @right[i] || blank
165 html << content_tag('tr', left + right)
173 html << content_tag('tr', left + right)
166 end
174 end
167 html
175 html
168 end
176 end
169
177
170 def cells(label, text, options={})
178 def cells(label, text, options={})
171 content_tag('th', "#{label}:", options) + content_tag('td', text, options)
179 content_tag('th', "#{label}:", options) + content_tag('td', text, options)
172 end
180 end
173 end
181 end
174
182
175 def issue_fields_rows
183 def issue_fields_rows
176 r = IssueFieldsRows.new
184 r = IssueFieldsRows.new
177 yield r
185 yield r
178 r.to_html
186 r.to_html
179 end
187 end
180
188
181 def render_custom_fields_rows(issue)
189 def render_custom_fields_rows(issue)
182 values = issue.visible_custom_field_values
190 values = issue.visible_custom_field_values
183 return if values.empty?
191 return if values.empty?
184 ordered_values = []
192 ordered_values = []
185 half = (values.size / 2.0).ceil
193 half = (values.size / 2.0).ceil
186 half.times do |i|
194 half.times do |i|
187 ordered_values << values[i]
195 ordered_values << values[i]
188 ordered_values << values[i + half]
196 ordered_values << values[i + half]
189 end
197 end
190 s = "<tr>\n"
198 s = "<tr>\n"
191 n = 0
199 n = 0
192 ordered_values.compact.each do |value|
200 ordered_values.compact.each do |value|
193 css = "cf_#{value.custom_field.id}"
201 css = "cf_#{value.custom_field.id}"
194 s << "</tr>\n<tr>\n" if n > 0 && (n % 2) == 0
202 s << "</tr>\n<tr>\n" if n > 0 && (n % 2) == 0
195 s << "\t<th class=\"#{css}\">#{ custom_field_name_tag(value.custom_field) }:</th><td class=\"#{css}\">#{ h(show_value(value)) }</td>\n"
203 s << "\t<th class=\"#{css}\">#{ custom_field_name_tag(value.custom_field) }:</th><td class=\"#{css}\">#{ h(show_value(value)) }</td>\n"
196 n += 1
204 n += 1
197 end
205 end
198 s << "</tr>\n"
206 s << "</tr>\n"
199 s.html_safe
207 s.html_safe
200 end
208 end
201
209
202 # Returns the path for updating the issue form
210 # Returns the path for updating the issue form
203 # with project as the current project
211 # with project as the current project
204 def update_issue_form_path(project, issue)
212 def update_issue_form_path(project, issue)
205 options = {:format => 'js'}
213 options = {:format => 'js'}
206 if issue.new_record?
214 if issue.new_record?
207 if project
215 if project
208 new_project_issue_path(project, options)
216 new_project_issue_path(project, options)
209 else
217 else
210 new_issue_path(options)
218 new_issue_path(options)
211 end
219 end
212 else
220 else
213 edit_issue_path(issue, options)
221 edit_issue_path(issue, options)
214 end
222 end
215 end
223 end
216
224
217 # Returns the number of descendants for an array of issues
225 # Returns the number of descendants for an array of issues
218 def issues_descendant_count(issues)
226 def issues_descendant_count(issues)
219 ids = issues.reject(&:leaf?).map {|issue| issue.descendants.ids}.flatten.uniq
227 ids = issues.reject(&:leaf?).map {|issue| issue.descendants.ids}.flatten.uniq
220 ids -= issues.map(&:id)
228 ids -= issues.map(&:id)
221 ids.size
229 ids.size
222 end
230 end
223
231
224 def issues_destroy_confirmation_message(issues)
232 def issues_destroy_confirmation_message(issues)
225 issues = [issues] unless issues.is_a?(Array)
233 issues = [issues] unless issues.is_a?(Array)
226 message = l(:text_issues_destroy_confirmation)
234 message = l(:text_issues_destroy_confirmation)
227
235
228 descendant_count = issues_descendant_count(issues)
236 descendant_count = issues_descendant_count(issues)
229 if descendant_count > 0
237 if descendant_count > 0
230 message << "\n" + l(:text_issues_destroy_descendants_confirmation, :count => descendant_count)
238 message << "\n" + l(:text_issues_destroy_descendants_confirmation, :count => descendant_count)
231 end
239 end
232 message
240 message
233 end
241 end
234
242
235 # Returns an array of users that are proposed as watchers
243 # Returns an array of users that are proposed as watchers
236 # on the new issue form
244 # on the new issue form
237 def users_for_new_issue_watchers(issue)
245 def users_for_new_issue_watchers(issue)
238 users = issue.watcher_users
246 users = issue.watcher_users
239 if issue.project.users.count <= 20
247 if issue.project.users.count <= 20
240 users = (users + issue.project.users.sort).uniq
248 users = (users + issue.project.users.sort).uniq
241 end
249 end
242 users
250 users
243 end
251 end
244
252
245 def sidebar_queries
253 def sidebar_queries
246 unless @sidebar_queries
254 unless @sidebar_queries
247 @sidebar_queries = IssueQuery.visible.
255 @sidebar_queries = IssueQuery.visible.
248 order("#{Query.table_name}.name ASC").
256 order("#{Query.table_name}.name ASC").
249 # Project specific queries and global queries
257 # Project specific queries and global queries
250 where(@project.nil? ? ["project_id IS NULL"] : ["project_id IS NULL OR project_id = ?", @project.id]).
258 where(@project.nil? ? ["project_id IS NULL"] : ["project_id IS NULL OR project_id = ?", @project.id]).
251 to_a
259 to_a
252 end
260 end
253 @sidebar_queries
261 @sidebar_queries
254 end
262 end
255
263
256 def query_links(title, queries)
264 def query_links(title, queries)
257 return '' if queries.empty?
265 return '' if queries.empty?
258 # links to #index on issues/show
266 # links to #index on issues/show
259 url_params = controller_name == 'issues' ? {:controller => 'issues', :action => 'index', :project_id => @project} : params
267 url_params = controller_name == 'issues' ? {:controller => 'issues', :action => 'index', :project_id => @project} : params
260
268
261 content_tag('h3', title) + "\n" +
269 content_tag('h3', title) + "\n" +
262 content_tag('ul',
270 content_tag('ul',
263 queries.collect {|query|
271 queries.collect {|query|
264 css = 'query'
272 css = 'query'
265 css << ' selected' if query == @query
273 css << ' selected' if query == @query
266 content_tag('li', link_to(query.name, url_params.merge(:query_id => query), :class => css))
274 content_tag('li', link_to(query.name, url_params.merge(:query_id => query), :class => css))
267 }.join("\n").html_safe,
275 }.join("\n").html_safe,
268 :class => 'queries'
276 :class => 'queries'
269 ) + "\n"
277 ) + "\n"
270 end
278 end
271
279
272 def render_sidebar_queries
280 def render_sidebar_queries
273 out = ''.html_safe
281 out = ''.html_safe
274 out << query_links(l(:label_my_queries), sidebar_queries.select(&:is_private?))
282 out << query_links(l(:label_my_queries), sidebar_queries.select(&:is_private?))
275 out << query_links(l(:label_query_plural), sidebar_queries.reject(&:is_private?))
283 out << query_links(l(:label_query_plural), sidebar_queries.reject(&:is_private?))
276 out
284 out
277 end
285 end
278
286
279 def email_issue_attributes(issue, user)
287 def email_issue_attributes(issue, user)
280 items = []
288 items = []
281 %w(author status priority assigned_to category fixed_version).each do |attribute|
289 %w(author status priority assigned_to category fixed_version).each do |attribute|
282 unless issue.disabled_core_fields.include?(attribute+"_id")
290 unless issue.disabled_core_fields.include?(attribute+"_id")
283 items << "#{l("field_#{attribute}")}: #{issue.send attribute}"
291 items << "#{l("field_#{attribute}")}: #{issue.send attribute}"
284 end
292 end
285 end
293 end
286 issue.visible_custom_field_values(user).each do |value|
294 issue.visible_custom_field_values(user).each do |value|
287 items << "#{value.custom_field.name}: #{show_value(value, false)}"
295 items << "#{value.custom_field.name}: #{show_value(value, false)}"
288 end
296 end
289 items
297 items
290 end
298 end
291
299
292 def render_email_issue_attributes(issue, user, html=false)
300 def render_email_issue_attributes(issue, user, html=false)
293 items = email_issue_attributes(issue, user)
301 items = email_issue_attributes(issue, user)
294 if html
302 if html
295 content_tag('ul', items.map{|s| content_tag('li', s)}.join("\n").html_safe)
303 content_tag('ul', items.map{|s| content_tag('li', s)}.join("\n").html_safe)
296 else
304 else
297 items.map{|s| "* #{s}"}.join("\n")
305 items.map{|s| "* #{s}"}.join("\n")
298 end
306 end
299 end
307 end
300
308
301 # Returns the textual representation of a journal details
309 # Returns the textual representation of a journal details
302 # as an array of strings
310 # as an array of strings
303 def details_to_strings(details, no_html=false, options={})
311 def details_to_strings(details, no_html=false, options={})
304 options[:only_path] = (options[:only_path] == false ? false : true)
312 options[:only_path] = (options[:only_path] == false ? false : true)
305 strings = []
313 strings = []
306 values_by_field = {}
314 values_by_field = {}
307 details.each do |detail|
315 details.each do |detail|
308 if detail.property == 'cf'
316 if detail.property == 'cf'
309 field = detail.custom_field
317 field = detail.custom_field
310 if field && field.multiple?
318 if field && field.multiple?
311 values_by_field[field] ||= {:added => [], :deleted => []}
319 values_by_field[field] ||= {:added => [], :deleted => []}
312 if detail.old_value
320 if detail.old_value
313 values_by_field[field][:deleted] << detail.old_value
321 values_by_field[field][:deleted] << detail.old_value
314 end
322 end
315 if detail.value
323 if detail.value
316 values_by_field[field][:added] << detail.value
324 values_by_field[field][:added] << detail.value
317 end
325 end
318 next
326 next
319 end
327 end
320 end
328 end
321 strings << show_detail(detail, no_html, options)
329 strings << show_detail(detail, no_html, options)
322 end
330 end
323 if values_by_field.present?
331 if values_by_field.present?
324 multiple_values_detail = Struct.new(:property, :prop_key, :custom_field, :old_value, :value)
332 multiple_values_detail = Struct.new(:property, :prop_key, :custom_field, :old_value, :value)
325 values_by_field.each do |field, changes|
333 values_by_field.each do |field, changes|
326 if changes[:added].any?
334 if changes[:added].any?
327 detail = multiple_values_detail.new('cf', field.id.to_s, field)
335 detail = multiple_values_detail.new('cf', field.id.to_s, field)
328 detail.value = changes[:added]
336 detail.value = changes[:added]
329 strings << show_detail(detail, no_html, options)
337 strings << show_detail(detail, no_html, options)
330 end
338 end
331 if changes[:deleted].any?
339 if changes[:deleted].any?
332 detail = multiple_values_detail.new('cf', field.id.to_s, field)
340 detail = multiple_values_detail.new('cf', field.id.to_s, field)
333 detail.old_value = changes[:deleted]
341 detail.old_value = changes[:deleted]
334 strings << show_detail(detail, no_html, options)
342 strings << show_detail(detail, no_html, options)
335 end
343 end
336 end
344 end
337 end
345 end
338 strings
346 strings
339 end
347 end
340
348
341 # Returns the textual representation of a single journal detail
349 # Returns the textual representation of a single journal detail
342 def show_detail(detail, no_html=false, options={})
350 def show_detail(detail, no_html=false, options={})
343 multiple = false
351 multiple = false
344 show_diff = false
352 show_diff = false
345
353
346 case detail.property
354 case detail.property
347 when 'attr'
355 when 'attr'
348 field = detail.prop_key.to_s.gsub(/\_id$/, "")
356 field = detail.prop_key.to_s.gsub(/\_id$/, "")
349 label = l(("field_" + field).to_sym)
357 label = l(("field_" + field).to_sym)
350 case detail.prop_key
358 case detail.prop_key
351 when 'due_date', 'start_date'
359 when 'due_date', 'start_date'
352 value = format_date(detail.value.to_date) if detail.value
360 value = format_date(detail.value.to_date) if detail.value
353 old_value = format_date(detail.old_value.to_date) if detail.old_value
361 old_value = format_date(detail.old_value.to_date) if detail.old_value
354
362
355 when 'project_id', 'status_id', 'tracker_id', 'assigned_to_id',
363 when 'project_id', 'status_id', 'tracker_id', 'assigned_to_id',
356 'priority_id', 'category_id', 'fixed_version_id'
364 'priority_id', 'category_id', 'fixed_version_id'
357 value = find_name_by_reflection(field, detail.value)
365 value = find_name_by_reflection(field, detail.value)
358 old_value = find_name_by_reflection(field, detail.old_value)
366 old_value = find_name_by_reflection(field, detail.old_value)
359
367
360 when 'estimated_hours'
368 when 'estimated_hours'
361 value = "%0.02f" % detail.value.to_f unless detail.value.blank?
369 value = "%0.02f" % detail.value.to_f unless detail.value.blank?
362 old_value = "%0.02f" % detail.old_value.to_f unless detail.old_value.blank?
370 old_value = "%0.02f" % detail.old_value.to_f unless detail.old_value.blank?
363
371
364 when 'parent_id'
372 when 'parent_id'
365 label = l(:field_parent_issue)
373 label = l(:field_parent_issue)
366 value = "##{detail.value}" unless detail.value.blank?
374 value = "##{detail.value}" unless detail.value.blank?
367 old_value = "##{detail.old_value}" unless detail.old_value.blank?
375 old_value = "##{detail.old_value}" unless detail.old_value.blank?
368
376
369 when 'is_private'
377 when 'is_private'
370 value = l(detail.value == "0" ? :general_text_No : :general_text_Yes) unless detail.value.blank?
378 value = l(detail.value == "0" ? :general_text_No : :general_text_Yes) unless detail.value.blank?
371 old_value = l(detail.old_value == "0" ? :general_text_No : :general_text_Yes) unless detail.old_value.blank?
379 old_value = l(detail.old_value == "0" ? :general_text_No : :general_text_Yes) unless detail.old_value.blank?
372
380
373 when 'description'
381 when 'description'
374 show_diff = true
382 show_diff = true
375 end
383 end
376 when 'cf'
384 when 'cf'
377 custom_field = detail.custom_field
385 custom_field = detail.custom_field
378 if custom_field
386 if custom_field
379 label = custom_field.name
387 label = custom_field.name
380 if custom_field.format.class.change_as_diff
388 if custom_field.format.class.change_as_diff
381 show_diff = true
389 show_diff = true
382 else
390 else
383 multiple = custom_field.multiple?
391 multiple = custom_field.multiple?
384 value = format_value(detail.value, custom_field) if detail.value
392 value = format_value(detail.value, custom_field) if detail.value
385 old_value = format_value(detail.old_value, custom_field) if detail.old_value
393 old_value = format_value(detail.old_value, custom_field) if detail.old_value
386 end
394 end
387 end
395 end
388 when 'attachment'
396 when 'attachment'
389 label = l(:label_attachment)
397 label = l(:label_attachment)
390 when 'relation'
398 when 'relation'
391 if detail.value && !detail.old_value
399 if detail.value && !detail.old_value
392 rel_issue = Issue.visible.find_by_id(detail.value)
400 rel_issue = Issue.visible.find_by_id(detail.value)
393 value = rel_issue.nil? ? "#{l(:label_issue)} ##{detail.value}" :
401 value = rel_issue.nil? ? "#{l(:label_issue)} ##{detail.value}" :
394 (no_html ? rel_issue : link_to_issue(rel_issue, :only_path => options[:only_path]))
402 (no_html ? rel_issue : link_to_issue(rel_issue, :only_path => options[:only_path]))
395 elsif detail.old_value && !detail.value
403 elsif detail.old_value && !detail.value
396 rel_issue = Issue.visible.find_by_id(detail.old_value)
404 rel_issue = Issue.visible.find_by_id(detail.old_value)
397 old_value = rel_issue.nil? ? "#{l(:label_issue)} ##{detail.old_value}" :
405 old_value = rel_issue.nil? ? "#{l(:label_issue)} ##{detail.old_value}" :
398 (no_html ? rel_issue : link_to_issue(rel_issue, :only_path => options[:only_path]))
406 (no_html ? rel_issue : link_to_issue(rel_issue, :only_path => options[:only_path]))
399 end
407 end
400 relation_type = IssueRelation::TYPES[detail.prop_key]
408 relation_type = IssueRelation::TYPES[detail.prop_key]
401 label = l(relation_type[:name]) if relation_type
409 label = l(relation_type[:name]) if relation_type
402 end
410 end
403 call_hook(:helper_issues_show_detail_after_setting,
411 call_hook(:helper_issues_show_detail_after_setting,
404 {:detail => detail, :label => label, :value => value, :old_value => old_value })
412 {:detail => detail, :label => label, :value => value, :old_value => old_value })
405
413
406 label ||= detail.prop_key
414 label ||= detail.prop_key
407 value ||= detail.value
415 value ||= detail.value
408 old_value ||= detail.old_value
416 old_value ||= detail.old_value
409
417
410 unless no_html
418 unless no_html
411 label = content_tag('strong', label)
419 label = content_tag('strong', label)
412 old_value = content_tag("i", h(old_value)) if detail.old_value
420 old_value = content_tag("i", h(old_value)) if detail.old_value
413 if detail.old_value && detail.value.blank? && detail.property != 'relation'
421 if detail.old_value && detail.value.blank? && detail.property != 'relation'
414 old_value = content_tag("del", old_value)
422 old_value = content_tag("del", old_value)
415 end
423 end
416 if detail.property == 'attachment' && value.present? &&
424 if detail.property == 'attachment' && value.present? &&
417 atta = detail.journal.journalized.attachments.detect {|a| a.id == detail.prop_key.to_i}
425 atta = detail.journal.journalized.attachments.detect {|a| a.id == detail.prop_key.to_i}
418 # Link to the attachment if it has not been removed
426 # Link to the attachment if it has not been removed
419 value = link_to_attachment(atta, :download => true, :only_path => options[:only_path])
427 value = link_to_attachment(atta, :download => true, :only_path => options[:only_path])
420 if options[:only_path] != false && atta.is_text?
428 if options[:only_path] != false && atta.is_text?
421 value += link_to(
429 value += link_to(
422 image_tag('magnifier.png'),
430 image_tag('magnifier.png'),
423 :controller => 'attachments', :action => 'show',
431 :controller => 'attachments', :action => 'show',
424 :id => atta, :filename => atta.filename
432 :id => atta, :filename => atta.filename
425 )
433 )
426 end
434 end
427 else
435 else
428 value = content_tag("i", h(value)) if value
436 value = content_tag("i", h(value)) if value
429 end
437 end
430 end
438 end
431
439
432 if show_diff
440 if show_diff
433 s = l(:text_journal_changed_no_detail, :label => label)
441 s = l(:text_journal_changed_no_detail, :label => label)
434 unless no_html
442 unless no_html
435 diff_link = link_to 'diff',
443 diff_link = link_to 'diff',
436 {:controller => 'journals', :action => 'diff', :id => detail.journal_id,
444 {:controller => 'journals', :action => 'diff', :id => detail.journal_id,
437 :detail_id => detail.id, :only_path => options[:only_path]},
445 :detail_id => detail.id, :only_path => options[:only_path]},
438 :title => l(:label_view_diff)
446 :title => l(:label_view_diff)
439 s << " (#{ diff_link })"
447 s << " (#{ diff_link })"
440 end
448 end
441 s.html_safe
449 s.html_safe
442 elsif detail.value.present?
450 elsif detail.value.present?
443 case detail.property
451 case detail.property
444 when 'attr', 'cf'
452 when 'attr', 'cf'
445 if detail.old_value.present?
453 if detail.old_value.present?
446 l(:text_journal_changed, :label => label, :old => old_value, :new => value).html_safe
454 l(:text_journal_changed, :label => label, :old => old_value, :new => value).html_safe
447 elsif multiple
455 elsif multiple
448 l(:text_journal_added, :label => label, :value => value).html_safe
456 l(:text_journal_added, :label => label, :value => value).html_safe
449 else
457 else
450 l(:text_journal_set_to, :label => label, :value => value).html_safe
458 l(:text_journal_set_to, :label => label, :value => value).html_safe
451 end
459 end
452 when 'attachment', 'relation'
460 when 'attachment', 'relation'
453 l(:text_journal_added, :label => label, :value => value).html_safe
461 l(:text_journal_added, :label => label, :value => value).html_safe
454 end
462 end
455 else
463 else
456 l(:text_journal_deleted, :label => label, :old => old_value).html_safe
464 l(:text_journal_deleted, :label => label, :old => old_value).html_safe
457 end
465 end
458 end
466 end
459
467
460 # Find the name of an associated record stored in the field attribute
468 # Find the name of an associated record stored in the field attribute
461 def find_name_by_reflection(field, id)
469 def find_name_by_reflection(field, id)
462 unless id.present?
470 unless id.present?
463 return nil
471 return nil
464 end
472 end
465 @detail_value_name_by_reflection ||= Hash.new do |hash, key|
473 @detail_value_name_by_reflection ||= Hash.new do |hash, key|
466 association = Issue.reflect_on_association(key.first.to_sym)
474 association = Issue.reflect_on_association(key.first.to_sym)
467 name = nil
475 name = nil
468 if association
476 if association
469 record = association.klass.find_by_id(key.last)
477 record = association.klass.find_by_id(key.last)
470 if record
478 if record
471 name = record.name.force_encoding('UTF-8')
479 name = record.name.force_encoding('UTF-8')
472 end
480 end
473 end
481 end
474 hash[key] = name
482 hash[key] = name
475 end
483 end
476 @detail_value_name_by_reflection[[field, id]]
484 @detail_value_name_by_reflection[[field, id]]
477 end
485 end
478
486
479 # Renders issue children recursively
487 # Renders issue children recursively
480 def render_api_issue_children(issue, api)
488 def render_api_issue_children(issue, api)
481 return if issue.leaf?
489 return if issue.leaf?
482 api.array :children do
490 api.array :children do
483 issue.children.each do |child|
491 issue.children.each do |child|
484 api.issue(:id => child.id) do
492 api.issue(:id => child.id) do
485 api.tracker(:id => child.tracker_id, :name => child.tracker.name) unless child.tracker.nil?
493 api.tracker(:id => child.tracker_id, :name => child.tracker.name) unless child.tracker.nil?
486 api.subject child.subject
494 api.subject child.subject
487 render_api_issue_children(child, api)
495 render_api_issue_children(child, api)
488 end
496 end
489 end
497 end
490 end
498 end
491 end
499 end
492 end
500 end
@@ -1,1637 +1,1639
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2015 Jean-Philippe Lang
2 # Copyright (C) 2006-2015 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class Issue < ActiveRecord::Base
18 class Issue < ActiveRecord::Base
19 include Redmine::SafeAttributes
19 include Redmine::SafeAttributes
20 include Redmine::Utils::DateCalculation
20 include Redmine::Utils::DateCalculation
21 include Redmine::I18n
21 include Redmine::I18n
22 before_save :set_parent_id
22 before_save :set_parent_id
23 include Redmine::NestedSet::IssueNestedSet
23 include Redmine::NestedSet::IssueNestedSet
24
24
25 belongs_to :project
25 belongs_to :project
26 belongs_to :tracker
26 belongs_to :tracker
27 belongs_to :status, :class_name => 'IssueStatus'
27 belongs_to :status, :class_name => 'IssueStatus'
28 belongs_to :author, :class_name => 'User'
28 belongs_to :author, :class_name => 'User'
29 belongs_to :assigned_to, :class_name => 'Principal'
29 belongs_to :assigned_to, :class_name => 'Principal'
30 belongs_to :fixed_version, :class_name => 'Version'
30 belongs_to :fixed_version, :class_name => 'Version'
31 belongs_to :priority, :class_name => 'IssuePriority'
31 belongs_to :priority, :class_name => 'IssuePriority'
32 belongs_to :category, :class_name => 'IssueCategory'
32 belongs_to :category, :class_name => 'IssueCategory'
33
33
34 has_many :journals, :as => :journalized, :dependent => :destroy, :inverse_of => :journalized
34 has_many :journals, :as => :journalized, :dependent => :destroy, :inverse_of => :journalized
35 has_many :visible_journals,
35 has_many :visible_journals,
36 lambda {where(["(#{Journal.table_name}.private_notes = ? OR (#{Project.allowed_to_condition(User.current, :view_private_notes)}))", false])},
36 lambda {where(["(#{Journal.table_name}.private_notes = ? OR (#{Project.allowed_to_condition(User.current, :view_private_notes)}))", false])},
37 :class_name => 'Journal',
37 :class_name => 'Journal',
38 :as => :journalized
38 :as => :journalized
39
39
40 has_many :time_entries, :dependent => :destroy
40 has_many :time_entries, :dependent => :destroy
41 has_and_belongs_to_many :changesets, lambda {order("#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC")}
41 has_and_belongs_to_many :changesets, lambda {order("#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC")}
42
42
43 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
43 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
44 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
44 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
45
45
46 acts_as_attachable :after_add => :attachment_added, :after_remove => :attachment_removed
46 acts_as_attachable :after_add => :attachment_added, :after_remove => :attachment_removed
47 acts_as_customizable
47 acts_as_customizable
48 acts_as_watchable
48 acts_as_watchable
49 acts_as_searchable :columns => ['subject', "#{table_name}.description"],
49 acts_as_searchable :columns => ['subject', "#{table_name}.description"],
50 :preload => [:project, :status, :tracker],
50 :preload => [:project, :status, :tracker],
51 :scope => lambda {|options| options[:open_issues] ? self.open : self.all}
51 :scope => lambda {|options| options[:open_issues] ? self.open : self.all}
52
52
53 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
53 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
54 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
54 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
55 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
55 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
56
56
57 acts_as_activity_provider :scope => preload(:project, :author, :tracker),
57 acts_as_activity_provider :scope => preload(:project, :author, :tracker),
58 :author_key => :author_id
58 :author_key => :author_id
59
59
60 DONE_RATIO_OPTIONS = %w(issue_field issue_status)
60 DONE_RATIO_OPTIONS = %w(issue_field issue_status)
61
61
62 attr_reader :current_journal
62 attr_reader :current_journal
63 delegate :notes, :notes=, :private_notes, :private_notes=, :to => :current_journal, :allow_nil => true
63 delegate :notes, :notes=, :private_notes, :private_notes=, :to => :current_journal, :allow_nil => true
64
64
65 validates_presence_of :subject, :project, :tracker
65 validates_presence_of :subject, :project, :tracker
66 validates_presence_of :priority, :if => Proc.new {|issue| issue.new_record? || issue.priority_id_changed?}
66 validates_presence_of :priority, :if => Proc.new {|issue| issue.new_record? || issue.priority_id_changed?}
67 validates_presence_of :status, :if => Proc.new {|issue| issue.new_record? || issue.status_id_changed?}
67 validates_presence_of :status, :if => Proc.new {|issue| issue.new_record? || issue.status_id_changed?}
68 validates_presence_of :author, :if => Proc.new {|issue| issue.new_record? || issue.author_id_changed?}
68 validates_presence_of :author, :if => Proc.new {|issue| issue.new_record? || issue.author_id_changed?}
69
69
70 validates_length_of :subject, :maximum => 255
70 validates_length_of :subject, :maximum => 255
71 validates_inclusion_of :done_ratio, :in => 0..100
71 validates_inclusion_of :done_ratio, :in => 0..100
72 validates :estimated_hours, :numericality => {:greater_than_or_equal_to => 0, :allow_nil => true, :message => :invalid}
72 validates :estimated_hours, :numericality => {:greater_than_or_equal_to => 0, :allow_nil => true, :message => :invalid}
73 validates :start_date, :date => true
73 validates :start_date, :date => true
74 validates :due_date, :date => true
74 validates :due_date, :date => true
75 validate :validate_issue, :validate_required_fields
75 validate :validate_issue, :validate_required_fields
76 attr_protected :id
76 attr_protected :id
77
77
78 scope :visible, lambda {|*args|
78 scope :visible, lambda {|*args|
79 joins(:project).
79 joins(:project).
80 where(Issue.visible_condition(args.shift || User.current, *args))
80 where(Issue.visible_condition(args.shift || User.current, *args))
81 }
81 }
82
82
83 scope :open, lambda {|*args|
83 scope :open, lambda {|*args|
84 is_closed = args.size > 0 ? !args.first : false
84 is_closed = args.size > 0 ? !args.first : false
85 joins(:status).
85 joins(:status).
86 where("#{IssueStatus.table_name}.is_closed = ?", is_closed)
86 where("#{IssueStatus.table_name}.is_closed = ?", is_closed)
87 }
87 }
88
88
89 scope :recently_updated, lambda { order("#{Issue.table_name}.updated_on DESC") }
89 scope :recently_updated, lambda { order("#{Issue.table_name}.updated_on DESC") }
90 scope :on_active_project, lambda {
90 scope :on_active_project, lambda {
91 joins(:project).
91 joins(:project).
92 where("#{Project.table_name}.status = ?", Project::STATUS_ACTIVE)
92 where("#{Project.table_name}.status = ?", Project::STATUS_ACTIVE)
93 }
93 }
94 scope :fixed_version, lambda {|versions|
94 scope :fixed_version, lambda {|versions|
95 ids = [versions].flatten.compact.map {|v| v.is_a?(Version) ? v.id : v}
95 ids = [versions].flatten.compact.map {|v| v.is_a?(Version) ? v.id : v}
96 ids.any? ? where(:fixed_version_id => ids) : where('1=0')
96 ids.any? ? where(:fixed_version_id => ids) : where('1=0')
97 }
97 }
98
98
99 before_validation :clear_disabled_fields
99 before_validation :clear_disabled_fields
100 before_create :default_assign
100 before_create :default_assign
101 before_save :close_duplicates, :update_done_ratio_from_issue_status,
101 before_save :close_duplicates, :update_done_ratio_from_issue_status,
102 :force_updated_on_change, :update_closed_on, :set_assigned_to_was
102 :force_updated_on_change, :update_closed_on, :set_assigned_to_was
103 after_save {|issue| issue.send :after_project_change if !issue.id_changed? && issue.project_id_changed?}
103 after_save {|issue| issue.send :after_project_change if !issue.id_changed? && issue.project_id_changed?}
104 after_save :reschedule_following_issues, :update_nested_set_attributes,
104 after_save :reschedule_following_issues, :update_nested_set_attributes,
105 :update_parent_attributes, :create_journal
105 :update_parent_attributes, :create_journal
106 # Should be after_create but would be called before previous after_save callbacks
106 # Should be after_create but would be called before previous after_save callbacks
107 after_save :after_create_from_copy
107 after_save :after_create_from_copy
108 after_destroy :update_parent_attributes
108 after_destroy :update_parent_attributes
109 after_create :send_notification
109 after_create :send_notification
110 # Keep it at the end of after_save callbacks
110 # Keep it at the end of after_save callbacks
111 after_save :clear_assigned_to_was
111 after_save :clear_assigned_to_was
112
112
113 # Returns a SQL conditions string used to find all issues visible by the specified user
113 # Returns a SQL conditions string used to find all issues visible by the specified user
114 def self.visible_condition(user, options={})
114 def self.visible_condition(user, options={})
115 Project.allowed_to_condition(user, :view_issues, options) do |role, user|
115 Project.allowed_to_condition(user, :view_issues, options) do |role, user|
116 if user.id && user.logged?
116 if user.id && user.logged?
117 case role.issues_visibility
117 case role.issues_visibility
118 when 'all'
118 when 'all'
119 nil
119 nil
120 when 'default'
120 when 'default'
121 user_ids = [user.id] + user.groups.map(&:id).compact
121 user_ids = [user.id] + user.groups.map(&:id).compact
122 "(#{table_name}.is_private = #{connection.quoted_false} OR #{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
122 "(#{table_name}.is_private = #{connection.quoted_false} OR #{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
123 when 'own'
123 when 'own'
124 user_ids = [user.id] + user.groups.map(&:id).compact
124 user_ids = [user.id] + user.groups.map(&:id).compact
125 "(#{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
125 "(#{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
126 else
126 else
127 '1=0'
127 '1=0'
128 end
128 end
129 else
129 else
130 "(#{table_name}.is_private = #{connection.quoted_false})"
130 "(#{table_name}.is_private = #{connection.quoted_false})"
131 end
131 end
132 end
132 end
133 end
133 end
134
134
135 # Returns true if usr or current user is allowed to view the issue
135 # Returns true if usr or current user is allowed to view the issue
136 def visible?(usr=nil)
136 def visible?(usr=nil)
137 (usr || User.current).allowed_to?(:view_issues, self.project) do |role, user|
137 (usr || User.current).allowed_to?(:view_issues, self.project) do |role, user|
138 if user.logged?
138 if user.logged?
139 case role.issues_visibility
139 case role.issues_visibility
140 when 'all'
140 when 'all'
141 true
141 true
142 when 'default'
142 when 'default'
143 !self.is_private? || (self.author == user || user.is_or_belongs_to?(assigned_to))
143 !self.is_private? || (self.author == user || user.is_or_belongs_to?(assigned_to))
144 when 'own'
144 when 'own'
145 self.author == user || user.is_or_belongs_to?(assigned_to)
145 self.author == user || user.is_or_belongs_to?(assigned_to)
146 else
146 else
147 false
147 false
148 end
148 end
149 else
149 else
150 !self.is_private?
150 !self.is_private?
151 end
151 end
152 end
152 end
153 end
153 end
154
154
155 # Returns true if user or current user is allowed to edit or add a note to the issue
155 # Returns true if user or current user is allowed to edit or add a note to the issue
156 def editable?(user=User.current)
156 def editable?(user=User.current)
157 attributes_editable?(user) || user.allowed_to?(:add_issue_notes, project)
157 attributes_editable?(user) || user.allowed_to?(:add_issue_notes, project)
158 end
158 end
159
159
160 # Returns true if user or current user is allowed to edit the issue
160 # Returns true if user or current user is allowed to edit the issue
161 def attributes_editable?(user=User.current)
161 def attributes_editable?(user=User.current)
162 user.allowed_to?(:edit_issues, project)
162 user.allowed_to?(:edit_issues, project)
163 end
163 end
164
164
165 def initialize(attributes=nil, *args)
165 def initialize(attributes=nil, *args)
166 super
166 super
167 if new_record?
167 if new_record?
168 # set default values for new records only
168 # set default values for new records only
169 self.priority ||= IssuePriority.default
169 self.priority ||= IssuePriority.default
170 self.watcher_user_ids = []
170 self.watcher_user_ids = []
171 end
171 end
172 end
172 end
173
173
174 def create_or_update
174 def create_or_update
175 super
175 super
176 ensure
176 ensure
177 @status_was = nil
177 @status_was = nil
178 end
178 end
179 private :create_or_update
179 private :create_or_update
180
180
181 # AR#Persistence#destroy would raise and RecordNotFound exception
181 # AR#Persistence#destroy would raise and RecordNotFound exception
182 # if the issue was already deleted or updated (non matching lock_version).
182 # if the issue was already deleted or updated (non matching lock_version).
183 # This is a problem when bulk deleting issues or deleting a project
183 # This is a problem when bulk deleting issues or deleting a project
184 # (because an issue may already be deleted if its parent was deleted
184 # (because an issue may already be deleted if its parent was deleted
185 # first).
185 # first).
186 # The issue is reloaded by the nested_set before being deleted so
186 # The issue is reloaded by the nested_set before being deleted so
187 # the lock_version condition should not be an issue but we handle it.
187 # the lock_version condition should not be an issue but we handle it.
188 def destroy
188 def destroy
189 super
189 super
190 rescue ActiveRecord::StaleObjectError, ActiveRecord::RecordNotFound
190 rescue ActiveRecord::StaleObjectError, ActiveRecord::RecordNotFound
191 # Stale or already deleted
191 # Stale or already deleted
192 begin
192 begin
193 reload
193 reload
194 rescue ActiveRecord::RecordNotFound
194 rescue ActiveRecord::RecordNotFound
195 # The issue was actually already deleted
195 # The issue was actually already deleted
196 @destroyed = true
196 @destroyed = true
197 return freeze
197 return freeze
198 end
198 end
199 # The issue was stale, retry to destroy
199 # The issue was stale, retry to destroy
200 super
200 super
201 end
201 end
202
202
203 alias :base_reload :reload
203 alias :base_reload :reload
204 def reload(*args)
204 def reload(*args)
205 @workflow_rule_by_attribute = nil
205 @workflow_rule_by_attribute = nil
206 @assignable_versions = nil
206 @assignable_versions = nil
207 @relations = nil
207 @relations = nil
208 @spent_hours = nil
208 @spent_hours = nil
209 @total_estimated_hours = nil
209 base_reload(*args)
210 base_reload(*args)
210 end
211 end
211
212
212 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
213 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
213 def available_custom_fields
214 def available_custom_fields
214 (project && tracker) ? (project.all_issue_custom_fields & tracker.custom_fields) : []
215 (project && tracker) ? (project.all_issue_custom_fields & tracker.custom_fields) : []
215 end
216 end
216
217
217 def visible_custom_field_values(user=nil)
218 def visible_custom_field_values(user=nil)
218 user_real = user || User.current
219 user_real = user || User.current
219 custom_field_values.select do |value|
220 custom_field_values.select do |value|
220 value.custom_field.visible_by?(project, user_real)
221 value.custom_field.visible_by?(project, user_real)
221 end
222 end
222 end
223 end
223
224
224 # Copies attributes from another issue, arg can be an id or an Issue
225 # Copies attributes from another issue, arg can be an id or an Issue
225 def copy_from(arg, options={})
226 def copy_from(arg, options={})
226 issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg)
227 issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg)
227 self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
228 self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
228 self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
229 self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
229 self.status = issue.status
230 self.status = issue.status
230 self.author = User.current
231 self.author = User.current
231 unless options[:attachments] == false
232 unless options[:attachments] == false
232 self.attachments = issue.attachments.map do |attachement|
233 self.attachments = issue.attachments.map do |attachement|
233 attachement.copy(:container => self)
234 attachement.copy(:container => self)
234 end
235 end
235 end
236 end
236 @copied_from = issue
237 @copied_from = issue
237 @copy_options = options
238 @copy_options = options
238 self
239 self
239 end
240 end
240
241
241 # Returns an unsaved copy of the issue
242 # Returns an unsaved copy of the issue
242 def copy(attributes=nil, copy_options={})
243 def copy(attributes=nil, copy_options={})
243 copy = self.class.new.copy_from(self, copy_options)
244 copy = self.class.new.copy_from(self, copy_options)
244 copy.attributes = attributes if attributes
245 copy.attributes = attributes if attributes
245 copy
246 copy
246 end
247 end
247
248
248 # Returns true if the issue is a copy
249 # Returns true if the issue is a copy
249 def copy?
250 def copy?
250 @copied_from.present?
251 @copied_from.present?
251 end
252 end
252
253
253 def status_id=(status_id)
254 def status_id=(status_id)
254 if status_id.to_s != self.status_id.to_s
255 if status_id.to_s != self.status_id.to_s
255 self.status = (status_id.present? ? IssueStatus.find_by_id(status_id) : nil)
256 self.status = (status_id.present? ? IssueStatus.find_by_id(status_id) : nil)
256 end
257 end
257 self.status_id
258 self.status_id
258 end
259 end
259
260
260 # Sets the status.
261 # Sets the status.
261 def status=(status)
262 def status=(status)
262 if status != self.status
263 if status != self.status
263 @workflow_rule_by_attribute = nil
264 @workflow_rule_by_attribute = nil
264 end
265 end
265 association(:status).writer(status)
266 association(:status).writer(status)
266 end
267 end
267
268
268 def priority_id=(pid)
269 def priority_id=(pid)
269 self.priority = nil
270 self.priority = nil
270 write_attribute(:priority_id, pid)
271 write_attribute(:priority_id, pid)
271 end
272 end
272
273
273 def category_id=(cid)
274 def category_id=(cid)
274 self.category = nil
275 self.category = nil
275 write_attribute(:category_id, cid)
276 write_attribute(:category_id, cid)
276 end
277 end
277
278
278 def fixed_version_id=(vid)
279 def fixed_version_id=(vid)
279 self.fixed_version = nil
280 self.fixed_version = nil
280 write_attribute(:fixed_version_id, vid)
281 write_attribute(:fixed_version_id, vid)
281 end
282 end
282
283
283 def tracker_id=(tracker_id)
284 def tracker_id=(tracker_id)
284 if tracker_id.to_s != self.tracker_id.to_s
285 if tracker_id.to_s != self.tracker_id.to_s
285 self.tracker = (tracker_id.present? ? Tracker.find_by_id(tracker_id) : nil)
286 self.tracker = (tracker_id.present? ? Tracker.find_by_id(tracker_id) : nil)
286 end
287 end
287 self.tracker_id
288 self.tracker_id
288 end
289 end
289
290
290 # Sets the tracker.
291 # Sets the tracker.
291 # This will set the status to the default status of the new tracker if:
292 # This will set the status to the default status of the new tracker if:
292 # * the status was the default for the previous tracker
293 # * the status was the default for the previous tracker
293 # * or if the status was not part of the new tracker statuses
294 # * or if the status was not part of the new tracker statuses
294 # * or the status was nil
295 # * or the status was nil
295 def tracker=(tracker)
296 def tracker=(tracker)
296 if tracker != self.tracker
297 if tracker != self.tracker
297 if status == default_status
298 if status == default_status
298 self.status = nil
299 self.status = nil
299 elsif status && tracker && !tracker.issue_status_ids.include?(status.id)
300 elsif status && tracker && !tracker.issue_status_ids.include?(status.id)
300 self.status = nil
301 self.status = nil
301 end
302 end
302 @custom_field_values = nil
303 @custom_field_values = nil
303 @workflow_rule_by_attribute = nil
304 @workflow_rule_by_attribute = nil
304 end
305 end
305 association(:tracker).writer(tracker)
306 association(:tracker).writer(tracker)
306 self.status ||= default_status
307 self.status ||= default_status
307 self.tracker
308 self.tracker
308 end
309 end
309
310
310 def project_id=(project_id)
311 def project_id=(project_id)
311 if project_id.to_s != self.project_id.to_s
312 if project_id.to_s != self.project_id.to_s
312 self.project = (project_id.present? ? Project.find_by_id(project_id) : nil)
313 self.project = (project_id.present? ? Project.find_by_id(project_id) : nil)
313 end
314 end
314 self.project_id
315 self.project_id
315 end
316 end
316
317
317 # Sets the project.
318 # Sets the project.
318 # Unless keep_tracker argument is set to true, this will change the tracker
319 # Unless keep_tracker argument is set to true, this will change the tracker
319 # to the first tracker of the new project if the previous tracker is not part
320 # to the first tracker of the new project if the previous tracker is not part
320 # of the new project trackers.
321 # of the new project trackers.
321 # This will clear the fixed_version is it's no longer valid for the new project.
322 # This will clear the fixed_version is it's no longer valid for the new project.
322 # This will clear the parent issue if it's no longer valid for the new project.
323 # This will clear the parent issue if it's no longer valid for the new project.
323 # This will set the category to the category with the same name in the new
324 # This will set the category to the category with the same name in the new
324 # project if it exists, or clear it if it doesn't.
325 # project if it exists, or clear it if it doesn't.
325 def project=(project, keep_tracker=false)
326 def project=(project, keep_tracker=false)
326 project_was = self.project
327 project_was = self.project
327 association(:project).writer(project)
328 association(:project).writer(project)
328 if project_was && project && project_was != project
329 if project_was && project && project_was != project
329 @assignable_versions = nil
330 @assignable_versions = nil
330
331
331 unless keep_tracker || project.trackers.include?(tracker)
332 unless keep_tracker || project.trackers.include?(tracker)
332 self.tracker = project.trackers.first
333 self.tracker = project.trackers.first
333 end
334 end
334 # Reassign to the category with same name if any
335 # Reassign to the category with same name if any
335 if category
336 if category
336 self.category = project.issue_categories.find_by_name(category.name)
337 self.category = project.issue_categories.find_by_name(category.name)
337 end
338 end
338 # Keep the fixed_version if it's still valid in the new_project
339 # Keep the fixed_version if it's still valid in the new_project
339 if fixed_version && fixed_version.project != project && !project.shared_versions.include?(fixed_version)
340 if fixed_version && fixed_version.project != project && !project.shared_versions.include?(fixed_version)
340 self.fixed_version = nil
341 self.fixed_version = nil
341 end
342 end
342 # Clear the parent task if it's no longer valid
343 # Clear the parent task if it's no longer valid
343 unless valid_parent_project?
344 unless valid_parent_project?
344 self.parent_issue_id = nil
345 self.parent_issue_id = nil
345 end
346 end
346 @custom_field_values = nil
347 @custom_field_values = nil
347 @workflow_rule_by_attribute = nil
348 @workflow_rule_by_attribute = nil
348 end
349 end
349 self.project
350 self.project
350 end
351 end
351
352
352 def description=(arg)
353 def description=(arg)
353 if arg.is_a?(String)
354 if arg.is_a?(String)
354 arg = arg.gsub(/(\r\n|\n|\r)/, "\r\n")
355 arg = arg.gsub(/(\r\n|\n|\r)/, "\r\n")
355 end
356 end
356 write_attribute(:description, arg)
357 write_attribute(:description, arg)
357 end
358 end
358
359
359 # Overrides assign_attributes so that project and tracker get assigned first
360 # Overrides assign_attributes so that project and tracker get assigned first
360 def assign_attributes_with_project_and_tracker_first(new_attributes, *args)
361 def assign_attributes_with_project_and_tracker_first(new_attributes, *args)
361 return if new_attributes.nil?
362 return if new_attributes.nil?
362 attrs = new_attributes.dup
363 attrs = new_attributes.dup
363 attrs.stringify_keys!
364 attrs.stringify_keys!
364
365
365 %w(project project_id tracker tracker_id).each do |attr|
366 %w(project project_id tracker tracker_id).each do |attr|
366 if attrs.has_key?(attr)
367 if attrs.has_key?(attr)
367 send "#{attr}=", attrs.delete(attr)
368 send "#{attr}=", attrs.delete(attr)
368 end
369 end
369 end
370 end
370 send :assign_attributes_without_project_and_tracker_first, attrs, *args
371 send :assign_attributes_without_project_and_tracker_first, attrs, *args
371 end
372 end
372 # Do not redefine alias chain on reload (see #4838)
373 # Do not redefine alias chain on reload (see #4838)
373 alias_method_chain(:assign_attributes, :project_and_tracker_first) unless method_defined?(:assign_attributes_without_project_and_tracker_first)
374 alias_method_chain(:assign_attributes, :project_and_tracker_first) unless method_defined?(:assign_attributes_without_project_and_tracker_first)
374
375
375 def attributes=(new_attributes)
376 def attributes=(new_attributes)
376 assign_attributes new_attributes
377 assign_attributes new_attributes
377 end
378 end
378
379
379 def estimated_hours=(h)
380 def estimated_hours=(h)
380 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
381 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
381 end
382 end
382
383
383 safe_attributes 'project_id',
384 safe_attributes 'project_id',
384 'tracker_id',
385 'tracker_id',
385 'status_id',
386 'status_id',
386 'category_id',
387 'category_id',
387 'assigned_to_id',
388 'assigned_to_id',
388 'priority_id',
389 'priority_id',
389 'fixed_version_id',
390 'fixed_version_id',
390 'subject',
391 'subject',
391 'description',
392 'description',
392 'start_date',
393 'start_date',
393 'due_date',
394 'due_date',
394 'done_ratio',
395 'done_ratio',
395 'estimated_hours',
396 'estimated_hours',
396 'custom_field_values',
397 'custom_field_values',
397 'custom_fields',
398 'custom_fields',
398 'lock_version',
399 'lock_version',
399 'notes',
400 'notes',
400 :if => lambda {|issue, user| issue.new_record? || user.allowed_to?(:edit_issues, issue.project) }
401 :if => lambda {|issue, user| issue.new_record? || user.allowed_to?(:edit_issues, issue.project) }
401
402
402 safe_attributes 'notes',
403 safe_attributes 'notes',
403 :if => lambda {|issue, user| user.allowed_to?(:add_issue_notes, issue.project)}
404 :if => lambda {|issue, user| user.allowed_to?(:add_issue_notes, issue.project)}
404
405
405 safe_attributes 'private_notes',
406 safe_attributes 'private_notes',
406 :if => lambda {|issue, user| !issue.new_record? && user.allowed_to?(:set_notes_private, issue.project)}
407 :if => lambda {|issue, user| !issue.new_record? && user.allowed_to?(:set_notes_private, issue.project)}
407
408
408 safe_attributes 'watcher_user_ids',
409 safe_attributes 'watcher_user_ids',
409 :if => lambda {|issue, user| issue.new_record? && user.allowed_to?(:add_issue_watchers, issue.project)}
410 :if => lambda {|issue, user| issue.new_record? && user.allowed_to?(:add_issue_watchers, issue.project)}
410
411
411 safe_attributes 'is_private',
412 safe_attributes 'is_private',
412 :if => lambda {|issue, user|
413 :if => lambda {|issue, user|
413 user.allowed_to?(:set_issues_private, issue.project) ||
414 user.allowed_to?(:set_issues_private, issue.project) ||
414 (issue.author_id == user.id && user.allowed_to?(:set_own_issues_private, issue.project))
415 (issue.author_id == user.id && user.allowed_to?(:set_own_issues_private, issue.project))
415 }
416 }
416
417
417 safe_attributes 'parent_issue_id',
418 safe_attributes 'parent_issue_id',
418 :if => lambda {|issue, user| (issue.new_record? || user.allowed_to?(:edit_issues, issue.project)) &&
419 :if => lambda {|issue, user| (issue.new_record? || user.allowed_to?(:edit_issues, issue.project)) &&
419 user.allowed_to?(:manage_subtasks, issue.project)}
420 user.allowed_to?(:manage_subtasks, issue.project)}
420
421
421 def safe_attribute_names(user=nil)
422 def safe_attribute_names(user=nil)
422 names = super
423 names = super
423 names -= disabled_core_fields
424 names -= disabled_core_fields
424 names -= read_only_attribute_names(user)
425 names -= read_only_attribute_names(user)
425 if new_record?
426 if new_record?
426 # Make sure that project_id can always be set for new issues
427 # Make sure that project_id can always be set for new issues
427 names |= %w(project_id)
428 names |= %w(project_id)
428 end
429 end
429 if dates_derived?
430 if dates_derived?
430 names -= %w(start_date due_date)
431 names -= %w(start_date due_date)
431 end
432 end
432 if priority_derived?
433 if priority_derived?
433 names -= %w(priority_id)
434 names -= %w(priority_id)
434 end
435 end
435 if done_ratio_derived?
436 if done_ratio_derived?
436 names -= %w(done_ratio)
437 names -= %w(done_ratio)
437 end
438 end
438 unless leaf?
439 names -= %w(estimated_hours)
440 end
441 names
439 names
442 end
440 end
443
441
444 # Safely sets attributes
442 # Safely sets attributes
445 # Should be called from controllers instead of #attributes=
443 # Should be called from controllers instead of #attributes=
446 # attr_accessible is too rough because we still want things like
444 # attr_accessible is too rough because we still want things like
447 # Issue.new(:project => foo) to work
445 # Issue.new(:project => foo) to work
448 def safe_attributes=(attrs, user=User.current)
446 def safe_attributes=(attrs, user=User.current)
449 return unless attrs.is_a?(Hash)
447 return unless attrs.is_a?(Hash)
450
448
451 attrs = attrs.deep_dup
449 attrs = attrs.deep_dup
452
450
453 # Project and Tracker must be set before since new_statuses_allowed_to depends on it.
451 # Project and Tracker must be set before since new_statuses_allowed_to depends on it.
454 if (p = attrs.delete('project_id')) && safe_attribute?('project_id')
452 if (p = attrs.delete('project_id')) && safe_attribute?('project_id')
455 if allowed_target_projects(user).where(:id => p.to_i).exists?
453 if allowed_target_projects(user).where(:id => p.to_i).exists?
456 self.project_id = p
454 self.project_id = p
457 end
455 end
458 end
456 end
459
457
460 if (t = attrs.delete('tracker_id')) && safe_attribute?('tracker_id')
458 if (t = attrs.delete('tracker_id')) && safe_attribute?('tracker_id')
461 self.tracker_id = t
459 self.tracker_id = t
462 end
460 end
463 if project
461 if project
464 # Set the default tracker to accept custom field values
462 # Set the default tracker to accept custom field values
465 # even if tracker is not specified
463 # even if tracker is not specified
466 self.tracker ||= project.trackers.first
464 self.tracker ||= project.trackers.first
467 end
465 end
468
466
469 if (s = attrs.delete('status_id')) && safe_attribute?('status_id')
467 if (s = attrs.delete('status_id')) && safe_attribute?('status_id')
470 if new_statuses_allowed_to(user).collect(&:id).include?(s.to_i)
468 if new_statuses_allowed_to(user).collect(&:id).include?(s.to_i)
471 self.status_id = s
469 self.status_id = s
472 end
470 end
473 end
471 end
474
472
475 attrs = delete_unsafe_attributes(attrs, user)
473 attrs = delete_unsafe_attributes(attrs, user)
476 return if attrs.empty?
474 return if attrs.empty?
477
475
478 if attrs['parent_issue_id'].present?
476 if attrs['parent_issue_id'].present?
479 s = attrs['parent_issue_id'].to_s
477 s = attrs['parent_issue_id'].to_s
480 unless (m = s.match(%r{\A#?(\d+)\z})) && (m[1] == parent_id.to_s || Issue.visible(user).exists?(m[1]))
478 unless (m = s.match(%r{\A#?(\d+)\z})) && (m[1] == parent_id.to_s || Issue.visible(user).exists?(m[1]))
481 @invalid_parent_issue_id = attrs.delete('parent_issue_id')
479 @invalid_parent_issue_id = attrs.delete('parent_issue_id')
482 end
480 end
483 end
481 end
484
482
485 if attrs['custom_field_values'].present?
483 if attrs['custom_field_values'].present?
486 editable_custom_field_ids = editable_custom_field_values(user).map {|v| v.custom_field_id.to_s}
484 editable_custom_field_ids = editable_custom_field_values(user).map {|v| v.custom_field_id.to_s}
487 attrs['custom_field_values'].select! {|k, v| editable_custom_field_ids.include?(k.to_s)}
485 attrs['custom_field_values'].select! {|k, v| editable_custom_field_ids.include?(k.to_s)}
488 end
486 end
489
487
490 if attrs['custom_fields'].present?
488 if attrs['custom_fields'].present?
491 editable_custom_field_ids = editable_custom_field_values(user).map {|v| v.custom_field_id.to_s}
489 editable_custom_field_ids = editable_custom_field_values(user).map {|v| v.custom_field_id.to_s}
492 attrs['custom_fields'].select! {|c| editable_custom_field_ids.include?(c['id'].to_s)}
490 attrs['custom_fields'].select! {|c| editable_custom_field_ids.include?(c['id'].to_s)}
493 end
491 end
494
492
495 # mass-assignment security bypass
493 # mass-assignment security bypass
496 assign_attributes attrs, :without_protection => true
494 assign_attributes attrs, :without_protection => true
497 end
495 end
498
496
499 def disabled_core_fields
497 def disabled_core_fields
500 tracker ? tracker.disabled_core_fields : []
498 tracker ? tracker.disabled_core_fields : []
501 end
499 end
502
500
503 # Returns the custom_field_values that can be edited by the given user
501 # Returns the custom_field_values that can be edited by the given user
504 def editable_custom_field_values(user=nil)
502 def editable_custom_field_values(user=nil)
505 visible_custom_field_values(user).reject do |value|
503 visible_custom_field_values(user).reject do |value|
506 read_only_attribute_names(user).include?(value.custom_field_id.to_s)
504 read_only_attribute_names(user).include?(value.custom_field_id.to_s)
507 end
505 end
508 end
506 end
509
507
510 # Returns the custom fields that can be edited by the given user
508 # Returns the custom fields that can be edited by the given user
511 def editable_custom_fields(user=nil)
509 def editable_custom_fields(user=nil)
512 editable_custom_field_values(user).map(&:custom_field).uniq
510 editable_custom_field_values(user).map(&:custom_field).uniq
513 end
511 end
514
512
515 # Returns the names of attributes that are read-only for user or the current user
513 # Returns the names of attributes that are read-only for user or the current user
516 # For users with multiple roles, the read-only fields are the intersection of
514 # For users with multiple roles, the read-only fields are the intersection of
517 # read-only fields of each role
515 # read-only fields of each role
518 # The result is an array of strings where sustom fields are represented with their ids
516 # The result is an array of strings where sustom fields are represented with their ids
519 #
517 #
520 # Examples:
518 # Examples:
521 # issue.read_only_attribute_names # => ['due_date', '2']
519 # issue.read_only_attribute_names # => ['due_date', '2']
522 # issue.read_only_attribute_names(user) # => []
520 # issue.read_only_attribute_names(user) # => []
523 def read_only_attribute_names(user=nil)
521 def read_only_attribute_names(user=nil)
524 workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'readonly'}.keys
522 workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'readonly'}.keys
525 end
523 end
526
524
527 # Returns the names of required attributes for user or the current user
525 # Returns the names of required attributes for user or the current user
528 # For users with multiple roles, the required fields are the intersection of
526 # For users with multiple roles, the required fields are the intersection of
529 # required fields of each role
527 # required fields of each role
530 # The result is an array of strings where sustom fields are represented with their ids
528 # The result is an array of strings where sustom fields are represented with their ids
531 #
529 #
532 # Examples:
530 # Examples:
533 # issue.required_attribute_names # => ['due_date', '2']
531 # issue.required_attribute_names # => ['due_date', '2']
534 # issue.required_attribute_names(user) # => []
532 # issue.required_attribute_names(user) # => []
535 def required_attribute_names(user=nil)
533 def required_attribute_names(user=nil)
536 workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'required'}.keys
534 workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'required'}.keys
537 end
535 end
538
536
539 # Returns true if the attribute is required for user
537 # Returns true if the attribute is required for user
540 def required_attribute?(name, user=nil)
538 def required_attribute?(name, user=nil)
541 required_attribute_names(user).include?(name.to_s)
539 required_attribute_names(user).include?(name.to_s)
542 end
540 end
543
541
544 # Returns a hash of the workflow rule by attribute for the given user
542 # Returns a hash of the workflow rule by attribute for the given user
545 #
543 #
546 # Examples:
544 # Examples:
547 # issue.workflow_rule_by_attribute # => {'due_date' => 'required', 'start_date' => 'readonly'}
545 # issue.workflow_rule_by_attribute # => {'due_date' => 'required', 'start_date' => 'readonly'}
548 def workflow_rule_by_attribute(user=nil)
546 def workflow_rule_by_attribute(user=nil)
549 return @workflow_rule_by_attribute if @workflow_rule_by_attribute && user.nil?
547 return @workflow_rule_by_attribute if @workflow_rule_by_attribute && user.nil?
550
548
551 user_real = user || User.current
549 user_real = user || User.current
552 roles = user_real.admin ? Role.all.to_a : user_real.roles_for_project(project)
550 roles = user_real.admin ? Role.all.to_a : user_real.roles_for_project(project)
553 roles = roles.select(&:consider_workflow?)
551 roles = roles.select(&:consider_workflow?)
554 return {} if roles.empty?
552 return {} if roles.empty?
555
553
556 result = {}
554 result = {}
557 workflow_permissions = WorkflowPermission.where(:tracker_id => tracker_id, :old_status_id => status_id, :role_id => roles.map(&:id)).to_a
555 workflow_permissions = WorkflowPermission.where(:tracker_id => tracker_id, :old_status_id => status_id, :role_id => roles.map(&:id)).to_a
558 if workflow_permissions.any?
556 if workflow_permissions.any?
559 workflow_rules = workflow_permissions.inject({}) do |h, wp|
557 workflow_rules = workflow_permissions.inject({}) do |h, wp|
560 h[wp.field_name] ||= {}
558 h[wp.field_name] ||= {}
561 h[wp.field_name][wp.role_id] = wp.rule
559 h[wp.field_name][wp.role_id] = wp.rule
562 h
560 h
563 end
561 end
564 fields_with_roles = {}
562 fields_with_roles = {}
565 IssueCustomField.where(:visible => false).joins(:roles).pluck(:id, "role_id").each do |field_id, role_id|
563 IssueCustomField.where(:visible => false).joins(:roles).pluck(:id, "role_id").each do |field_id, role_id|
566 fields_with_roles[field_id] ||= []
564 fields_with_roles[field_id] ||= []
567 fields_with_roles[field_id] << role_id
565 fields_with_roles[field_id] << role_id
568 end
566 end
569 roles.each do |role|
567 roles.each do |role|
570 fields_with_roles.each do |field_id, role_ids|
568 fields_with_roles.each do |field_id, role_ids|
571 unless role_ids.include?(role.id)
569 unless role_ids.include?(role.id)
572 field_name = field_id.to_s
570 field_name = field_id.to_s
573 workflow_rules[field_name] ||= {}
571 workflow_rules[field_name] ||= {}
574 workflow_rules[field_name][role.id] = 'readonly'
572 workflow_rules[field_name][role.id] = 'readonly'
575 end
573 end
576 end
574 end
577 end
575 end
578 workflow_rules.each do |attr, rules|
576 workflow_rules.each do |attr, rules|
579 next if rules.size < roles.size
577 next if rules.size < roles.size
580 uniq_rules = rules.values.uniq
578 uniq_rules = rules.values.uniq
581 if uniq_rules.size == 1
579 if uniq_rules.size == 1
582 result[attr] = uniq_rules.first
580 result[attr] = uniq_rules.first
583 else
581 else
584 result[attr] = 'required'
582 result[attr] = 'required'
585 end
583 end
586 end
584 end
587 end
585 end
588 @workflow_rule_by_attribute = result if user.nil?
586 @workflow_rule_by_attribute = result if user.nil?
589 result
587 result
590 end
588 end
591 private :workflow_rule_by_attribute
589 private :workflow_rule_by_attribute
592
590
593 def done_ratio
591 def done_ratio
594 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
592 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
595 status.default_done_ratio
593 status.default_done_ratio
596 else
594 else
597 read_attribute(:done_ratio)
595 read_attribute(:done_ratio)
598 end
596 end
599 end
597 end
600
598
601 def self.use_status_for_done_ratio?
599 def self.use_status_for_done_ratio?
602 Setting.issue_done_ratio == 'issue_status'
600 Setting.issue_done_ratio == 'issue_status'
603 end
601 end
604
602
605 def self.use_field_for_done_ratio?
603 def self.use_field_for_done_ratio?
606 Setting.issue_done_ratio == 'issue_field'
604 Setting.issue_done_ratio == 'issue_field'
607 end
605 end
608
606
609 def validate_issue
607 def validate_issue
610 if due_date && start_date && (start_date_changed? || due_date_changed?) && due_date < start_date
608 if due_date && start_date && (start_date_changed? || due_date_changed?) && due_date < start_date
611 errors.add :due_date, :greater_than_start_date
609 errors.add :due_date, :greater_than_start_date
612 end
610 end
613
611
614 if start_date && start_date_changed? && soonest_start && start_date < soonest_start
612 if start_date && start_date_changed? && soonest_start && start_date < soonest_start
615 errors.add :start_date, :earlier_than_minimum_start_date, :date => format_date(soonest_start)
613 errors.add :start_date, :earlier_than_minimum_start_date, :date => format_date(soonest_start)
616 end
614 end
617
615
618 if fixed_version
616 if fixed_version
619 if !assignable_versions.include?(fixed_version)
617 if !assignable_versions.include?(fixed_version)
620 errors.add :fixed_version_id, :inclusion
618 errors.add :fixed_version_id, :inclusion
621 elsif reopening? && fixed_version.closed?
619 elsif reopening? && fixed_version.closed?
622 errors.add :base, I18n.t(:error_can_not_reopen_issue_on_closed_version)
620 errors.add :base, I18n.t(:error_can_not_reopen_issue_on_closed_version)
623 end
621 end
624 end
622 end
625
623
626 # Checks that the issue can not be added/moved to a disabled tracker
624 # Checks that the issue can not be added/moved to a disabled tracker
627 if project && (tracker_id_changed? || project_id_changed?)
625 if project && (tracker_id_changed? || project_id_changed?)
628 unless project.trackers.include?(tracker)
626 unless project.trackers.include?(tracker)
629 errors.add :tracker_id, :inclusion
627 errors.add :tracker_id, :inclusion
630 end
628 end
631 end
629 end
632
630
633 # Checks parent issue assignment
631 # Checks parent issue assignment
634 if @invalid_parent_issue_id.present?
632 if @invalid_parent_issue_id.present?
635 errors.add :parent_issue_id, :invalid
633 errors.add :parent_issue_id, :invalid
636 elsif @parent_issue
634 elsif @parent_issue
637 if !valid_parent_project?(@parent_issue)
635 if !valid_parent_project?(@parent_issue)
638 errors.add :parent_issue_id, :invalid
636 errors.add :parent_issue_id, :invalid
639 elsif (@parent_issue != parent) && (all_dependent_issues.include?(@parent_issue) || @parent_issue.all_dependent_issues.include?(self))
637 elsif (@parent_issue != parent) && (all_dependent_issues.include?(@parent_issue) || @parent_issue.all_dependent_issues.include?(self))
640 errors.add :parent_issue_id, :invalid
638 errors.add :parent_issue_id, :invalid
641 elsif !new_record?
639 elsif !new_record?
642 # moving an existing issue
640 # moving an existing issue
643 if move_possible?(@parent_issue)
641 if move_possible?(@parent_issue)
644 # move accepted
642 # move accepted
645 else
643 else
646 errors.add :parent_issue_id, :invalid
644 errors.add :parent_issue_id, :invalid
647 end
645 end
648 end
646 end
649 end
647 end
650 end
648 end
651
649
652 # Validates the issue against additional workflow requirements
650 # Validates the issue against additional workflow requirements
653 def validate_required_fields
651 def validate_required_fields
654 user = new_record? ? author : current_journal.try(:user)
652 user = new_record? ? author : current_journal.try(:user)
655
653
656 required_attribute_names(user).each do |attribute|
654 required_attribute_names(user).each do |attribute|
657 if attribute =~ /^\d+$/
655 if attribute =~ /^\d+$/
658 attribute = attribute.to_i
656 attribute = attribute.to_i
659 v = custom_field_values.detect {|v| v.custom_field_id == attribute }
657 v = custom_field_values.detect {|v| v.custom_field_id == attribute }
660 if v && v.value.blank?
658 if v && v.value.blank?
661 errors.add :base, v.custom_field.name + ' ' + l('activerecord.errors.messages.blank')
659 errors.add :base, v.custom_field.name + ' ' + l('activerecord.errors.messages.blank')
662 end
660 end
663 else
661 else
664 if respond_to?(attribute) && send(attribute).blank? && !disabled_core_fields.include?(attribute)
662 if respond_to?(attribute) && send(attribute).blank? && !disabled_core_fields.include?(attribute)
665 errors.add attribute, :blank
663 errors.add attribute, :blank
666 end
664 end
667 end
665 end
668 end
666 end
669 end
667 end
670
668
671 # Overrides Redmine::Acts::Customizable::InstanceMethods#validate_custom_field_values
669 # Overrides Redmine::Acts::Customizable::InstanceMethods#validate_custom_field_values
672 # so that custom values that are not editable are not validated (eg. a custom field that
670 # so that custom values that are not editable are not validated (eg. a custom field that
673 # is marked as required should not trigger a validation error if the user is not allowed
671 # is marked as required should not trigger a validation error if the user is not allowed
674 # to edit this field).
672 # to edit this field).
675 def validate_custom_field_values
673 def validate_custom_field_values
676 user = new_record? ? author : current_journal.try(:user)
674 user = new_record? ? author : current_journal.try(:user)
677 if new_record? || custom_field_values_changed?
675 if new_record? || custom_field_values_changed?
678 editable_custom_field_values(user).each(&:validate_value)
676 editable_custom_field_values(user).each(&:validate_value)
679 end
677 end
680 end
678 end
681
679
682 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
680 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
683 # even if the user turns off the setting later
681 # even if the user turns off the setting later
684 def update_done_ratio_from_issue_status
682 def update_done_ratio_from_issue_status
685 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
683 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
686 self.done_ratio = status.default_done_ratio
684 self.done_ratio = status.default_done_ratio
687 end
685 end
688 end
686 end
689
687
690 def init_journal(user, notes = "")
688 def init_journal(user, notes = "")
691 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
689 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
692 end
690 end
693
691
694 # Returns the current journal or nil if it's not initialized
692 # Returns the current journal or nil if it's not initialized
695 def current_journal
693 def current_journal
696 @current_journal
694 @current_journal
697 end
695 end
698
696
699 # Returns the names of attributes that are journalized when updating the issue
697 # Returns the names of attributes that are journalized when updating the issue
700 def journalized_attribute_names
698 def journalized_attribute_names
701 names = Issue.column_names - %w(id root_id lft rgt lock_version created_on updated_on closed_on)
699 names = Issue.column_names - %w(id root_id lft rgt lock_version created_on updated_on closed_on)
702 if tracker
700 if tracker
703 names -= tracker.disabled_core_fields
701 names -= tracker.disabled_core_fields
704 end
702 end
705 names
703 names
706 end
704 end
707
705
708 # Returns the id of the last journal or nil
706 # Returns the id of the last journal or nil
709 def last_journal_id
707 def last_journal_id
710 if new_record?
708 if new_record?
711 nil
709 nil
712 else
710 else
713 journals.maximum(:id)
711 journals.maximum(:id)
714 end
712 end
715 end
713 end
716
714
717 # Returns a scope for journals that have an id greater than journal_id
715 # Returns a scope for journals that have an id greater than journal_id
718 def journals_after(journal_id)
716 def journals_after(journal_id)
719 scope = journals.reorder("#{Journal.table_name}.id ASC")
717 scope = journals.reorder("#{Journal.table_name}.id ASC")
720 if journal_id.present?
718 if journal_id.present?
721 scope = scope.where("#{Journal.table_name}.id > ?", journal_id.to_i)
719 scope = scope.where("#{Journal.table_name}.id > ?", journal_id.to_i)
722 end
720 end
723 scope
721 scope
724 end
722 end
725
723
726 # Returns the initial status of the issue
724 # Returns the initial status of the issue
727 # Returns nil for a new issue
725 # Returns nil for a new issue
728 def status_was
726 def status_was
729 if status_id_changed?
727 if status_id_changed?
730 if status_id_was.to_i > 0
728 if status_id_was.to_i > 0
731 @status_was ||= IssueStatus.find_by_id(status_id_was)
729 @status_was ||= IssueStatus.find_by_id(status_id_was)
732 end
730 end
733 else
731 else
734 @status_was ||= status
732 @status_was ||= status
735 end
733 end
736 end
734 end
737
735
738 # Return true if the issue is closed, otherwise false
736 # Return true if the issue is closed, otherwise false
739 def closed?
737 def closed?
740 status.present? && status.is_closed?
738 status.present? && status.is_closed?
741 end
739 end
742
740
743 # Returns true if the issue was closed when loaded
741 # Returns true if the issue was closed when loaded
744 def was_closed?
742 def was_closed?
745 status_was.present? && status_was.is_closed?
743 status_was.present? && status_was.is_closed?
746 end
744 end
747
745
748 # Return true if the issue is being reopened
746 # Return true if the issue is being reopened
749 def reopening?
747 def reopening?
750 if new_record?
748 if new_record?
751 false
749 false
752 else
750 else
753 status_id_changed? && !closed? && was_closed?
751 status_id_changed? && !closed? && was_closed?
754 end
752 end
755 end
753 end
756 alias :reopened? :reopening?
754 alias :reopened? :reopening?
757
755
758 # Return true if the issue is being closed
756 # Return true if the issue is being closed
759 def closing?
757 def closing?
760 if new_record?
758 if new_record?
761 closed?
759 closed?
762 else
760 else
763 status_id_changed? && closed? && !was_closed?
761 status_id_changed? && closed? && !was_closed?
764 end
762 end
765 end
763 end
766
764
767 # Returns true if the issue is overdue
765 # Returns true if the issue is overdue
768 def overdue?
766 def overdue?
769 due_date.present? && (due_date < Date.today) && !closed?
767 due_date.present? && (due_date < Date.today) && !closed?
770 end
768 end
771
769
772 # Is the amount of work done less than it should for the due date
770 # Is the amount of work done less than it should for the due date
773 def behind_schedule?
771 def behind_schedule?
774 return false if start_date.nil? || due_date.nil?
772 return false if start_date.nil? || due_date.nil?
775 done_date = start_date + ((due_date - start_date + 1) * done_ratio / 100).floor
773 done_date = start_date + ((due_date - start_date + 1) * done_ratio / 100).floor
776 return done_date <= Date.today
774 return done_date <= Date.today
777 end
775 end
778
776
779 # Does this issue have children?
777 # Does this issue have children?
780 def children?
778 def children?
781 !leaf?
779 !leaf?
782 end
780 end
783
781
784 # Users the issue can be assigned to
782 # Users the issue can be assigned to
785 def assignable_users
783 def assignable_users
786 users = project.assignable_users.to_a
784 users = project.assignable_users.to_a
787 users << author if author
785 users << author if author
788 users << assigned_to if assigned_to
786 users << assigned_to if assigned_to
789 users.uniq.sort
787 users.uniq.sort
790 end
788 end
791
789
792 # Versions that the issue can be assigned to
790 # Versions that the issue can be assigned to
793 def assignable_versions
791 def assignable_versions
794 return @assignable_versions if @assignable_versions
792 return @assignable_versions if @assignable_versions
795
793
796 versions = project.shared_versions.open.to_a
794 versions = project.shared_versions.open.to_a
797 if fixed_version
795 if fixed_version
798 if fixed_version_id_changed?
796 if fixed_version_id_changed?
799 # nothing to do
797 # nothing to do
800 elsif project_id_changed?
798 elsif project_id_changed?
801 if project.shared_versions.include?(fixed_version)
799 if project.shared_versions.include?(fixed_version)
802 versions << fixed_version
800 versions << fixed_version
803 end
801 end
804 else
802 else
805 versions << fixed_version
803 versions << fixed_version
806 end
804 end
807 end
805 end
808 @assignable_versions = versions.uniq.sort
806 @assignable_versions = versions.uniq.sort
809 end
807 end
810
808
811 # Returns true if this issue is blocked by another issue that is still open
809 # Returns true if this issue is blocked by another issue that is still open
812 def blocked?
810 def blocked?
813 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
811 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
814 end
812 end
815
813
816 # Returns the default status of the issue based on its tracker
814 # Returns the default status of the issue based on its tracker
817 # Returns nil if tracker is nil
815 # Returns nil if tracker is nil
818 def default_status
816 def default_status
819 tracker.try(:default_status)
817 tracker.try(:default_status)
820 end
818 end
821
819
822 # Returns an array of statuses that user is able to apply
820 # Returns an array of statuses that user is able to apply
823 def new_statuses_allowed_to(user=User.current, include_default=false)
821 def new_statuses_allowed_to(user=User.current, include_default=false)
824 if new_record? && @copied_from
822 if new_record? && @copied_from
825 [default_status, @copied_from.status].compact.uniq.sort
823 [default_status, @copied_from.status].compact.uniq.sort
826 else
824 else
827 initial_status = nil
825 initial_status = nil
828 if new_record?
826 if new_record?
829 initial_status = default_status
827 initial_status = default_status
830 elsif tracker_id_changed?
828 elsif tracker_id_changed?
831 if Tracker.where(:id => tracker_id_was, :default_status_id => status_id_was).any?
829 if Tracker.where(:id => tracker_id_was, :default_status_id => status_id_was).any?
832 initial_status = default_status
830 initial_status = default_status
833 elsif tracker.issue_status_ids.include?(status_id_was)
831 elsif tracker.issue_status_ids.include?(status_id_was)
834 initial_status = IssueStatus.find_by_id(status_id_was)
832 initial_status = IssueStatus.find_by_id(status_id_was)
835 else
833 else
836 initial_status = default_status
834 initial_status = default_status
837 end
835 end
838 else
836 else
839 initial_status = status_was
837 initial_status = status_was
840 end
838 end
841
839
842 initial_assigned_to_id = assigned_to_id_changed? ? assigned_to_id_was : assigned_to_id
840 initial_assigned_to_id = assigned_to_id_changed? ? assigned_to_id_was : assigned_to_id
843 assignee_transitions_allowed = initial_assigned_to_id.present? &&
841 assignee_transitions_allowed = initial_assigned_to_id.present? &&
844 (user.id == initial_assigned_to_id || user.group_ids.include?(initial_assigned_to_id))
842 (user.id == initial_assigned_to_id || user.group_ids.include?(initial_assigned_to_id))
845
843
846 statuses = []
844 statuses = []
847 if initial_status
845 if initial_status
848 statuses += initial_status.find_new_statuses_allowed_to(
846 statuses += initial_status.find_new_statuses_allowed_to(
849 user.admin ? Role.all.to_a : user.roles_for_project(project),
847 user.admin ? Role.all.to_a : user.roles_for_project(project),
850 tracker,
848 tracker,
851 author == user,
849 author == user,
852 assignee_transitions_allowed
850 assignee_transitions_allowed
853 )
851 )
854 end
852 end
855 statuses << initial_status unless statuses.empty?
853 statuses << initial_status unless statuses.empty?
856 statuses << default_status if include_default
854 statuses << default_status if include_default
857 statuses = statuses.compact.uniq.sort
855 statuses = statuses.compact.uniq.sort
858 if blocked?
856 if blocked?
859 statuses.reject!(&:is_closed?)
857 statuses.reject!(&:is_closed?)
860 end
858 end
861 statuses
859 statuses
862 end
860 end
863 end
861 end
864
862
865 # Returns the previous assignee (user or group) if changed
863 # Returns the previous assignee (user or group) if changed
866 def assigned_to_was
864 def assigned_to_was
867 # assigned_to_id_was is reset before after_save callbacks
865 # assigned_to_id_was is reset before after_save callbacks
868 user_id = @previous_assigned_to_id || assigned_to_id_was
866 user_id = @previous_assigned_to_id || assigned_to_id_was
869 if user_id && user_id != assigned_to_id
867 if user_id && user_id != assigned_to_id
870 @assigned_to_was ||= Principal.find_by_id(user_id)
868 @assigned_to_was ||= Principal.find_by_id(user_id)
871 end
869 end
872 end
870 end
873
871
874 # Returns the users that should be notified
872 # Returns the users that should be notified
875 def notified_users
873 def notified_users
876 notified = []
874 notified = []
877 # Author and assignee are always notified unless they have been
875 # Author and assignee are always notified unless they have been
878 # locked or don't want to be notified
876 # locked or don't want to be notified
879 notified << author if author
877 notified << author if author
880 if assigned_to
878 if assigned_to
881 notified += (assigned_to.is_a?(Group) ? assigned_to.users : [assigned_to])
879 notified += (assigned_to.is_a?(Group) ? assigned_to.users : [assigned_to])
882 end
880 end
883 if assigned_to_was
881 if assigned_to_was
884 notified += (assigned_to_was.is_a?(Group) ? assigned_to_was.users : [assigned_to_was])
882 notified += (assigned_to_was.is_a?(Group) ? assigned_to_was.users : [assigned_to_was])
885 end
883 end
886 notified = notified.select {|u| u.active? && u.notify_about?(self)}
884 notified = notified.select {|u| u.active? && u.notify_about?(self)}
887
885
888 notified += project.notified_users
886 notified += project.notified_users
889 notified.uniq!
887 notified.uniq!
890 # Remove users that can not view the issue
888 # Remove users that can not view the issue
891 notified.reject! {|user| !visible?(user)}
889 notified.reject! {|user| !visible?(user)}
892 notified
890 notified
893 end
891 end
894
892
895 # Returns the email addresses that should be notified
893 # Returns the email addresses that should be notified
896 def recipients
894 def recipients
897 notified_users.collect(&:mail)
895 notified_users.collect(&:mail)
898 end
896 end
899
897
900 def each_notification(users, &block)
898 def each_notification(users, &block)
901 if users.any?
899 if users.any?
902 if custom_field_values.detect {|value| !value.custom_field.visible?}
900 if custom_field_values.detect {|value| !value.custom_field.visible?}
903 users_by_custom_field_visibility = users.group_by do |user|
901 users_by_custom_field_visibility = users.group_by do |user|
904 visible_custom_field_values(user).map(&:custom_field_id).sort
902 visible_custom_field_values(user).map(&:custom_field_id).sort
905 end
903 end
906 users_by_custom_field_visibility.values.each do |users|
904 users_by_custom_field_visibility.values.each do |users|
907 yield(users)
905 yield(users)
908 end
906 end
909 else
907 else
910 yield(users)
908 yield(users)
911 end
909 end
912 end
910 end
913 end
911 end
914
912
915 # Returns the number of hours spent on this issue
913 # Returns the number of hours spent on this issue
916 def spent_hours
914 def spent_hours
917 @spent_hours ||= time_entries.sum(:hours) || 0
915 @spent_hours ||= time_entries.sum(:hours) || 0
918 end
916 end
919
917
920 # Returns the total number of hours spent on this issue and its descendants
918 # Returns the total number of hours spent on this issue and its descendants
921 #
919 #
922 # Example:
920 # Example:
923 # spent_hours => 0.0
921 # spent_hours => 0.0
924 # spent_hours => 50.2
922 # spent_hours => 50.2
925 def total_spent_hours
923 def total_spent_hours
926 @total_spent_hours ||=
924 @total_spent_hours ||=
927 self_and_descendants.
925 self_and_descendants.
928 joins("LEFT JOIN #{TimeEntry.table_name} ON #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id").
926 joins("LEFT JOIN #{TimeEntry.table_name} ON #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id").
929 sum("#{TimeEntry.table_name}.hours").to_f || 0.0
927 sum("#{TimeEntry.table_name}.hours").to_f || 0.0
930 end
928 end
931
929
930 def total_estimated_hours
931 if leaf?
932 estimated_hours
933 else
934 @total_estimated_hours ||= self_and_descendants.sum(:estimated_hours)
935 end
936 end
937
932 def relations
938 def relations
933 @relations ||= IssueRelation::Relations.new(self, (relations_from + relations_to).sort)
939 @relations ||= IssueRelation::Relations.new(self, (relations_from + relations_to).sort)
934 end
940 end
935
941
936 # Preloads relations for a collection of issues
942 # Preloads relations for a collection of issues
937 def self.load_relations(issues)
943 def self.load_relations(issues)
938 if issues.any?
944 if issues.any?
939 relations = IssueRelation.where("issue_from_id IN (:ids) OR issue_to_id IN (:ids)", :ids => issues.map(&:id)).all
945 relations = IssueRelation.where("issue_from_id IN (:ids) OR issue_to_id IN (:ids)", :ids => issues.map(&:id)).all
940 issues.each do |issue|
946 issues.each do |issue|
941 issue.instance_variable_set "@relations", relations.select {|r| r.issue_from_id == issue.id || r.issue_to_id == issue.id}
947 issue.instance_variable_set "@relations", relations.select {|r| r.issue_from_id == issue.id || r.issue_to_id == issue.id}
942 end
948 end
943 end
949 end
944 end
950 end
945
951
946 # Preloads visible spent time for a collection of issues
952 # Preloads visible spent time for a collection of issues
947 def self.load_visible_spent_hours(issues, user=User.current)
953 def self.load_visible_spent_hours(issues, user=User.current)
948 if issues.any?
954 if issues.any?
949 hours_by_issue_id = TimeEntry.visible(user).group(:issue_id).sum(:hours)
955 hours_by_issue_id = TimeEntry.visible(user).group(:issue_id).sum(:hours)
950 issues.each do |issue|
956 issues.each do |issue|
951 issue.instance_variable_set "@spent_hours", (hours_by_issue_id[issue.id] || 0)
957 issue.instance_variable_set "@spent_hours", (hours_by_issue_id[issue.id] || 0)
952 end
958 end
953 end
959 end
954 end
960 end
955
961
956 # Preloads visible relations for a collection of issues
962 # Preloads visible relations for a collection of issues
957 def self.load_visible_relations(issues, user=User.current)
963 def self.load_visible_relations(issues, user=User.current)
958 if issues.any?
964 if issues.any?
959 issue_ids = issues.map(&:id)
965 issue_ids = issues.map(&:id)
960 # Relations with issue_from in given issues and visible issue_to
966 # Relations with issue_from in given issues and visible issue_to
961 relations_from = IssueRelation.joins(:issue_to => :project).
967 relations_from = IssueRelation.joins(:issue_to => :project).
962 where(visible_condition(user)).where(:issue_from_id => issue_ids).to_a
968 where(visible_condition(user)).where(:issue_from_id => issue_ids).to_a
963 # Relations with issue_to in given issues and visible issue_from
969 # Relations with issue_to in given issues and visible issue_from
964 relations_to = IssueRelation.joins(:issue_from => :project).
970 relations_to = IssueRelation.joins(:issue_from => :project).
965 where(visible_condition(user)).
971 where(visible_condition(user)).
966 where(:issue_to_id => issue_ids).to_a
972 where(:issue_to_id => issue_ids).to_a
967 issues.each do |issue|
973 issues.each do |issue|
968 relations =
974 relations =
969 relations_from.select {|relation| relation.issue_from_id == issue.id} +
975 relations_from.select {|relation| relation.issue_from_id == issue.id} +
970 relations_to.select {|relation| relation.issue_to_id == issue.id}
976 relations_to.select {|relation| relation.issue_to_id == issue.id}
971
977
972 issue.instance_variable_set "@relations", IssueRelation::Relations.new(issue, relations.sort)
978 issue.instance_variable_set "@relations", IssueRelation::Relations.new(issue, relations.sort)
973 end
979 end
974 end
980 end
975 end
981 end
976
982
977 # Finds an issue relation given its id.
983 # Finds an issue relation given its id.
978 def find_relation(relation_id)
984 def find_relation(relation_id)
979 IssueRelation.where("issue_to_id = ? OR issue_from_id = ?", id, id).find(relation_id)
985 IssueRelation.where("issue_to_id = ? OR issue_from_id = ?", id, id).find(relation_id)
980 end
986 end
981
987
982 # Returns all the other issues that depend on the issue
988 # Returns all the other issues that depend on the issue
983 # The algorithm is a modified breadth first search (bfs)
989 # The algorithm is a modified breadth first search (bfs)
984 def all_dependent_issues(except=[])
990 def all_dependent_issues(except=[])
985 # The found dependencies
991 # The found dependencies
986 dependencies = []
992 dependencies = []
987
993
988 # The visited flag for every node (issue) used by the breadth first search
994 # The visited flag for every node (issue) used by the breadth first search
989 eNOT_DISCOVERED = 0 # The issue is "new" to the algorithm, it has not seen it before.
995 eNOT_DISCOVERED = 0 # The issue is "new" to the algorithm, it has not seen it before.
990
996
991 ePROCESS_ALL = 1 # The issue is added to the queue. Process both children and relations of
997 ePROCESS_ALL = 1 # The issue is added to the queue. Process both children and relations of
992 # the issue when it is processed.
998 # the issue when it is processed.
993
999
994 ePROCESS_RELATIONS_ONLY = 2 # The issue was added to the queue and will be output as dependent issue,
1000 ePROCESS_RELATIONS_ONLY = 2 # The issue was added to the queue and will be output as dependent issue,
995 # but its children will not be added to the queue when it is processed.
1001 # but its children will not be added to the queue when it is processed.
996
1002
997 eRELATIONS_PROCESSED = 3 # The related issues, the parent issue and the issue itself have been added to
1003 eRELATIONS_PROCESSED = 3 # The related issues, the parent issue and the issue itself have been added to
998 # the queue, but its children have not been added.
1004 # the queue, but its children have not been added.
999
1005
1000 ePROCESS_CHILDREN_ONLY = 4 # The relations and the parent of the issue have been added to the queue, but
1006 ePROCESS_CHILDREN_ONLY = 4 # The relations and the parent of the issue have been added to the queue, but
1001 # the children still need to be processed.
1007 # the children still need to be processed.
1002
1008
1003 eALL_PROCESSED = 5 # The issue and all its children, its parent and its related issues have been
1009 eALL_PROCESSED = 5 # The issue and all its children, its parent and its related issues have been
1004 # added as dependent issues. It needs no further processing.
1010 # added as dependent issues. It needs no further processing.
1005
1011
1006 issue_status = Hash.new(eNOT_DISCOVERED)
1012 issue_status = Hash.new(eNOT_DISCOVERED)
1007
1013
1008 # The queue
1014 # The queue
1009 queue = []
1015 queue = []
1010
1016
1011 # Initialize the bfs, add start node (self) to the queue
1017 # Initialize the bfs, add start node (self) to the queue
1012 queue << self
1018 queue << self
1013 issue_status[self] = ePROCESS_ALL
1019 issue_status[self] = ePROCESS_ALL
1014
1020
1015 while (!queue.empty?) do
1021 while (!queue.empty?) do
1016 current_issue = queue.shift
1022 current_issue = queue.shift
1017 current_issue_status = issue_status[current_issue]
1023 current_issue_status = issue_status[current_issue]
1018 dependencies << current_issue
1024 dependencies << current_issue
1019
1025
1020 # Add parent to queue, if not already in it.
1026 # Add parent to queue, if not already in it.
1021 parent = current_issue.parent
1027 parent = current_issue.parent
1022 parent_status = issue_status[parent]
1028 parent_status = issue_status[parent]
1023
1029
1024 if parent && (parent_status == eNOT_DISCOVERED) && !except.include?(parent)
1030 if parent && (parent_status == eNOT_DISCOVERED) && !except.include?(parent)
1025 queue << parent
1031 queue << parent
1026 issue_status[parent] = ePROCESS_RELATIONS_ONLY
1032 issue_status[parent] = ePROCESS_RELATIONS_ONLY
1027 end
1033 end
1028
1034
1029 # Add children to queue, but only if they are not already in it and
1035 # Add children to queue, but only if they are not already in it and
1030 # the children of the current node need to be processed.
1036 # the children of the current node need to be processed.
1031 if (current_issue_status == ePROCESS_CHILDREN_ONLY || current_issue_status == ePROCESS_ALL)
1037 if (current_issue_status == ePROCESS_CHILDREN_ONLY || current_issue_status == ePROCESS_ALL)
1032 current_issue.children.each do |child|
1038 current_issue.children.each do |child|
1033 next if except.include?(child)
1039 next if except.include?(child)
1034
1040
1035 if (issue_status[child] == eNOT_DISCOVERED)
1041 if (issue_status[child] == eNOT_DISCOVERED)
1036 queue << child
1042 queue << child
1037 issue_status[child] = ePROCESS_ALL
1043 issue_status[child] = ePROCESS_ALL
1038 elsif (issue_status[child] == eRELATIONS_PROCESSED)
1044 elsif (issue_status[child] == eRELATIONS_PROCESSED)
1039 queue << child
1045 queue << child
1040 issue_status[child] = ePROCESS_CHILDREN_ONLY
1046 issue_status[child] = ePROCESS_CHILDREN_ONLY
1041 elsif (issue_status[child] == ePROCESS_RELATIONS_ONLY)
1047 elsif (issue_status[child] == ePROCESS_RELATIONS_ONLY)
1042 queue << child
1048 queue << child
1043 issue_status[child] = ePROCESS_ALL
1049 issue_status[child] = ePROCESS_ALL
1044 end
1050 end
1045 end
1051 end
1046 end
1052 end
1047
1053
1048 # Add related issues to the queue, if they are not already in it.
1054 # Add related issues to the queue, if they are not already in it.
1049 current_issue.relations_from.map(&:issue_to).each do |related_issue|
1055 current_issue.relations_from.map(&:issue_to).each do |related_issue|
1050 next if except.include?(related_issue)
1056 next if except.include?(related_issue)
1051
1057
1052 if (issue_status[related_issue] == eNOT_DISCOVERED)
1058 if (issue_status[related_issue] == eNOT_DISCOVERED)
1053 queue << related_issue
1059 queue << related_issue
1054 issue_status[related_issue] = ePROCESS_ALL
1060 issue_status[related_issue] = ePROCESS_ALL
1055 elsif (issue_status[related_issue] == eRELATIONS_PROCESSED)
1061 elsif (issue_status[related_issue] == eRELATIONS_PROCESSED)
1056 queue << related_issue
1062 queue << related_issue
1057 issue_status[related_issue] = ePROCESS_CHILDREN_ONLY
1063 issue_status[related_issue] = ePROCESS_CHILDREN_ONLY
1058 elsif (issue_status[related_issue] == ePROCESS_RELATIONS_ONLY)
1064 elsif (issue_status[related_issue] == ePROCESS_RELATIONS_ONLY)
1059 queue << related_issue
1065 queue << related_issue
1060 issue_status[related_issue] = ePROCESS_ALL
1066 issue_status[related_issue] = ePROCESS_ALL
1061 end
1067 end
1062 end
1068 end
1063
1069
1064 # Set new status for current issue
1070 # Set new status for current issue
1065 if (current_issue_status == ePROCESS_ALL) || (current_issue_status == ePROCESS_CHILDREN_ONLY)
1071 if (current_issue_status == ePROCESS_ALL) || (current_issue_status == ePROCESS_CHILDREN_ONLY)
1066 issue_status[current_issue] = eALL_PROCESSED
1072 issue_status[current_issue] = eALL_PROCESSED
1067 elsif (current_issue_status == ePROCESS_RELATIONS_ONLY)
1073 elsif (current_issue_status == ePROCESS_RELATIONS_ONLY)
1068 issue_status[current_issue] = eRELATIONS_PROCESSED
1074 issue_status[current_issue] = eRELATIONS_PROCESSED
1069 end
1075 end
1070 end # while
1076 end # while
1071
1077
1072 # Remove the issues from the "except" parameter from the result array
1078 # Remove the issues from the "except" parameter from the result array
1073 dependencies -= except
1079 dependencies -= except
1074 dependencies.delete(self)
1080 dependencies.delete(self)
1075
1081
1076 dependencies
1082 dependencies
1077 end
1083 end
1078
1084
1079 # Returns an array of issues that duplicate this one
1085 # Returns an array of issues that duplicate this one
1080 def duplicates
1086 def duplicates
1081 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
1087 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
1082 end
1088 end
1083
1089
1084 # Returns the due date or the target due date if any
1090 # Returns the due date or the target due date if any
1085 # Used on gantt chart
1091 # Used on gantt chart
1086 def due_before
1092 def due_before
1087 due_date || (fixed_version ? fixed_version.effective_date : nil)
1093 due_date || (fixed_version ? fixed_version.effective_date : nil)
1088 end
1094 end
1089
1095
1090 # Returns the time scheduled for this issue.
1096 # Returns the time scheduled for this issue.
1091 #
1097 #
1092 # Example:
1098 # Example:
1093 # Start Date: 2/26/09, End Date: 3/04/09
1099 # Start Date: 2/26/09, End Date: 3/04/09
1094 # duration => 6
1100 # duration => 6
1095 def duration
1101 def duration
1096 (start_date && due_date) ? due_date - start_date : 0
1102 (start_date && due_date) ? due_date - start_date : 0
1097 end
1103 end
1098
1104
1099 # Returns the duration in working days
1105 # Returns the duration in working days
1100 def working_duration
1106 def working_duration
1101 (start_date && due_date) ? working_days(start_date, due_date) : 0
1107 (start_date && due_date) ? working_days(start_date, due_date) : 0
1102 end
1108 end
1103
1109
1104 def soonest_start(reload=false)
1110 def soonest_start(reload=false)
1105 if @soonest_start.nil? || reload
1111 if @soonest_start.nil? || reload
1106 dates = relations_to(reload).collect{|relation| relation.successor_soonest_start}
1112 dates = relations_to(reload).collect{|relation| relation.successor_soonest_start}
1107 p = @parent_issue || parent
1113 p = @parent_issue || parent
1108 if p && Setting.parent_issue_dates == 'derived'
1114 if p && Setting.parent_issue_dates == 'derived'
1109 dates << p.soonest_start
1115 dates << p.soonest_start
1110 end
1116 end
1111 @soonest_start = dates.compact.max
1117 @soonest_start = dates.compact.max
1112 end
1118 end
1113 @soonest_start
1119 @soonest_start
1114 end
1120 end
1115
1121
1116 # Sets start_date on the given date or the next working day
1122 # Sets start_date on the given date or the next working day
1117 # and changes due_date to keep the same working duration.
1123 # and changes due_date to keep the same working duration.
1118 def reschedule_on(date)
1124 def reschedule_on(date)
1119 wd = working_duration
1125 wd = working_duration
1120 date = next_working_date(date)
1126 date = next_working_date(date)
1121 self.start_date = date
1127 self.start_date = date
1122 self.due_date = add_working_days(date, wd)
1128 self.due_date = add_working_days(date, wd)
1123 end
1129 end
1124
1130
1125 # Reschedules the issue on the given date or the next working day and saves the record.
1131 # Reschedules the issue on the given date or the next working day and saves the record.
1126 # If the issue is a parent task, this is done by rescheduling its subtasks.
1132 # If the issue is a parent task, this is done by rescheduling its subtasks.
1127 def reschedule_on!(date)
1133 def reschedule_on!(date)
1128 return if date.nil?
1134 return if date.nil?
1129 if leaf? || !dates_derived?
1135 if leaf? || !dates_derived?
1130 if start_date.nil? || start_date != date
1136 if start_date.nil? || start_date != date
1131 if start_date && start_date > date
1137 if start_date && start_date > date
1132 # Issue can not be moved earlier than its soonest start date
1138 # Issue can not be moved earlier than its soonest start date
1133 date = [soonest_start(true), date].compact.max
1139 date = [soonest_start(true), date].compact.max
1134 end
1140 end
1135 reschedule_on(date)
1141 reschedule_on(date)
1136 begin
1142 begin
1137 save
1143 save
1138 rescue ActiveRecord::StaleObjectError
1144 rescue ActiveRecord::StaleObjectError
1139 reload
1145 reload
1140 reschedule_on(date)
1146 reschedule_on(date)
1141 save
1147 save
1142 end
1148 end
1143 end
1149 end
1144 else
1150 else
1145 leaves.each do |leaf|
1151 leaves.each do |leaf|
1146 if leaf.start_date
1152 if leaf.start_date
1147 # Only move subtask if it starts at the same date as the parent
1153 # Only move subtask if it starts at the same date as the parent
1148 # or if it starts before the given date
1154 # or if it starts before the given date
1149 if start_date == leaf.start_date || date > leaf.start_date
1155 if start_date == leaf.start_date || date > leaf.start_date
1150 leaf.reschedule_on!(date)
1156 leaf.reschedule_on!(date)
1151 end
1157 end
1152 else
1158 else
1153 leaf.reschedule_on!(date)
1159 leaf.reschedule_on!(date)
1154 end
1160 end
1155 end
1161 end
1156 end
1162 end
1157 end
1163 end
1158
1164
1159 def dates_derived?
1165 def dates_derived?
1160 !leaf? && Setting.parent_issue_dates == 'derived'
1166 !leaf? && Setting.parent_issue_dates == 'derived'
1161 end
1167 end
1162
1168
1163 def priority_derived?
1169 def priority_derived?
1164 !leaf? && Setting.parent_issue_priority == 'derived'
1170 !leaf? && Setting.parent_issue_priority == 'derived'
1165 end
1171 end
1166
1172
1167 def done_ratio_derived?
1173 def done_ratio_derived?
1168 !leaf? && Setting.parent_issue_done_ratio == 'derived'
1174 !leaf? && Setting.parent_issue_done_ratio == 'derived'
1169 end
1175 end
1170
1176
1171 def <=>(issue)
1177 def <=>(issue)
1172 if issue.nil?
1178 if issue.nil?
1173 -1
1179 -1
1174 elsif root_id != issue.root_id
1180 elsif root_id != issue.root_id
1175 (root_id || 0) <=> (issue.root_id || 0)
1181 (root_id || 0) <=> (issue.root_id || 0)
1176 else
1182 else
1177 (lft || 0) <=> (issue.lft || 0)
1183 (lft || 0) <=> (issue.lft || 0)
1178 end
1184 end
1179 end
1185 end
1180
1186
1181 def to_s
1187 def to_s
1182 "#{tracker} ##{id}: #{subject}"
1188 "#{tracker} ##{id}: #{subject}"
1183 end
1189 end
1184
1190
1185 # Returns a string of css classes that apply to the issue
1191 # Returns a string of css classes that apply to the issue
1186 def css_classes(user=User.current)
1192 def css_classes(user=User.current)
1187 s = "issue tracker-#{tracker_id} status-#{status_id} #{priority.try(:css_classes)}"
1193 s = "issue tracker-#{tracker_id} status-#{status_id} #{priority.try(:css_classes)}"
1188 s << ' closed' if closed?
1194 s << ' closed' if closed?
1189 s << ' overdue' if overdue?
1195 s << ' overdue' if overdue?
1190 s << ' child' if child?
1196 s << ' child' if child?
1191 s << ' parent' unless leaf?
1197 s << ' parent' unless leaf?
1192 s << ' private' if is_private?
1198 s << ' private' if is_private?
1193 if user.logged?
1199 if user.logged?
1194 s << ' created-by-me' if author_id == user.id
1200 s << ' created-by-me' if author_id == user.id
1195 s << ' assigned-to-me' if assigned_to_id == user.id
1201 s << ' assigned-to-me' if assigned_to_id == user.id
1196 s << ' assigned-to-my-group' if user.groups.any? {|g| g.id == assigned_to_id}
1202 s << ' assigned-to-my-group' if user.groups.any? {|g| g.id == assigned_to_id}
1197 end
1203 end
1198 s
1204 s
1199 end
1205 end
1200
1206
1201 # Unassigns issues from +version+ if it's no longer shared with issue's project
1207 # Unassigns issues from +version+ if it's no longer shared with issue's project
1202 def self.update_versions_from_sharing_change(version)
1208 def self.update_versions_from_sharing_change(version)
1203 # Update issues assigned to the version
1209 # Update issues assigned to the version
1204 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
1210 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
1205 end
1211 end
1206
1212
1207 # Unassigns issues from versions that are no longer shared
1213 # Unassigns issues from versions that are no longer shared
1208 # after +project+ was moved
1214 # after +project+ was moved
1209 def self.update_versions_from_hierarchy_change(project)
1215 def self.update_versions_from_hierarchy_change(project)
1210 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
1216 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
1211 # Update issues of the moved projects and issues assigned to a version of a moved project
1217 # Update issues of the moved projects and issues assigned to a version of a moved project
1212 Issue.update_versions(
1218 Issue.update_versions(
1213 ["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)",
1219 ["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)",
1214 moved_project_ids, moved_project_ids]
1220 moved_project_ids, moved_project_ids]
1215 )
1221 )
1216 end
1222 end
1217
1223
1218 def parent_issue_id=(arg)
1224 def parent_issue_id=(arg)
1219 s = arg.to_s.strip.presence
1225 s = arg.to_s.strip.presence
1220 if s && (m = s.match(%r{\A#?(\d+)\z})) && (@parent_issue = Issue.find_by_id(m[1]))
1226 if s && (m = s.match(%r{\A#?(\d+)\z})) && (@parent_issue = Issue.find_by_id(m[1]))
1221 @invalid_parent_issue_id = nil
1227 @invalid_parent_issue_id = nil
1222 elsif s.blank?
1228 elsif s.blank?
1223 @parent_issue = nil
1229 @parent_issue = nil
1224 @invalid_parent_issue_id = nil
1230 @invalid_parent_issue_id = nil
1225 else
1231 else
1226 @parent_issue = nil
1232 @parent_issue = nil
1227 @invalid_parent_issue_id = arg
1233 @invalid_parent_issue_id = arg
1228 end
1234 end
1229 end
1235 end
1230
1236
1231 def parent_issue_id
1237 def parent_issue_id
1232 if @invalid_parent_issue_id
1238 if @invalid_parent_issue_id
1233 @invalid_parent_issue_id
1239 @invalid_parent_issue_id
1234 elsif instance_variable_defined? :@parent_issue
1240 elsif instance_variable_defined? :@parent_issue
1235 @parent_issue.nil? ? nil : @parent_issue.id
1241 @parent_issue.nil? ? nil : @parent_issue.id
1236 else
1242 else
1237 parent_id
1243 parent_id
1238 end
1244 end
1239 end
1245 end
1240
1246
1241 def set_parent_id
1247 def set_parent_id
1242 self.parent_id = parent_issue_id
1248 self.parent_id = parent_issue_id
1243 end
1249 end
1244
1250
1245 # Returns true if issue's project is a valid
1251 # Returns true if issue's project is a valid
1246 # parent issue project
1252 # parent issue project
1247 def valid_parent_project?(issue=parent)
1253 def valid_parent_project?(issue=parent)
1248 return true if issue.nil? || issue.project_id == project_id
1254 return true if issue.nil? || issue.project_id == project_id
1249
1255
1250 case Setting.cross_project_subtasks
1256 case Setting.cross_project_subtasks
1251 when 'system'
1257 when 'system'
1252 true
1258 true
1253 when 'tree'
1259 when 'tree'
1254 issue.project.root == project.root
1260 issue.project.root == project.root
1255 when 'hierarchy'
1261 when 'hierarchy'
1256 issue.project.is_or_is_ancestor_of?(project) || issue.project.is_descendant_of?(project)
1262 issue.project.is_or_is_ancestor_of?(project) || issue.project.is_descendant_of?(project)
1257 when 'descendants'
1263 when 'descendants'
1258 issue.project.is_or_is_ancestor_of?(project)
1264 issue.project.is_or_is_ancestor_of?(project)
1259 else
1265 else
1260 false
1266 false
1261 end
1267 end
1262 end
1268 end
1263
1269
1264 # Returns an issue scope based on project and scope
1270 # Returns an issue scope based on project and scope
1265 def self.cross_project_scope(project, scope=nil)
1271 def self.cross_project_scope(project, scope=nil)
1266 if project.nil?
1272 if project.nil?
1267 return Issue
1273 return Issue
1268 end
1274 end
1269 case scope
1275 case scope
1270 when 'all', 'system'
1276 when 'all', 'system'
1271 Issue
1277 Issue
1272 when 'tree'
1278 when 'tree'
1273 Issue.joins(:project).where("(#{Project.table_name}.lft >= :lft AND #{Project.table_name}.rgt <= :rgt)",
1279 Issue.joins(:project).where("(#{Project.table_name}.lft >= :lft AND #{Project.table_name}.rgt <= :rgt)",
1274 :lft => project.root.lft, :rgt => project.root.rgt)
1280 :lft => project.root.lft, :rgt => project.root.rgt)
1275 when 'hierarchy'
1281 when 'hierarchy'
1276 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)",
1282 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)",
1277 :lft => project.lft, :rgt => project.rgt)
1283 :lft => project.lft, :rgt => project.rgt)
1278 when 'descendants'
1284 when 'descendants'
1279 Issue.joins(:project).where("(#{Project.table_name}.lft >= :lft AND #{Project.table_name}.rgt <= :rgt)",
1285 Issue.joins(:project).where("(#{Project.table_name}.lft >= :lft AND #{Project.table_name}.rgt <= :rgt)",
1280 :lft => project.lft, :rgt => project.rgt)
1286 :lft => project.lft, :rgt => project.rgt)
1281 else
1287 else
1282 Issue.where(:project_id => project.id)
1288 Issue.where(:project_id => project.id)
1283 end
1289 end
1284 end
1290 end
1285
1291
1286 def self.by_tracker(project)
1292 def self.by_tracker(project)
1287 count_and_group_by(:project => project, :association => :tracker)
1293 count_and_group_by(:project => project, :association => :tracker)
1288 end
1294 end
1289
1295
1290 def self.by_version(project)
1296 def self.by_version(project)
1291 count_and_group_by(:project => project, :association => :fixed_version)
1297 count_and_group_by(:project => project, :association => :fixed_version)
1292 end
1298 end
1293
1299
1294 def self.by_priority(project)
1300 def self.by_priority(project)
1295 count_and_group_by(:project => project, :association => :priority)
1301 count_and_group_by(:project => project, :association => :priority)
1296 end
1302 end
1297
1303
1298 def self.by_category(project)
1304 def self.by_category(project)
1299 count_and_group_by(:project => project, :association => :category)
1305 count_and_group_by(:project => project, :association => :category)
1300 end
1306 end
1301
1307
1302 def self.by_assigned_to(project)
1308 def self.by_assigned_to(project)
1303 count_and_group_by(:project => project, :association => :assigned_to)
1309 count_and_group_by(:project => project, :association => :assigned_to)
1304 end
1310 end
1305
1311
1306 def self.by_author(project)
1312 def self.by_author(project)
1307 count_and_group_by(:project => project, :association => :author)
1313 count_and_group_by(:project => project, :association => :author)
1308 end
1314 end
1309
1315
1310 def self.by_subproject(project)
1316 def self.by_subproject(project)
1311 r = count_and_group_by(:project => project, :with_subprojects => true, :association => :project)
1317 r = count_and_group_by(:project => project, :with_subprojects => true, :association => :project)
1312 r.reject {|r| r["project_id"] == project.id.to_s}
1318 r.reject {|r| r["project_id"] == project.id.to_s}
1313 end
1319 end
1314
1320
1315 # Query generator for selecting groups of issue counts for a project
1321 # Query generator for selecting groups of issue counts for a project
1316 # based on specific criteria
1322 # based on specific criteria
1317 #
1323 #
1318 # Options
1324 # Options
1319 # * project - Project to search in.
1325 # * project - Project to search in.
1320 # * with_subprojects - Includes subprojects issues if set to true.
1326 # * with_subprojects - Includes subprojects issues if set to true.
1321 # * association - Symbol. Association for grouping.
1327 # * association - Symbol. Association for grouping.
1322 def self.count_and_group_by(options)
1328 def self.count_and_group_by(options)
1323 assoc = reflect_on_association(options[:association])
1329 assoc = reflect_on_association(options[:association])
1324 select_field = assoc.foreign_key
1330 select_field = assoc.foreign_key
1325
1331
1326 Issue.
1332 Issue.
1327 visible(User.current, :project => options[:project], :with_subprojects => options[:with_subprojects]).
1333 visible(User.current, :project => options[:project], :with_subprojects => options[:with_subprojects]).
1328 joins(:status, assoc.name).
1334 joins(:status, assoc.name).
1329 group(:status_id, :is_closed, select_field).
1335 group(:status_id, :is_closed, select_field).
1330 count.
1336 count.
1331 map do |columns, total|
1337 map do |columns, total|
1332 status_id, is_closed, field_value = columns
1338 status_id, is_closed, field_value = columns
1333 is_closed = ['t', 'true', '1'].include?(is_closed.to_s)
1339 is_closed = ['t', 'true', '1'].include?(is_closed.to_s)
1334 {
1340 {
1335 "status_id" => status_id.to_s,
1341 "status_id" => status_id.to_s,
1336 "closed" => is_closed,
1342 "closed" => is_closed,
1337 select_field => field_value.to_s,
1343 select_field => field_value.to_s,
1338 "total" => total.to_s
1344 "total" => total.to_s
1339 }
1345 }
1340 end
1346 end
1341 end
1347 end
1342
1348
1343 # Returns a scope of projects that user can assign the issue to
1349 # Returns a scope of projects that user can assign the issue to
1344 def allowed_target_projects(user=User.current)
1350 def allowed_target_projects(user=User.current)
1345 current_project = new_record? ? nil : project
1351 current_project = new_record? ? nil : project
1346 self.class.allowed_target_projects(user, current_project)
1352 self.class.allowed_target_projects(user, current_project)
1347 end
1353 end
1348
1354
1349 # Returns a scope of projects that user can assign issues to
1355 # Returns a scope of projects that user can assign issues to
1350 # If current_project is given, it will be included in the scope
1356 # If current_project is given, it will be included in the scope
1351 def self.allowed_target_projects(user=User.current, current_project=nil)
1357 def self.allowed_target_projects(user=User.current, current_project=nil)
1352 condition = Project.allowed_to_condition(user, :add_issues)
1358 condition = Project.allowed_to_condition(user, :add_issues)
1353 if current_project
1359 if current_project
1354 condition = ["(#{condition}) OR #{Project.table_name}.id = ?", current_project.id]
1360 condition = ["(#{condition}) OR #{Project.table_name}.id = ?", current_project.id]
1355 end
1361 end
1356 Project.where(condition)
1362 Project.where(condition)
1357 end
1363 end
1358
1364
1359 private
1365 private
1360
1366
1361 def after_project_change
1367 def after_project_change
1362 # Update project_id on related time entries
1368 # Update project_id on related time entries
1363 TimeEntry.where({:issue_id => id}).update_all(["project_id = ?", project_id])
1369 TimeEntry.where({:issue_id => id}).update_all(["project_id = ?", project_id])
1364
1370
1365 # Delete issue relations
1371 # Delete issue relations
1366 unless Setting.cross_project_issue_relations?
1372 unless Setting.cross_project_issue_relations?
1367 relations_from.clear
1373 relations_from.clear
1368 relations_to.clear
1374 relations_to.clear
1369 end
1375 end
1370
1376
1371 # Move subtasks that were in the same project
1377 # Move subtasks that were in the same project
1372 children.each do |child|
1378 children.each do |child|
1373 next unless child.project_id == project_id_was
1379 next unless child.project_id == project_id_was
1374 # Change project and keep project
1380 # Change project and keep project
1375 child.send :project=, project, true
1381 child.send :project=, project, true
1376 unless child.save
1382 unless child.save
1377 raise ActiveRecord::Rollback
1383 raise ActiveRecord::Rollback
1378 end
1384 end
1379 end
1385 end
1380 end
1386 end
1381
1387
1382 # Callback for after the creation of an issue by copy
1388 # Callback for after the creation of an issue by copy
1383 # * adds a "copied to" relation with the copied issue
1389 # * adds a "copied to" relation with the copied issue
1384 # * copies subtasks from the copied issue
1390 # * copies subtasks from the copied issue
1385 def after_create_from_copy
1391 def after_create_from_copy
1386 return unless copy? && !@after_create_from_copy_handled
1392 return unless copy? && !@after_create_from_copy_handled
1387
1393
1388 if (@copied_from.project_id == project_id || Setting.cross_project_issue_relations?) && @copy_options[:link] != false
1394 if (@copied_from.project_id == project_id || Setting.cross_project_issue_relations?) && @copy_options[:link] != false
1389 if @current_journal
1395 if @current_journal
1390 @copied_from.init_journal(@current_journal.user)
1396 @copied_from.init_journal(@current_journal.user)
1391 end
1397 end
1392 relation = IssueRelation.new(:issue_from => @copied_from, :issue_to => self, :relation_type => IssueRelation::TYPE_COPIED_TO)
1398 relation = IssueRelation.new(:issue_from => @copied_from, :issue_to => self, :relation_type => IssueRelation::TYPE_COPIED_TO)
1393 unless relation.save
1399 unless relation.save
1394 logger.error "Could not create relation while copying ##{@copied_from.id} to ##{id} due to validation errors: #{relation.errors.full_messages.join(', ')}" if logger
1400 logger.error "Could not create relation while copying ##{@copied_from.id} to ##{id} due to validation errors: #{relation.errors.full_messages.join(', ')}" if logger
1395 end
1401 end
1396 end
1402 end
1397
1403
1398 unless @copied_from.leaf? || @copy_options[:subtasks] == false
1404 unless @copied_from.leaf? || @copy_options[:subtasks] == false
1399 copy_options = (@copy_options || {}).merge(:subtasks => false)
1405 copy_options = (@copy_options || {}).merge(:subtasks => false)
1400 copied_issue_ids = {@copied_from.id => self.id}
1406 copied_issue_ids = {@copied_from.id => self.id}
1401 @copied_from.reload.descendants.reorder("#{Issue.table_name}.lft").each do |child|
1407 @copied_from.reload.descendants.reorder("#{Issue.table_name}.lft").each do |child|
1402 # Do not copy self when copying an issue as a descendant of the copied issue
1408 # Do not copy self when copying an issue as a descendant of the copied issue
1403 next if child == self
1409 next if child == self
1404 # Do not copy subtasks of issues that were not copied
1410 # Do not copy subtasks of issues that were not copied
1405 next unless copied_issue_ids[child.parent_id]
1411 next unless copied_issue_ids[child.parent_id]
1406 # Do not copy subtasks that are not visible to avoid potential disclosure of private data
1412 # Do not copy subtasks that are not visible to avoid potential disclosure of private data
1407 unless child.visible?
1413 unless child.visible?
1408 logger.error "Subtask ##{child.id} was not copied during ##{@copied_from.id} copy because it is not visible to the current user" if logger
1414 logger.error "Subtask ##{child.id} was not copied during ##{@copied_from.id} copy because it is not visible to the current user" if logger
1409 next
1415 next
1410 end
1416 end
1411 copy = Issue.new.copy_from(child, copy_options)
1417 copy = Issue.new.copy_from(child, copy_options)
1412 if @current_journal
1418 if @current_journal
1413 copy.init_journal(@current_journal.user)
1419 copy.init_journal(@current_journal.user)
1414 end
1420 end
1415 copy.author = author
1421 copy.author = author
1416 copy.project = project
1422 copy.project = project
1417 copy.parent_issue_id = copied_issue_ids[child.parent_id]
1423 copy.parent_issue_id = copied_issue_ids[child.parent_id]
1418 unless copy.save
1424 unless copy.save
1419 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
1425 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
1420 next
1426 next
1421 end
1427 end
1422 copied_issue_ids[child.id] = copy.id
1428 copied_issue_ids[child.id] = copy.id
1423 end
1429 end
1424 end
1430 end
1425 @after_create_from_copy_handled = true
1431 @after_create_from_copy_handled = true
1426 end
1432 end
1427
1433
1428 def update_nested_set_attributes
1434 def update_nested_set_attributes
1429 if parent_id_changed?
1435 if parent_id_changed?
1430 update_nested_set_attributes_on_parent_change
1436 update_nested_set_attributes_on_parent_change
1431 end
1437 end
1432 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
1438 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
1433 end
1439 end
1434
1440
1435 # Updates the nested set for when an existing issue is moved
1441 # Updates the nested set for when an existing issue is moved
1436 def update_nested_set_attributes_on_parent_change
1442 def update_nested_set_attributes_on_parent_change
1437 former_parent_id = parent_id_was
1443 former_parent_id = parent_id_was
1438 # delete invalid relations of all descendants
1444 # delete invalid relations of all descendants
1439 self_and_descendants.each do |issue|
1445 self_and_descendants.each do |issue|
1440 issue.relations.each do |relation|
1446 issue.relations.each do |relation|
1441 relation.destroy unless relation.valid?
1447 relation.destroy unless relation.valid?
1442 end
1448 end
1443 end
1449 end
1444 # update former parent
1450 # update former parent
1445 recalculate_attributes_for(former_parent_id) if former_parent_id
1451 recalculate_attributes_for(former_parent_id) if former_parent_id
1446 end
1452 end
1447
1453
1448 def update_parent_attributes
1454 def update_parent_attributes
1449 if parent_id
1455 if parent_id
1450 recalculate_attributes_for(parent_id)
1456 recalculate_attributes_for(parent_id)
1451 association(:parent).reset
1457 association(:parent).reset
1452 end
1458 end
1453 end
1459 end
1454
1460
1455 def recalculate_attributes_for(issue_id)
1461 def recalculate_attributes_for(issue_id)
1456 if issue_id && p = Issue.find_by_id(issue_id)
1462 if issue_id && p = Issue.find_by_id(issue_id)
1457 if p.priority_derived?
1463 if p.priority_derived?
1458 # priority = highest priority of children
1464 # priority = highest priority of children
1459 if priority_position = p.children.joins(:priority).maximum("#{IssuePriority.table_name}.position")
1465 if priority_position = p.children.joins(:priority).maximum("#{IssuePriority.table_name}.position")
1460 p.priority = IssuePriority.find_by_position(priority_position)
1466 p.priority = IssuePriority.find_by_position(priority_position)
1461 end
1467 end
1462 end
1468 end
1463
1469
1464 if p.dates_derived?
1470 if p.dates_derived?
1465 # start/due dates = lowest/highest dates of children
1471 # start/due dates = lowest/highest dates of children
1466 p.start_date = p.children.minimum(:start_date)
1472 p.start_date = p.children.minimum(:start_date)
1467 p.due_date = p.children.maximum(:due_date)
1473 p.due_date = p.children.maximum(:due_date)
1468 if p.start_date && p.due_date && p.due_date < p.start_date
1474 if p.start_date && p.due_date && p.due_date < p.start_date
1469 p.start_date, p.due_date = p.due_date, p.start_date
1475 p.start_date, p.due_date = p.due_date, p.start_date
1470 end
1476 end
1471 end
1477 end
1472
1478
1473 if p.done_ratio_derived?
1479 if p.done_ratio_derived?
1474 # done ratio = weighted average ratio of leaves
1480 # done ratio = weighted average ratio of leaves
1475 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
1481 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
1476 leaves_count = p.leaves.count
1482 leaves_count = p.leaves.count
1477 if leaves_count > 0
1483 if leaves_count > 0
1478 average = p.leaves.where("estimated_hours > 0").average(:estimated_hours).to_f
1484 average = p.leaves.where("estimated_hours > 0").average(:estimated_hours).to_f
1479 if average == 0
1485 if average == 0
1480 average = 1
1486 average = 1
1481 end
1487 end
1482 done = p.leaves.joins(:status).
1488 done = p.leaves.joins(:status).
1483 sum("COALESCE(CASE WHEN estimated_hours > 0 THEN estimated_hours ELSE NULL END, #{average}) " +
1489 sum("COALESCE(CASE WHEN estimated_hours > 0 THEN estimated_hours ELSE NULL END, #{average}) " +
1484 "* (CASE WHEN is_closed = #{self.class.connection.quoted_true} THEN 100 ELSE COALESCE(done_ratio, 0) END)").to_f
1490 "* (CASE WHEN is_closed = #{self.class.connection.quoted_true} THEN 100 ELSE COALESCE(done_ratio, 0) END)").to_f
1485 progress = done / (average * leaves_count)
1491 progress = done / (average * leaves_count)
1486 p.done_ratio = progress.round
1492 p.done_ratio = progress.round
1487 end
1493 end
1488 end
1494 end
1489 end
1495 end
1490
1496
1491 # estimate = sum of leaves estimates
1492 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
1493 p.estimated_hours = nil if p.estimated_hours == 0.0
1494
1495 # ancestors will be recursively updated
1497 # ancestors will be recursively updated
1496 p.save(:validate => false)
1498 p.save(:validate => false)
1497 end
1499 end
1498 end
1500 end
1499
1501
1500 # Update issues so their versions are not pointing to a
1502 # Update issues so their versions are not pointing to a
1501 # fixed_version that is not shared with the issue's project
1503 # fixed_version that is not shared with the issue's project
1502 def self.update_versions(conditions=nil)
1504 def self.update_versions(conditions=nil)
1503 # Only need to update issues with a fixed_version from
1505 # Only need to update issues with a fixed_version from
1504 # a different project and that is not systemwide shared
1506 # a different project and that is not systemwide shared
1505 Issue.joins(:project, :fixed_version).
1507 Issue.joins(:project, :fixed_version).
1506 where("#{Issue.table_name}.fixed_version_id IS NOT NULL" +
1508 where("#{Issue.table_name}.fixed_version_id IS NOT NULL" +
1507 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
1509 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
1508 " AND #{Version.table_name}.sharing <> 'system'").
1510 " AND #{Version.table_name}.sharing <> 'system'").
1509 where(conditions).each do |issue|
1511 where(conditions).each do |issue|
1510 next if issue.project.nil? || issue.fixed_version.nil?
1512 next if issue.project.nil? || issue.fixed_version.nil?
1511 unless issue.project.shared_versions.include?(issue.fixed_version)
1513 unless issue.project.shared_versions.include?(issue.fixed_version)
1512 issue.init_journal(User.current)
1514 issue.init_journal(User.current)
1513 issue.fixed_version = nil
1515 issue.fixed_version = nil
1514 issue.save
1516 issue.save
1515 end
1517 end
1516 end
1518 end
1517 end
1519 end
1518
1520
1519 # Callback on file attachment
1521 # Callback on file attachment
1520 def attachment_added(attachment)
1522 def attachment_added(attachment)
1521 if current_journal && !attachment.new_record?
1523 if current_journal && !attachment.new_record?
1522 current_journal.journalize_attachment(attachment, :added)
1524 current_journal.journalize_attachment(attachment, :added)
1523 end
1525 end
1524 end
1526 end
1525
1527
1526 # Callback on attachment deletion
1528 # Callback on attachment deletion
1527 def attachment_removed(attachment)
1529 def attachment_removed(attachment)
1528 if current_journal && !attachment.new_record?
1530 if current_journal && !attachment.new_record?
1529 current_journal.journalize_attachment(attachment, :removed)
1531 current_journal.journalize_attachment(attachment, :removed)
1530 current_journal.save
1532 current_journal.save
1531 end
1533 end
1532 end
1534 end
1533
1535
1534 # Called after a relation is added
1536 # Called after a relation is added
1535 def relation_added(relation)
1537 def relation_added(relation)
1536 if current_journal
1538 if current_journal
1537 current_journal.journalize_relation(relation, :added)
1539 current_journal.journalize_relation(relation, :added)
1538 current_journal.save
1540 current_journal.save
1539 end
1541 end
1540 end
1542 end
1541
1543
1542 # Called after a relation is removed
1544 # Called after a relation is removed
1543 def relation_removed(relation)
1545 def relation_removed(relation)
1544 if current_journal
1546 if current_journal
1545 current_journal.journalize_relation(relation, :removed)
1547 current_journal.journalize_relation(relation, :removed)
1546 current_journal.save
1548 current_journal.save
1547 end
1549 end
1548 end
1550 end
1549
1551
1550 # Default assignment based on category
1552 # Default assignment based on category
1551 def default_assign
1553 def default_assign
1552 if assigned_to.nil? && category && category.assigned_to
1554 if assigned_to.nil? && category && category.assigned_to
1553 self.assigned_to = category.assigned_to
1555 self.assigned_to = category.assigned_to
1554 end
1556 end
1555 end
1557 end
1556
1558
1557 # Updates start/due dates of following issues
1559 # Updates start/due dates of following issues
1558 def reschedule_following_issues
1560 def reschedule_following_issues
1559 if start_date_changed? || due_date_changed?
1561 if start_date_changed? || due_date_changed?
1560 relations_from.each do |relation|
1562 relations_from.each do |relation|
1561 relation.set_issue_to_dates
1563 relation.set_issue_to_dates
1562 end
1564 end
1563 end
1565 end
1564 end
1566 end
1565
1567
1566 # Closes duplicates if the issue is being closed
1568 # Closes duplicates if the issue is being closed
1567 def close_duplicates
1569 def close_duplicates
1568 if closing?
1570 if closing?
1569 duplicates.each do |duplicate|
1571 duplicates.each do |duplicate|
1570 # Reload is needed in case the duplicate was updated by a previous duplicate
1572 # Reload is needed in case the duplicate was updated by a previous duplicate
1571 duplicate.reload
1573 duplicate.reload
1572 # Don't re-close it if it's already closed
1574 # Don't re-close it if it's already closed
1573 next if duplicate.closed?
1575 next if duplicate.closed?
1574 # Same user and notes
1576 # Same user and notes
1575 if @current_journal
1577 if @current_journal
1576 duplicate.init_journal(@current_journal.user, @current_journal.notes)
1578 duplicate.init_journal(@current_journal.user, @current_journal.notes)
1577 end
1579 end
1578 duplicate.update_attribute :status, self.status
1580 duplicate.update_attribute :status, self.status
1579 end
1581 end
1580 end
1582 end
1581 end
1583 end
1582
1584
1583 # Make sure updated_on is updated when adding a note and set updated_on now
1585 # Make sure updated_on is updated when adding a note and set updated_on now
1584 # so we can set closed_on with the same value on closing
1586 # so we can set closed_on with the same value on closing
1585 def force_updated_on_change
1587 def force_updated_on_change
1586 if @current_journal || changed?
1588 if @current_journal || changed?
1587 self.updated_on = current_time_from_proper_timezone
1589 self.updated_on = current_time_from_proper_timezone
1588 if new_record?
1590 if new_record?
1589 self.created_on = updated_on
1591 self.created_on = updated_on
1590 end
1592 end
1591 end
1593 end
1592 end
1594 end
1593
1595
1594 # Callback for setting closed_on when the issue is closed.
1596 # Callback for setting closed_on when the issue is closed.
1595 # The closed_on attribute stores the time of the last closing
1597 # The closed_on attribute stores the time of the last closing
1596 # and is preserved when the issue is reopened.
1598 # and is preserved when the issue is reopened.
1597 def update_closed_on
1599 def update_closed_on
1598 if closing?
1600 if closing?
1599 self.closed_on = updated_on
1601 self.closed_on = updated_on
1600 end
1602 end
1601 end
1603 end
1602
1604
1603 # Saves the changes in a Journal
1605 # Saves the changes in a Journal
1604 # Called after_save
1606 # Called after_save
1605 def create_journal
1607 def create_journal
1606 if current_journal
1608 if current_journal
1607 current_journal.save
1609 current_journal.save
1608 end
1610 end
1609 end
1611 end
1610
1612
1611 def send_notification
1613 def send_notification
1612 if Setting.notified_events.include?('issue_added')
1614 if Setting.notified_events.include?('issue_added')
1613 Mailer.deliver_issue_add(self)
1615 Mailer.deliver_issue_add(self)
1614 end
1616 end
1615 end
1617 end
1616
1618
1617 # Stores the previous assignee so we can still have access
1619 # Stores the previous assignee so we can still have access
1618 # to it during after_save callbacks (assigned_to_id_was is reset)
1620 # to it during after_save callbacks (assigned_to_id_was is reset)
1619 def set_assigned_to_was
1621 def set_assigned_to_was
1620 @previous_assigned_to_id = assigned_to_id_was
1622 @previous_assigned_to_id = assigned_to_id_was
1621 end
1623 end
1622
1624
1623 # Clears the previous assignee at the end of after_save callbacks
1625 # Clears the previous assignee at the end of after_save callbacks
1624 def clear_assigned_to_was
1626 def clear_assigned_to_was
1625 @assigned_to_was = nil
1627 @assigned_to_was = nil
1626 @previous_assigned_to_id = nil
1628 @previous_assigned_to_id = nil
1627 end
1629 end
1628
1630
1629 def clear_disabled_fields
1631 def clear_disabled_fields
1630 if tracker
1632 if tracker
1631 tracker.disabled_core_fields.each do |attribute|
1633 tracker.disabled_core_fields.each do |attribute|
1632 send "#{attribute}=", nil
1634 send "#{attribute}=", nil
1633 end
1635 end
1634 self.done_ratio ||= 0
1636 self.done_ratio ||= 0
1635 end
1637 end
1636 end
1638 end
1637 end
1639 end
@@ -1,291 +1,291
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2015 Jean-Philippe Lang
2 # Copyright (C) 2006-2015 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class Version < ActiveRecord::Base
18 class Version < ActiveRecord::Base
19 include Redmine::SafeAttributes
19 include Redmine::SafeAttributes
20 after_update :update_issues_from_sharing_change
20 after_update :update_issues_from_sharing_change
21 belongs_to :project
21 belongs_to :project
22 has_many :fixed_issues, :class_name => 'Issue', :foreign_key => 'fixed_version_id', :dependent => :nullify
22 has_many :fixed_issues, :class_name => 'Issue', :foreign_key => 'fixed_version_id', :dependent => :nullify
23 acts_as_customizable
23 acts_as_customizable
24 acts_as_attachable :view_permission => :view_files,
24 acts_as_attachable :view_permission => :view_files,
25 :edit_permission => :manage_files,
25 :edit_permission => :manage_files,
26 :delete_permission => :manage_files
26 :delete_permission => :manage_files
27
27
28 VERSION_STATUSES = %w(open locked closed)
28 VERSION_STATUSES = %w(open locked closed)
29 VERSION_SHARINGS = %w(none descendants hierarchy tree system)
29 VERSION_SHARINGS = %w(none descendants hierarchy tree system)
30
30
31 validates_presence_of :name
31 validates_presence_of :name
32 validates_uniqueness_of :name, :scope => [:project_id]
32 validates_uniqueness_of :name, :scope => [:project_id]
33 validates_length_of :name, :maximum => 60
33 validates_length_of :name, :maximum => 60
34 validates_length_of :description, :maximum => 255
34 validates_length_of :description, :maximum => 255
35 validates :effective_date, :date => true
35 validates :effective_date, :date => true
36 validates_inclusion_of :status, :in => VERSION_STATUSES
36 validates_inclusion_of :status, :in => VERSION_STATUSES
37 validates_inclusion_of :sharing, :in => VERSION_SHARINGS
37 validates_inclusion_of :sharing, :in => VERSION_SHARINGS
38 attr_protected :id
38 attr_protected :id
39
39
40 scope :named, lambda {|arg| where("LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip)}
40 scope :named, lambda {|arg| where("LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip)}
41 scope :open, lambda { where(:status => 'open') }
41 scope :open, lambda { where(:status => 'open') }
42 scope :visible, lambda {|*args|
42 scope :visible, lambda {|*args|
43 joins(:project).
43 joins(:project).
44 where(Project.allowed_to_condition(args.first || User.current, :view_issues))
44 where(Project.allowed_to_condition(args.first || User.current, :view_issues))
45 }
45 }
46
46
47 safe_attributes 'name',
47 safe_attributes 'name',
48 'description',
48 'description',
49 'effective_date',
49 'effective_date',
50 'due_date',
50 'due_date',
51 'wiki_page_title',
51 'wiki_page_title',
52 'status',
52 'status',
53 'sharing',
53 'sharing',
54 'custom_field_values',
54 'custom_field_values',
55 'custom_fields'
55 'custom_fields'
56
56
57 # Returns true if +user+ or current user is allowed to view the version
57 # Returns true if +user+ or current user is allowed to view the version
58 def visible?(user=User.current)
58 def visible?(user=User.current)
59 user.allowed_to?(:view_issues, self.project)
59 user.allowed_to?(:view_issues, self.project)
60 end
60 end
61
61
62 # Version files have same visibility as project files
62 # Version files have same visibility as project files
63 def attachments_visible?(*args)
63 def attachments_visible?(*args)
64 project.present? && project.attachments_visible?(*args)
64 project.present? && project.attachments_visible?(*args)
65 end
65 end
66
66
67 def attachments_deletable?(usr=User.current)
67 def attachments_deletable?(usr=User.current)
68 project.present? && project.attachments_deletable?(usr)
68 project.present? && project.attachments_deletable?(usr)
69 end
69 end
70
70
71 def start_date
71 def start_date
72 @start_date ||= fixed_issues.minimum('start_date')
72 @start_date ||= fixed_issues.minimum('start_date')
73 end
73 end
74
74
75 def due_date
75 def due_date
76 effective_date
76 effective_date
77 end
77 end
78
78
79 def due_date=(arg)
79 def due_date=(arg)
80 self.effective_date=(arg)
80 self.effective_date=(arg)
81 end
81 end
82
82
83 # Returns the total estimated time for this version
83 # Returns the total estimated time for this version
84 # (sum of leaves estimated_hours)
84 # (sum of leaves estimated_hours)
85 def estimated_hours
85 def estimated_hours
86 @estimated_hours ||= fixed_issues.leaves.sum(:estimated_hours).to_f
86 @estimated_hours ||= fixed_issues.sum(:estimated_hours).to_f
87 end
87 end
88
88
89 # Returns the total reported time for this version
89 # Returns the total reported time for this version
90 def spent_hours
90 def spent_hours
91 @spent_hours ||= TimeEntry.joins(:issue).where("#{Issue.table_name}.fixed_version_id = ?", id).sum(:hours).to_f
91 @spent_hours ||= TimeEntry.joins(:issue).where("#{Issue.table_name}.fixed_version_id = ?", id).sum(:hours).to_f
92 end
92 end
93
93
94 def closed?
94 def closed?
95 status == 'closed'
95 status == 'closed'
96 end
96 end
97
97
98 def open?
98 def open?
99 status == 'open'
99 status == 'open'
100 end
100 end
101
101
102 # Returns true if the version is completed: due date reached and no open issues
102 # Returns true if the version is completed: due date reached and no open issues
103 def completed?
103 def completed?
104 effective_date && (effective_date < Date.today) && (open_issues_count == 0)
104 effective_date && (effective_date < Date.today) && (open_issues_count == 0)
105 end
105 end
106
106
107 def behind_schedule?
107 def behind_schedule?
108 if completed_percent == 100
108 if completed_percent == 100
109 return false
109 return false
110 elsif due_date && start_date
110 elsif due_date && start_date
111 done_date = start_date + ((due_date - start_date+1)* completed_percent/100).floor
111 done_date = start_date + ((due_date - start_date+1)* completed_percent/100).floor
112 return done_date <= Date.today
112 return done_date <= Date.today
113 else
113 else
114 false # No issues so it's not late
114 false # No issues so it's not late
115 end
115 end
116 end
116 end
117
117
118 # Returns the completion percentage of this version based on the amount of open/closed issues
118 # Returns the completion percentage of this version based on the amount of open/closed issues
119 # and the time spent on the open issues.
119 # and the time spent on the open issues.
120 def completed_percent
120 def completed_percent
121 if issues_count == 0
121 if issues_count == 0
122 0
122 0
123 elsif open_issues_count == 0
123 elsif open_issues_count == 0
124 100
124 100
125 else
125 else
126 issues_progress(false) + issues_progress(true)
126 issues_progress(false) + issues_progress(true)
127 end
127 end
128 end
128 end
129
129
130 # Returns the percentage of issues that have been marked as 'closed'.
130 # Returns the percentage of issues that have been marked as 'closed'.
131 def closed_percent
131 def closed_percent
132 if issues_count == 0
132 if issues_count == 0
133 0
133 0
134 else
134 else
135 issues_progress(false)
135 issues_progress(false)
136 end
136 end
137 end
137 end
138
138
139 # Returns true if the version is overdue: due date reached and some open issues
139 # Returns true if the version is overdue: due date reached and some open issues
140 def overdue?
140 def overdue?
141 effective_date && (effective_date < Date.today) && (open_issues_count > 0)
141 effective_date && (effective_date < Date.today) && (open_issues_count > 0)
142 end
142 end
143
143
144 # Returns assigned issues count
144 # Returns assigned issues count
145 def issues_count
145 def issues_count
146 load_issue_counts
146 load_issue_counts
147 @issue_count
147 @issue_count
148 end
148 end
149
149
150 # Returns the total amount of open issues for this version.
150 # Returns the total amount of open issues for this version.
151 def open_issues_count
151 def open_issues_count
152 load_issue_counts
152 load_issue_counts
153 @open_issues_count
153 @open_issues_count
154 end
154 end
155
155
156 # Returns the total amount of closed issues for this version.
156 # Returns the total amount of closed issues for this version.
157 def closed_issues_count
157 def closed_issues_count
158 load_issue_counts
158 load_issue_counts
159 @closed_issues_count
159 @closed_issues_count
160 end
160 end
161
161
162 def wiki_page
162 def wiki_page
163 if project.wiki && !wiki_page_title.blank?
163 if project.wiki && !wiki_page_title.blank?
164 @wiki_page ||= project.wiki.find_page(wiki_page_title)
164 @wiki_page ||= project.wiki.find_page(wiki_page_title)
165 end
165 end
166 @wiki_page
166 @wiki_page
167 end
167 end
168
168
169 def to_s; name end
169 def to_s; name end
170
170
171 def to_s_with_project
171 def to_s_with_project
172 "#{project} - #{name}"
172 "#{project} - #{name}"
173 end
173 end
174
174
175 # Versions are sorted by effective_date and name
175 # Versions are sorted by effective_date and name
176 # Those with no effective_date are at the end, sorted by name
176 # Those with no effective_date are at the end, sorted by name
177 def <=>(version)
177 def <=>(version)
178 if self.effective_date
178 if self.effective_date
179 if version.effective_date
179 if version.effective_date
180 if self.effective_date == version.effective_date
180 if self.effective_date == version.effective_date
181 name == version.name ? id <=> version.id : name <=> version.name
181 name == version.name ? id <=> version.id : name <=> version.name
182 else
182 else
183 self.effective_date <=> version.effective_date
183 self.effective_date <=> version.effective_date
184 end
184 end
185 else
185 else
186 -1
186 -1
187 end
187 end
188 else
188 else
189 if version.effective_date
189 if version.effective_date
190 1
190 1
191 else
191 else
192 name == version.name ? id <=> version.id : name <=> version.name
192 name == version.name ? id <=> version.id : name <=> version.name
193 end
193 end
194 end
194 end
195 end
195 end
196
196
197 def self.fields_for_order_statement(table=nil)
197 def self.fields_for_order_statement(table=nil)
198 table ||= table_name
198 table ||= table_name
199 ["(CASE WHEN #{table}.effective_date IS NULL THEN 1 ELSE 0 END)", "#{table}.effective_date", "#{table}.name", "#{table}.id"]
199 ["(CASE WHEN #{table}.effective_date IS NULL THEN 1 ELSE 0 END)", "#{table}.effective_date", "#{table}.name", "#{table}.id"]
200 end
200 end
201
201
202 scope :sorted, lambda { order(fields_for_order_statement) }
202 scope :sorted, lambda { order(fields_for_order_statement) }
203
203
204 # Returns the sharings that +user+ can set the version to
204 # Returns the sharings that +user+ can set the version to
205 def allowed_sharings(user = User.current)
205 def allowed_sharings(user = User.current)
206 VERSION_SHARINGS.select do |s|
206 VERSION_SHARINGS.select do |s|
207 if sharing == s
207 if sharing == s
208 true
208 true
209 else
209 else
210 case s
210 case s
211 when 'system'
211 when 'system'
212 # Only admin users can set a systemwide sharing
212 # Only admin users can set a systemwide sharing
213 user.admin?
213 user.admin?
214 when 'hierarchy', 'tree'
214 when 'hierarchy', 'tree'
215 # Only users allowed to manage versions of the root project can
215 # Only users allowed to manage versions of the root project can
216 # set sharing to hierarchy or tree
216 # set sharing to hierarchy or tree
217 project.nil? || user.allowed_to?(:manage_versions, project.root)
217 project.nil? || user.allowed_to?(:manage_versions, project.root)
218 else
218 else
219 true
219 true
220 end
220 end
221 end
221 end
222 end
222 end
223 end
223 end
224
224
225 # Returns true if the version is shared, otherwise false
225 # Returns true if the version is shared, otherwise false
226 def shared?
226 def shared?
227 sharing != 'none'
227 sharing != 'none'
228 end
228 end
229
229
230 private
230 private
231
231
232 def load_issue_counts
232 def load_issue_counts
233 unless @issue_count
233 unless @issue_count
234 @open_issues_count = 0
234 @open_issues_count = 0
235 @closed_issues_count = 0
235 @closed_issues_count = 0
236 fixed_issues.group(:status).count.each do |status, count|
236 fixed_issues.group(:status).count.each do |status, count|
237 if status.is_closed?
237 if status.is_closed?
238 @closed_issues_count += count
238 @closed_issues_count += count
239 else
239 else
240 @open_issues_count += count
240 @open_issues_count += count
241 end
241 end
242 end
242 end
243 @issue_count = @open_issues_count + @closed_issues_count
243 @issue_count = @open_issues_count + @closed_issues_count
244 end
244 end
245 end
245 end
246
246
247 # Update the issue's fixed versions. Used if a version's sharing changes.
247 # Update the issue's fixed versions. Used if a version's sharing changes.
248 def update_issues_from_sharing_change
248 def update_issues_from_sharing_change
249 if sharing_changed?
249 if sharing_changed?
250 if VERSION_SHARINGS.index(sharing_was).nil? ||
250 if VERSION_SHARINGS.index(sharing_was).nil? ||
251 VERSION_SHARINGS.index(sharing).nil? ||
251 VERSION_SHARINGS.index(sharing).nil? ||
252 VERSION_SHARINGS.index(sharing_was) > VERSION_SHARINGS.index(sharing)
252 VERSION_SHARINGS.index(sharing_was) > VERSION_SHARINGS.index(sharing)
253 Issue.update_versions_from_sharing_change self
253 Issue.update_versions_from_sharing_change self
254 end
254 end
255 end
255 end
256 end
256 end
257
257
258 # Returns the average estimated time of assigned issues
258 # Returns the average estimated time of assigned issues
259 # or 1 if no issue has an estimated time
259 # or 1 if no issue has an estimated time
260 # Used to weight unestimated issues in progress calculation
260 # Used to weight unestimated issues in progress calculation
261 def estimated_average
261 def estimated_average
262 if @estimated_average.nil?
262 if @estimated_average.nil?
263 average = fixed_issues.average(:estimated_hours).to_f
263 average = fixed_issues.average(:estimated_hours).to_f
264 if average == 0
264 if average == 0
265 average = 1
265 average = 1
266 end
266 end
267 @estimated_average = average
267 @estimated_average = average
268 end
268 end
269 @estimated_average
269 @estimated_average
270 end
270 end
271
271
272 # Returns the total progress of open or closed issues. The returned percentage takes into account
272 # Returns the total progress of open or closed issues. The returned percentage takes into account
273 # the amount of estimated time set for this version.
273 # the amount of estimated time set for this version.
274 #
274 #
275 # Examples:
275 # Examples:
276 # issues_progress(true) => returns the progress percentage for open issues.
276 # issues_progress(true) => returns the progress percentage for open issues.
277 # issues_progress(false) => returns the progress percentage for closed issues.
277 # issues_progress(false) => returns the progress percentage for closed issues.
278 def issues_progress(open)
278 def issues_progress(open)
279 @issues_progress ||= {}
279 @issues_progress ||= {}
280 @issues_progress[open] ||= begin
280 @issues_progress[open] ||= begin
281 progress = 0
281 progress = 0
282 if issues_count > 0
282 if issues_count > 0
283 ratio = open ? 'done_ratio' : 100
283 ratio = open ? 'done_ratio' : 100
284
284
285 done = fixed_issues.open(open).sum("COALESCE(estimated_hours, #{estimated_average}) * #{ratio}").to_f
285 done = fixed_issues.open(open).sum("COALESCE(estimated_hours, #{estimated_average}) * #{ratio}").to_f
286 progress = done / (estimated_average * issues_count)
286 progress = done / (estimated_average * issues_count)
287 end
287 end
288 progress
288 progress
289 end
289 end
290 end
290 end
291 end
291 end
@@ -1,160 +1,160
1 <%= render :partial => 'action_menu' %>
1 <%= render :partial => 'action_menu' %>
2
2
3 <h2><%= issue_heading(@issue) %></h2>
3 <h2><%= issue_heading(@issue) %></h2>
4
4
5 <div class="<%= @issue.css_classes %> details">
5 <div class="<%= @issue.css_classes %> details">
6 <% if @prev_issue_id || @next_issue_id %>
6 <% if @prev_issue_id || @next_issue_id %>
7 <div class="next-prev-links contextual">
7 <div class="next-prev-links contextual">
8 <%= link_to_if @prev_issue_id,
8 <%= link_to_if @prev_issue_id,
9 "\xc2\xab #{l(:label_previous)}",
9 "\xc2\xab #{l(:label_previous)}",
10 (@prev_issue_id ? issue_path(@prev_issue_id) : nil),
10 (@prev_issue_id ? issue_path(@prev_issue_id) : nil),
11 :title => "##{@prev_issue_id}",
11 :title => "##{@prev_issue_id}",
12 :accesskey => accesskey(:previous) %> |
12 :accesskey => accesskey(:previous) %> |
13 <% if @issue_position && @issue_count %>
13 <% if @issue_position && @issue_count %>
14 <span class="position"><%= l(:label_item_position, :position => @issue_position, :count => @issue_count) %></span> |
14 <span class="position"><%= l(:label_item_position, :position => @issue_position, :count => @issue_count) %></span> |
15 <% end %>
15 <% end %>
16 <%= link_to_if @next_issue_id,
16 <%= link_to_if @next_issue_id,
17 "#{l(:label_next)} \xc2\xbb",
17 "#{l(:label_next)} \xc2\xbb",
18 (@next_issue_id ? issue_path(@next_issue_id) : nil),
18 (@next_issue_id ? issue_path(@next_issue_id) : nil),
19 :title => "##{@next_issue_id}",
19 :title => "##{@next_issue_id}",
20 :accesskey => accesskey(:next) %>
20 :accesskey => accesskey(:next) %>
21 </div>
21 </div>
22 <% end %>
22 <% end %>
23
23
24 <%= avatar(@issue.author, :size => "50") %>
24 <%= avatar(@issue.author, :size => "50") %>
25
25
26 <div class="subject">
26 <div class="subject">
27 <%= render_issue_subject_with_tree(@issue) %>
27 <%= render_issue_subject_with_tree(@issue) %>
28 </div>
28 </div>
29 <p class="author">
29 <p class="author">
30 <%= authoring @issue.created_on, @issue.author %>.
30 <%= authoring @issue.created_on, @issue.author %>.
31 <% if @issue.created_on != @issue.updated_on %>
31 <% if @issue.created_on != @issue.updated_on %>
32 <%= l(:label_updated_time, time_tag(@issue.updated_on)).html_safe %>.
32 <%= l(:label_updated_time, time_tag(@issue.updated_on)).html_safe %>.
33 <% end %>
33 <% end %>
34 </p>
34 </p>
35
35
36 <table class="attributes">
36 <table class="attributes">
37 <%= issue_fields_rows do |rows|
37 <%= issue_fields_rows do |rows|
38 rows.left l(:field_status), @issue.status.name, :class => 'status'
38 rows.left l(:field_status), @issue.status.name, :class => 'status'
39 rows.left l(:field_priority), @issue.priority.name, :class => 'priority'
39 rows.left l(:field_priority), @issue.priority.name, :class => 'priority'
40
40
41 unless @issue.disabled_core_fields.include?('assigned_to_id')
41 unless @issue.disabled_core_fields.include?('assigned_to_id')
42 rows.left l(:field_assigned_to), avatar(@issue.assigned_to, :size => "14").to_s.html_safe + (@issue.assigned_to ? link_to_user(@issue.assigned_to) : "-"), :class => 'assigned-to'
42 rows.left l(:field_assigned_to), avatar(@issue.assigned_to, :size => "14").to_s.html_safe + (@issue.assigned_to ? link_to_user(@issue.assigned_to) : "-"), :class => 'assigned-to'
43 end
43 end
44 unless @issue.disabled_core_fields.include?('category_id')
44 unless @issue.disabled_core_fields.include?('category_id')
45 rows.left l(:field_category), (@issue.category ? @issue.category.name : "-"), :class => 'category'
45 rows.left l(:field_category), (@issue.category ? @issue.category.name : "-"), :class => 'category'
46 end
46 end
47 unless @issue.disabled_core_fields.include?('fixed_version_id')
47 unless @issue.disabled_core_fields.include?('fixed_version_id')
48 rows.left l(:field_fixed_version), (@issue.fixed_version ? link_to_version(@issue.fixed_version) : "-"), :class => 'fixed-version'
48 rows.left l(:field_fixed_version), (@issue.fixed_version ? link_to_version(@issue.fixed_version) : "-"), :class => 'fixed-version'
49 end
49 end
50
50
51 unless @issue.disabled_core_fields.include?('start_date')
51 unless @issue.disabled_core_fields.include?('start_date')
52 rows.right l(:field_start_date), format_date(@issue.start_date), :class => 'start-date'
52 rows.right l(:field_start_date), format_date(@issue.start_date), :class => 'start-date'
53 end
53 end
54 unless @issue.disabled_core_fields.include?('due_date')
54 unless @issue.disabled_core_fields.include?('due_date')
55 rows.right l(:field_due_date), format_date(@issue.due_date), :class => 'due-date'
55 rows.right l(:field_due_date), format_date(@issue.due_date), :class => 'due-date'
56 end
56 end
57 unless @issue.disabled_core_fields.include?('done_ratio')
57 unless @issue.disabled_core_fields.include?('done_ratio')
58 rows.right l(:field_done_ratio), progress_bar(@issue.done_ratio, :width => '80px', :legend => "#{@issue.done_ratio}%"), :class => 'progress'
58 rows.right l(:field_done_ratio), progress_bar(@issue.done_ratio, :width => '80px', :legend => "#{@issue.done_ratio}%"), :class => 'progress'
59 end
59 end
60 unless @issue.disabled_core_fields.include?('estimated_hours')
60 unless @issue.disabled_core_fields.include?('estimated_hours')
61 unless @issue.estimated_hours.nil?
61 unless @issue.total_estimated_hours.nil?
62 rows.right l(:field_estimated_hours), l_hours(@issue.estimated_hours), :class => 'estimated-hours'
62 rows.right l(:field_estimated_hours), issue_estimated_hours_details(@issue), :class => 'estimated-hours'
63 end
63 end
64 end
64 end
65 if User.current.allowed_to?(:view_time_entries, @project)
65 if User.current.allowed_to?(:view_time_entries, @project)
66 rows.right l(:label_spent_time), (@issue.total_spent_hours > 0 ? link_to(l_hours(@issue.total_spent_hours), issue_time_entries_path(@issue)) : "-"), :class => 'spent-time'
66 rows.right l(:label_spent_time), (@issue.total_spent_hours > 0 ? link_to(l_hours(@issue.total_spent_hours), issue_time_entries_path(@issue)) : "-"), :class => 'spent-time'
67 end
67 end
68 end %>
68 end %>
69 <%= render_custom_fields_rows(@issue) %>
69 <%= render_custom_fields_rows(@issue) %>
70 <%= call_hook(:view_issues_show_details_bottom, :issue => @issue) %>
70 <%= call_hook(:view_issues_show_details_bottom, :issue => @issue) %>
71 </table>
71 </table>
72
72
73 <% if @issue.description? || @issue.attachments.any? -%>
73 <% if @issue.description? || @issue.attachments.any? -%>
74 <hr />
74 <hr />
75 <% if @issue.description? %>
75 <% if @issue.description? %>
76 <div class="description">
76 <div class="description">
77 <div class="contextual">
77 <div class="contextual">
78 <%= link_to l(:button_quote), quoted_issue_path(@issue), :remote => true, :method => 'post', :class => 'icon icon-comment' if authorize_for('issues', 'edit') %>
78 <%= link_to l(:button_quote), quoted_issue_path(@issue), :remote => true, :method => 'post', :class => 'icon icon-comment' if authorize_for('issues', 'edit') %>
79 </div>
79 </div>
80
80
81 <p><strong><%=l(:field_description)%></strong></p>
81 <p><strong><%=l(:field_description)%></strong></p>
82 <div class="wiki">
82 <div class="wiki">
83 <%= textilizable @issue, :description, :attachments => @issue.attachments %>
83 <%= textilizable @issue, :description, :attachments => @issue.attachments %>
84 </div>
84 </div>
85 </div>
85 </div>
86 <% end %>
86 <% end %>
87 <%= link_to_attachments @issue, :thumbnails => true %>
87 <%= link_to_attachments @issue, :thumbnails => true %>
88 <% end -%>
88 <% end -%>
89
89
90 <%= call_hook(:view_issues_show_description_bottom, :issue => @issue) %>
90 <%= call_hook(:view_issues_show_description_bottom, :issue => @issue) %>
91
91
92 <% if !@issue.leaf? || User.current.allowed_to?(:manage_subtasks, @project) %>
92 <% if !@issue.leaf? || User.current.allowed_to?(:manage_subtasks, @project) %>
93 <hr />
93 <hr />
94 <div id="issue_tree">
94 <div id="issue_tree">
95 <div class="contextual">
95 <div class="contextual">
96 <%= link_to_new_subtask(@issue) if User.current.allowed_to?(:manage_subtasks, @project) %>
96 <%= link_to_new_subtask(@issue) if User.current.allowed_to?(:manage_subtasks, @project) %>
97 </div>
97 </div>
98 <p><strong><%=l(:label_subtask_plural)%></strong></p>
98 <p><strong><%=l(:label_subtask_plural)%></strong></p>
99 <%= render_descendants_tree(@issue) unless @issue.leaf? %>
99 <%= render_descendants_tree(@issue) unless @issue.leaf? %>
100 </div>
100 </div>
101 <% end %>
101 <% end %>
102
102
103 <% if @relations.present? || User.current.allowed_to?(:manage_issue_relations, @project) %>
103 <% if @relations.present? || User.current.allowed_to?(:manage_issue_relations, @project) %>
104 <hr />
104 <hr />
105 <div id="relations">
105 <div id="relations">
106 <%= render :partial => 'relations' %>
106 <%= render :partial => 'relations' %>
107 </div>
107 </div>
108 <% end %>
108 <% end %>
109
109
110 </div>
110 </div>
111
111
112 <% if @changesets.present? %>
112 <% if @changesets.present? %>
113 <div id="issue-changesets">
113 <div id="issue-changesets">
114 <h3><%=l(:label_associated_revisions)%></h3>
114 <h3><%=l(:label_associated_revisions)%></h3>
115 <%= render :partial => 'changesets', :locals => { :changesets => @changesets} %>
115 <%= render :partial => 'changesets', :locals => { :changesets => @changesets} %>
116 </div>
116 </div>
117 <% end %>
117 <% end %>
118
118
119 <% if @journals.present? %>
119 <% if @journals.present? %>
120 <div id="history">
120 <div id="history">
121 <h3><%=l(:label_history)%></h3>
121 <h3><%=l(:label_history)%></h3>
122 <%= render :partial => 'history', :locals => { :issue => @issue, :journals => @journals } %>
122 <%= render :partial => 'history', :locals => { :issue => @issue, :journals => @journals } %>
123 </div>
123 </div>
124 <% end %>
124 <% end %>
125
125
126
126
127 <div style="clear: both;"></div>
127 <div style="clear: both;"></div>
128 <%= render :partial => 'action_menu' %>
128 <%= render :partial => 'action_menu' %>
129
129
130 <div style="clear: both;"></div>
130 <div style="clear: both;"></div>
131 <% if @issue.editable? %>
131 <% if @issue.editable? %>
132 <div id="update" style="display:none;">
132 <div id="update" style="display:none;">
133 <h3><%= l(:button_edit) %></h3>
133 <h3><%= l(:button_edit) %></h3>
134 <%= render :partial => 'edit' %>
134 <%= render :partial => 'edit' %>
135 </div>
135 </div>
136 <% end %>
136 <% end %>
137
137
138 <% other_formats_links do |f| %>
138 <% other_formats_links do |f| %>
139 <%= f.link_to 'Atom', :url => {:key => User.current.rss_key} %>
139 <%= f.link_to 'Atom', :url => {:key => User.current.rss_key} %>
140 <%= f.link_to 'PDF' %>
140 <%= f.link_to 'PDF' %>
141 <% end %>
141 <% end %>
142
142
143 <% html_title "#{@issue.tracker.name} ##{@issue.id}: #{@issue.subject}" %>
143 <% html_title "#{@issue.tracker.name} ##{@issue.id}: #{@issue.subject}" %>
144
144
145 <% content_for :sidebar do %>
145 <% content_for :sidebar do %>
146 <%= render :partial => 'issues/sidebar' %>
146 <%= render :partial => 'issues/sidebar' %>
147
147
148 <% if User.current.allowed_to?(:add_issue_watchers, @project) ||
148 <% if User.current.allowed_to?(:add_issue_watchers, @project) ||
149 (@issue.watchers.present? && User.current.allowed_to?(:view_issue_watchers, @project)) %>
149 (@issue.watchers.present? && User.current.allowed_to?(:view_issue_watchers, @project)) %>
150 <div id="watchers">
150 <div id="watchers">
151 <%= render :partial => 'watchers/watchers', :locals => {:watched => @issue} %>
151 <%= render :partial => 'watchers/watchers', :locals => {:watched => @issue} %>
152 </div>
152 </div>
153 <% end %>
153 <% end %>
154 <% end %>
154 <% end %>
155
155
156 <% content_for :header_tags do %>
156 <% content_for :header_tags do %>
157 <%= auto_discovery_link_tag(:atom, {:format => 'atom', :key => User.current.rss_key}, :title => "#{@issue.project} - #{@issue.tracker} ##{@issue.id}: #{@issue.subject}") %>
157 <%= auto_discovery_link_tag(:atom, {:format => 'atom', :key => User.current.rss_key}, :title => "#{@issue.project} - #{@issue.tracker} ##{@issue.id}: #{@issue.subject}") %>
158 <% end %>
158 <% end %>
159
159
160 <%= context_menu issues_context_menu_path %>
160 <%= context_menu issues_context_menu_path %>
@@ -1,329 +1,309
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2015 Jean-Philippe Lang
2 # Copyright (C) 2006-2015 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 require File.expand_path('../../test_helper', __FILE__)
18 require File.expand_path('../../test_helper', __FILE__)
19
19
20 class IssueNestedSetTest < ActiveSupport::TestCase
20 class IssueNestedSetTest < ActiveSupport::TestCase
21 fixtures :projects, :users, :roles,
21 fixtures :projects, :users, :roles,
22 :trackers, :projects_trackers,
22 :trackers, :projects_trackers,
23 :issue_statuses, :issue_categories, :issue_relations,
23 :issue_statuses, :issue_categories, :issue_relations,
24 :enumerations,
24 :enumerations,
25 :issues
25 :issues
26
26
27 def test_new_record_is_leaf
27 def test_new_record_is_leaf
28 i = Issue.new
28 i = Issue.new
29 assert i.leaf?
29 assert i.leaf?
30 end
30 end
31
31
32 def test_create_root_issue
32 def test_create_root_issue
33 lft1 = new_issue_lft
33 lft1 = new_issue_lft
34 issue1 = Issue.generate!
34 issue1 = Issue.generate!
35 lft2 = new_issue_lft
35 lft2 = new_issue_lft
36 issue2 = Issue.generate!
36 issue2 = Issue.generate!
37 issue1.reload
37 issue1.reload
38 issue2.reload
38 issue2.reload
39 assert_equal [issue1.id, nil, lft1, lft1 + 1], [issue1.root_id, issue1.parent_id, issue1.lft, issue1.rgt]
39 assert_equal [issue1.id, nil, lft1, lft1 + 1], [issue1.root_id, issue1.parent_id, issue1.lft, issue1.rgt]
40 assert_equal [issue2.id, nil, lft2, lft2 + 1], [issue2.root_id, issue2.parent_id, issue2.lft, issue2.rgt]
40 assert_equal [issue2.id, nil, lft2, lft2 + 1], [issue2.root_id, issue2.parent_id, issue2.lft, issue2.rgt]
41 end
41 end
42
42
43 def test_create_child_issue
43 def test_create_child_issue
44 lft = new_issue_lft
44 lft = new_issue_lft
45 parent = Issue.generate!
45 parent = Issue.generate!
46 child = parent.generate_child!
46 child = parent.generate_child!
47 parent.reload
47 parent.reload
48 child.reload
48 child.reload
49 assert_equal [parent.id, nil, lft, lft + 3], [parent.root_id, parent.parent_id, parent.lft, parent.rgt]
49 assert_equal [parent.id, nil, lft, lft + 3], [parent.root_id, parent.parent_id, parent.lft, parent.rgt]
50 assert_equal [parent.id, parent.id, lft + 1, lft + 2], [child.root_id, child.parent_id, child.lft, child.rgt]
50 assert_equal [parent.id, parent.id, lft + 1, lft + 2], [child.root_id, child.parent_id, child.lft, child.rgt]
51 end
51 end
52
52
53 def test_creating_a_child_in_a_subproject_should_validate
53 def test_creating_a_child_in_a_subproject_should_validate
54 issue = Issue.generate!
54 issue = Issue.generate!
55 child = Issue.new(:project_id => 3, :tracker_id => 2, :author_id => 1,
55 child = Issue.new(:project_id => 3, :tracker_id => 2, :author_id => 1,
56 :subject => 'child', :parent_issue_id => issue.id)
56 :subject => 'child', :parent_issue_id => issue.id)
57 assert_save child
57 assert_save child
58 assert_equal issue, child.reload.parent
58 assert_equal issue, child.reload.parent
59 end
59 end
60
60
61 def test_creating_a_child_in_an_invalid_project_should_not_validate
61 def test_creating_a_child_in_an_invalid_project_should_not_validate
62 issue = Issue.generate!
62 issue = Issue.generate!
63 child = Issue.new(:project_id => 2, :tracker_id => 1, :author_id => 1,
63 child = Issue.new(:project_id => 2, :tracker_id => 1, :author_id => 1,
64 :subject => 'child', :parent_issue_id => issue.id)
64 :subject => 'child', :parent_issue_id => issue.id)
65 assert !child.save
65 assert !child.save
66 assert_not_equal [], child.errors[:parent_issue_id]
66 assert_not_equal [], child.errors[:parent_issue_id]
67 end
67 end
68
68
69 def test_move_a_root_to_child
69 def test_move_a_root_to_child
70 lft = new_issue_lft
70 lft = new_issue_lft
71 parent1 = Issue.generate!
71 parent1 = Issue.generate!
72 parent2 = Issue.generate!
72 parent2 = Issue.generate!
73 child = parent1.generate_child!
73 child = parent1.generate_child!
74 parent2.parent_issue_id = parent1.id
74 parent2.parent_issue_id = parent1.id
75 parent2.save!
75 parent2.save!
76 child.reload
76 child.reload
77 parent1.reload
77 parent1.reload
78 parent2.reload
78 parent2.reload
79 assert_equal [parent1.id, lft, lft + 5], [parent1.root_id, parent1.lft, parent1.rgt]
79 assert_equal [parent1.id, lft, lft + 5], [parent1.root_id, parent1.lft, parent1.rgt]
80 assert_equal [parent1.id, lft + 1, lft + 2], [parent2.root_id, parent2.lft, parent2.rgt]
80 assert_equal [parent1.id, lft + 1, lft + 2], [parent2.root_id, parent2.lft, parent2.rgt]
81 assert_equal [parent1.id, lft + 3, lft + 4], [child.root_id, child.lft, child.rgt]
81 assert_equal [parent1.id, lft + 3, lft + 4], [child.root_id, child.lft, child.rgt]
82 end
82 end
83
83
84 def test_move_a_child_to_root
84 def test_move_a_child_to_root
85 lft1 = new_issue_lft
85 lft1 = new_issue_lft
86 parent1 = Issue.generate!
86 parent1 = Issue.generate!
87 lft2 = new_issue_lft
87 lft2 = new_issue_lft
88 parent2 = Issue.generate!
88 parent2 = Issue.generate!
89 lft3 = new_issue_lft
89 lft3 = new_issue_lft
90 child = parent1.generate_child!
90 child = parent1.generate_child!
91 child.parent_issue_id = nil
91 child.parent_issue_id = nil
92 child.save!
92 child.save!
93 child.reload
93 child.reload
94 parent1.reload
94 parent1.reload
95 parent2.reload
95 parent2.reload
96 assert_equal [parent1.id, lft1, lft1 + 1], [parent1.root_id, parent1.lft, parent1.rgt]
96 assert_equal [parent1.id, lft1, lft1 + 1], [parent1.root_id, parent1.lft, parent1.rgt]
97 assert_equal [parent2.id, lft2, lft2 + 1], [parent2.root_id, parent2.lft, parent2.rgt]
97 assert_equal [parent2.id, lft2, lft2 + 1], [parent2.root_id, parent2.lft, parent2.rgt]
98 assert_equal [child.id, lft3, lft3 + 1], [child.root_id, child.lft, child.rgt]
98 assert_equal [child.id, lft3, lft3 + 1], [child.root_id, child.lft, child.rgt]
99 end
99 end
100
100
101 def test_move_a_child_to_another_issue
101 def test_move_a_child_to_another_issue
102 lft1 = new_issue_lft
102 lft1 = new_issue_lft
103 parent1 = Issue.generate!
103 parent1 = Issue.generate!
104 lft2 = new_issue_lft
104 lft2 = new_issue_lft
105 parent2 = Issue.generate!
105 parent2 = Issue.generate!
106 child = parent1.generate_child!
106 child = parent1.generate_child!
107 child.parent_issue_id = parent2.id
107 child.parent_issue_id = parent2.id
108 child.save!
108 child.save!
109 child.reload
109 child.reload
110 parent1.reload
110 parent1.reload
111 parent2.reload
111 parent2.reload
112 assert_equal [parent1.id, lft1, lft1 + 1], [parent1.root_id, parent1.lft, parent1.rgt]
112 assert_equal [parent1.id, lft1, lft1 + 1], [parent1.root_id, parent1.lft, parent1.rgt]
113 assert_equal [parent2.id, lft2, lft2 + 3], [parent2.root_id, parent2.lft, parent2.rgt]
113 assert_equal [parent2.id, lft2, lft2 + 3], [parent2.root_id, parent2.lft, parent2.rgt]
114 assert_equal [parent2.id, lft2 + 1, lft2 + 2], [child.root_id, child.lft, child.rgt]
114 assert_equal [parent2.id, lft2 + 1, lft2 + 2], [child.root_id, child.lft, child.rgt]
115 end
115 end
116
116
117 def test_move_a_child_with_descendants_to_another_issue
117 def test_move_a_child_with_descendants_to_another_issue
118 lft1 = new_issue_lft
118 lft1 = new_issue_lft
119 parent1 = Issue.generate!
119 parent1 = Issue.generate!
120 lft2 = new_issue_lft
120 lft2 = new_issue_lft
121 parent2 = Issue.generate!
121 parent2 = Issue.generate!
122 child = parent1.generate_child!
122 child = parent1.generate_child!
123 grandchild = child.generate_child!
123 grandchild = child.generate_child!
124 parent1.reload
124 parent1.reload
125 parent2.reload
125 parent2.reload
126 child.reload
126 child.reload
127 grandchild.reload
127 grandchild.reload
128 assert_equal [parent1.id, lft1, lft1 + 5], [parent1.root_id, parent1.lft, parent1.rgt]
128 assert_equal [parent1.id, lft1, lft1 + 5], [parent1.root_id, parent1.lft, parent1.rgt]
129 assert_equal [parent2.id, lft2, lft2 + 1], [parent2.root_id, parent2.lft, parent2.rgt]
129 assert_equal [parent2.id, lft2, lft2 + 1], [parent2.root_id, parent2.lft, parent2.rgt]
130 assert_equal [parent1.id, lft1 + 1, lft1 + 4], [child.root_id, child.lft, child.rgt]
130 assert_equal [parent1.id, lft1 + 1, lft1 + 4], [child.root_id, child.lft, child.rgt]
131 assert_equal [parent1.id, lft1 + 2, lft1 + 3], [grandchild.root_id, grandchild.lft, grandchild.rgt]
131 assert_equal [parent1.id, lft1 + 2, lft1 + 3], [grandchild.root_id, grandchild.lft, grandchild.rgt]
132 child.reload.parent_issue_id = parent2.id
132 child.reload.parent_issue_id = parent2.id
133 child.save!
133 child.save!
134 child.reload
134 child.reload
135 grandchild.reload
135 grandchild.reload
136 parent1.reload
136 parent1.reload
137 parent2.reload
137 parent2.reload
138 assert_equal [parent1.id, lft1, lft1 + 1], [parent1.root_id, parent1.lft, parent1.rgt]
138 assert_equal [parent1.id, lft1, lft1 + 1], [parent1.root_id, parent1.lft, parent1.rgt]
139 assert_equal [parent2.id, lft2, lft2 + 5], [parent2.root_id, parent2.lft, parent2.rgt]
139 assert_equal [parent2.id, lft2, lft2 + 5], [parent2.root_id, parent2.lft, parent2.rgt]
140 assert_equal [parent2.id, lft2 + 1, lft2 + 4], [child.root_id, child.lft, child.rgt]
140 assert_equal [parent2.id, lft2 + 1, lft2 + 4], [child.root_id, child.lft, child.rgt]
141 assert_equal [parent2.id, lft2 + 2, lft2 + 3], [grandchild.root_id, grandchild.lft, grandchild.rgt]
141 assert_equal [parent2.id, lft2 + 2, lft2 + 3], [grandchild.root_id, grandchild.lft, grandchild.rgt]
142 end
142 end
143
143
144 def test_move_a_child_with_descendants_to_another_project
144 def test_move_a_child_with_descendants_to_another_project
145 lft1 = new_issue_lft
145 lft1 = new_issue_lft
146 parent1 = Issue.generate!
146 parent1 = Issue.generate!
147 child = parent1.generate_child!
147 child = parent1.generate_child!
148 grandchild = child.generate_child!
148 grandchild = child.generate_child!
149 lft4 = new_issue_lft
149 lft4 = new_issue_lft
150 child.reload
150 child.reload
151 child.project = Project.find(2)
151 child.project = Project.find(2)
152 assert child.save
152 assert child.save
153 child.reload
153 child.reload
154 grandchild.reload
154 grandchild.reload
155 parent1.reload
155 parent1.reload
156 assert_equal [1, parent1.id, lft1, lft1 + 1], [parent1.project_id, parent1.root_id, parent1.lft, parent1.rgt]
156 assert_equal [1, parent1.id, lft1, lft1 + 1], [parent1.project_id, parent1.root_id, parent1.lft, parent1.rgt]
157 assert_equal [2, child.id, lft4, lft4 + 3],
157 assert_equal [2, child.id, lft4, lft4 + 3],
158 [child.project_id, child.root_id, child.lft, child.rgt]
158 [child.project_id, child.root_id, child.lft, child.rgt]
159 assert_equal [2, child.id, lft4 + 1, lft4 + 2],
159 assert_equal [2, child.id, lft4 + 1, lft4 + 2],
160 [grandchild.project_id, grandchild.root_id, grandchild.lft, grandchild.rgt]
160 [grandchild.project_id, grandchild.root_id, grandchild.lft, grandchild.rgt]
161 end
161 end
162
162
163 def test_moving_an_issue_to_a_descendant_should_not_validate
163 def test_moving_an_issue_to_a_descendant_should_not_validate
164 parent1 = Issue.generate!
164 parent1 = Issue.generate!
165 parent2 = Issue.generate!
165 parent2 = Issue.generate!
166 child = parent1.generate_child!
166 child = parent1.generate_child!
167 grandchild = child.generate_child!
167 grandchild = child.generate_child!
168
168
169 child.reload
169 child.reload
170 child.parent_issue_id = grandchild.id
170 child.parent_issue_id = grandchild.id
171 assert !child.save
171 assert !child.save
172 assert_not_equal [], child.errors[:parent_issue_id]
172 assert_not_equal [], child.errors[:parent_issue_id]
173 end
173 end
174
174
175 def test_updating_a_root_issue_should_not_trigger_update_nested_set_attributes_on_parent_change
175 def test_updating_a_root_issue_should_not_trigger_update_nested_set_attributes_on_parent_change
176 issue = Issue.find(Issue.generate!.id)
176 issue = Issue.find(Issue.generate!.id)
177 issue.parent_issue_id = ""
177 issue.parent_issue_id = ""
178 issue.expects(:update_nested_set_attributes_on_parent_change).never
178 issue.expects(:update_nested_set_attributes_on_parent_change).never
179 issue.save!
179 issue.save!
180 end
180 end
181
181
182 def test_updating_a_child_issue_should_not_trigger_update_nested_set_attributes_on_parent_change
182 def test_updating_a_child_issue_should_not_trigger_update_nested_set_attributes_on_parent_change
183 issue = Issue.find(Issue.generate!(:parent_issue_id => 1).id)
183 issue = Issue.find(Issue.generate!(:parent_issue_id => 1).id)
184 issue.parent_issue_id = "1"
184 issue.parent_issue_id = "1"
185 issue.expects(:update_nested_set_attributes_on_parent_change).never
185 issue.expects(:update_nested_set_attributes_on_parent_change).never
186 issue.save!
186 issue.save!
187 end
187 end
188
188
189 def test_moving_a_root_issue_should_trigger_update_nested_set_attributes_on_parent_change
189 def test_moving_a_root_issue_should_trigger_update_nested_set_attributes_on_parent_change
190 issue = Issue.find(Issue.generate!.id)
190 issue = Issue.find(Issue.generate!.id)
191 issue.parent_issue_id = "1"
191 issue.parent_issue_id = "1"
192 issue.expects(:update_nested_set_attributes_on_parent_change).once
192 issue.expects(:update_nested_set_attributes_on_parent_change).once
193 issue.save!
193 issue.save!
194 end
194 end
195
195
196 def test_moving_a_child_issue_to_another_parent_should_trigger_update_nested_set_attributes_on_parent_change
196 def test_moving_a_child_issue_to_another_parent_should_trigger_update_nested_set_attributes_on_parent_change
197 issue = Issue.find(Issue.generate!(:parent_issue_id => 1).id)
197 issue = Issue.find(Issue.generate!(:parent_issue_id => 1).id)
198 issue.parent_issue_id = "2"
198 issue.parent_issue_id = "2"
199 issue.expects(:update_nested_set_attributes_on_parent_change).once
199 issue.expects(:update_nested_set_attributes_on_parent_change).once
200 issue.save!
200 issue.save!
201 end
201 end
202
202
203 def test_moving_a_child_issue_to_root_should_trigger_update_nested_set_attributes_on_parent_change
203 def test_moving_a_child_issue_to_root_should_trigger_update_nested_set_attributes_on_parent_change
204 issue = Issue.find(Issue.generate!(:parent_issue_id => 1).id)
204 issue = Issue.find(Issue.generate!(:parent_issue_id => 1).id)
205 issue.parent_issue_id = ""
205 issue.parent_issue_id = ""
206 issue.expects(:update_nested_set_attributes_on_parent_change).once
206 issue.expects(:update_nested_set_attributes_on_parent_change).once
207 issue.save!
207 issue.save!
208 end
208 end
209
209
210 def test_destroy_should_destroy_children
210 def test_destroy_should_destroy_children
211 lft1 = new_issue_lft
211 lft1 = new_issue_lft
212 issue1 = Issue.generate!
212 issue1 = Issue.generate!
213 issue2 = Issue.generate!
213 issue2 = Issue.generate!
214 issue3 = issue2.generate_child!
214 issue3 = issue2.generate_child!
215 issue4 = issue1.generate_child!
215 issue4 = issue1.generate_child!
216 issue3.init_journal(User.find(2))
216 issue3.init_journal(User.find(2))
217 issue3.subject = 'child with journal'
217 issue3.subject = 'child with journal'
218 issue3.save!
218 issue3.save!
219 assert_difference 'Issue.count', -2 do
219 assert_difference 'Issue.count', -2 do
220 assert_difference 'Journal.count', -1 do
220 assert_difference 'Journal.count', -1 do
221 assert_difference 'JournalDetail.count', -1 do
221 assert_difference 'JournalDetail.count', -1 do
222 Issue.find(issue2.id).destroy
222 Issue.find(issue2.id).destroy
223 end
223 end
224 end
224 end
225 end
225 end
226 issue1.reload
226 issue1.reload
227 issue4.reload
227 issue4.reload
228 assert !Issue.exists?(issue2.id)
228 assert !Issue.exists?(issue2.id)
229 assert !Issue.exists?(issue3.id)
229 assert !Issue.exists?(issue3.id)
230 assert_equal [issue1.id, lft1, lft1 + 3], [issue1.root_id, issue1.lft, issue1.rgt]
230 assert_equal [issue1.id, lft1, lft1 + 3], [issue1.root_id, issue1.lft, issue1.rgt]
231 assert_equal [issue1.id, lft1 + 1, lft1 + 2], [issue4.root_id, issue4.lft, issue4.rgt]
231 assert_equal [issue1.id, lft1 + 1, lft1 + 2], [issue4.root_id, issue4.lft, issue4.rgt]
232 end
232 end
233
233
234 def test_destroy_child_should_update_parent
234 def test_destroy_child_should_update_parent
235 lft1 = new_issue_lft
235 lft1 = new_issue_lft
236 issue = Issue.generate!
236 issue = Issue.generate!
237 child1 = issue.generate_child!
237 child1 = issue.generate_child!
238 child2 = issue.generate_child!
238 child2 = issue.generate_child!
239 issue.reload
239 issue.reload
240 assert_equal [issue.id, lft1, lft1 + 5], [issue.root_id, issue.lft, issue.rgt]
240 assert_equal [issue.id, lft1, lft1 + 5], [issue.root_id, issue.lft, issue.rgt]
241 child2.reload.destroy
241 child2.reload.destroy
242 issue.reload
242 issue.reload
243 assert_equal [issue.id, lft1, lft1 + 3], [issue.root_id, issue.lft, issue.rgt]
243 assert_equal [issue.id, lft1, lft1 + 3], [issue.root_id, issue.lft, issue.rgt]
244 end
244 end
245
245
246 def test_destroy_parent_issue_updated_during_children_destroy
246 def test_destroy_parent_issue_updated_during_children_destroy
247 parent = Issue.generate!
247 parent = Issue.generate!
248 parent.generate_child!(:start_date => Date.today)
248 parent.generate_child!(:start_date => Date.today)
249 parent.generate_child!(:start_date => 2.days.from_now)
249 parent.generate_child!(:start_date => 2.days.from_now)
250
250
251 assert_difference 'Issue.count', -3 do
251 assert_difference 'Issue.count', -3 do
252 Issue.find(parent.id).destroy
252 Issue.find(parent.id).destroy
253 end
253 end
254 end
254 end
255
255
256 def test_destroy_child_issue_with_children
256 def test_destroy_child_issue_with_children
257 root = Issue.generate!
257 root = Issue.generate!
258 child = root.generate_child!
258 child = root.generate_child!
259 leaf = child.generate_child!
259 leaf = child.generate_child!
260 leaf.init_journal(User.find(2))
260 leaf.init_journal(User.find(2))
261 leaf.subject = 'leaf with journal'
261 leaf.subject = 'leaf with journal'
262 leaf.save!
262 leaf.save!
263
263
264 assert_difference 'Issue.count', -2 do
264 assert_difference 'Issue.count', -2 do
265 assert_difference 'Journal.count', -1 do
265 assert_difference 'Journal.count', -1 do
266 assert_difference 'JournalDetail.count', -1 do
266 assert_difference 'JournalDetail.count', -1 do
267 Issue.find(child.id).destroy
267 Issue.find(child.id).destroy
268 end
268 end
269 end
269 end
270 end
270 end
271
271
272 root = Issue.find(root.id)
272 root = Issue.find(root.id)
273 assert root.leaf?, "Root issue is not a leaf (lft: #{root.lft}, rgt: #{root.rgt})"
273 assert root.leaf?, "Root issue is not a leaf (lft: #{root.lft}, rgt: #{root.rgt})"
274 end
274 end
275
275
276 def test_destroy_issue_with_grand_child
276 def test_destroy_issue_with_grand_child
277 lft1 = new_issue_lft
277 lft1 = new_issue_lft
278 parent = Issue.generate!
278 parent = Issue.generate!
279 issue = parent.generate_child!
279 issue = parent.generate_child!
280 child = issue.generate_child!
280 child = issue.generate_child!
281 grandchild1 = child.generate_child!
281 grandchild1 = child.generate_child!
282 grandchild2 = child.generate_child!
282 grandchild2 = child.generate_child!
283 assert_difference 'Issue.count', -4 do
283 assert_difference 'Issue.count', -4 do
284 Issue.find(issue.id).destroy
284 Issue.find(issue.id).destroy
285 parent.reload
285 parent.reload
286 assert_equal [lft1, lft1 + 1], [parent.lft, parent.rgt]
286 assert_equal [lft1, lft1 + 1], [parent.lft, parent.rgt]
287 end
287 end
288 end
288 end
289
289
290 def test_parent_estimate_should_be_sum_of_leaves
291 parent = Issue.generate!
292 parent.generate_child!(:estimated_hours => nil)
293 assert_equal nil, parent.reload.estimated_hours
294 parent.generate_child!(:estimated_hours => 5)
295 assert_equal 5, parent.reload.estimated_hours
296 parent.generate_child!(:estimated_hours => 7)
297 assert_equal 12, parent.reload.estimated_hours
298 end
299
300 def test_move_parent_updates_old_parent_attributes
301 first_parent = Issue.generate!
302 second_parent = Issue.generate!
303 child = first_parent.generate_child!(:estimated_hours => 5)
304 assert_equal 5, first_parent.reload.estimated_hours
305 child.update_attributes(:estimated_hours => 7, :parent_issue_id => second_parent.id)
306 assert_equal 7, second_parent.reload.estimated_hours
307 assert_nil first_parent.reload.estimated_hours
308 end
309
310 def test_project_copy_should_copy_issue_tree
290 def test_project_copy_should_copy_issue_tree
311 p = Project.create!(:name => 'Tree copy', :identifier => 'tree-copy', :tracker_ids => [1, 2])
291 p = Project.create!(:name => 'Tree copy', :identifier => 'tree-copy', :tracker_ids => [1, 2])
312 i1 = Issue.generate!(:project => p, :subject => 'i1')
292 i1 = Issue.generate!(:project => p, :subject => 'i1')
313 i2 = i1.generate_child!(:project => p, :subject => 'i2')
293 i2 = i1.generate_child!(:project => p, :subject => 'i2')
314 i3 = i1.generate_child!(:project => p, :subject => 'i3')
294 i3 = i1.generate_child!(:project => p, :subject => 'i3')
315 i4 = i2.generate_child!(:project => p, :subject => 'i4')
295 i4 = i2.generate_child!(:project => p, :subject => 'i4')
316 i5 = Issue.generate!(:project => p, :subject => 'i5')
296 i5 = Issue.generate!(:project => p, :subject => 'i5')
317 c = Project.new(:name => 'Copy', :identifier => 'copy', :tracker_ids => [1, 2])
297 c = Project.new(:name => 'Copy', :identifier => 'copy', :tracker_ids => [1, 2])
318 c.copy(p, :only => 'issues')
298 c.copy(p, :only => 'issues')
319 c.reload
299 c.reload
320
300
321 assert_equal 5, c.issues.count
301 assert_equal 5, c.issues.count
322 ic1, ic2, ic3, ic4, ic5 = c.issues.order('subject').to_a
302 ic1, ic2, ic3, ic4, ic5 = c.issues.order('subject').to_a
323 assert ic1.root?
303 assert ic1.root?
324 assert_equal ic1, ic2.parent
304 assert_equal ic1, ic2.parent
325 assert_equal ic1, ic3.parent
305 assert_equal ic1, ic3.parent
326 assert_equal ic2, ic4.parent
306 assert_equal ic2, ic4.parent
327 assert ic5.root?
307 assert ic5.root?
328 end
308 end
329 end
309 end
@@ -1,230 +1,258
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2015 Jean-Philippe Lang
2 # Copyright (C) 2006-2015 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 require File.expand_path('../../test_helper', __FILE__)
18 require File.expand_path('../../test_helper', __FILE__)
19
19
20 class IssueSubtaskingTest < ActiveSupport::TestCase
20 class IssueSubtaskingTest < ActiveSupport::TestCase
21 fixtures :projects, :users, :roles, :members, :member_roles,
21 fixtures :projects, :users, :roles, :members, :member_roles,
22 :trackers, :projects_trackers,
22 :trackers, :projects_trackers,
23 :issue_statuses, :issue_categories, :enumerations,
23 :issue_statuses, :issue_categories, :enumerations,
24 :issues
24 :issues
25
25
26 def test_leaf_planning_fields_should_be_editable
26 def test_leaf_planning_fields_should_be_editable
27 issue = Issue.generate!
27 issue = Issue.generate!
28 user = User.find(1)
28 user = User.find(1)
29 %w(priority_id done_ratio start_date due_date estimated_hours).each do |attribute|
29 %w(priority_id done_ratio start_date due_date estimated_hours).each do |attribute|
30 assert issue.safe_attribute?(attribute, user)
30 assert issue.safe_attribute?(attribute, user)
31 end
31 end
32 end
32 end
33
33
34 def test_parent_dates_should_be_read_only_with_parent_issue_dates_set_to_derived
34 def test_parent_dates_should_be_read_only_with_parent_issue_dates_set_to_derived
35 with_settings :parent_issue_dates => 'derived' do
35 with_settings :parent_issue_dates => 'derived' do
36 issue = Issue.generate_with_child!
36 issue = Issue.generate_with_child!
37 user = User.find(1)
37 user = User.find(1)
38 %w(start_date due_date).each do |attribute|
38 %w(start_date due_date).each do |attribute|
39 assert !issue.safe_attribute?(attribute, user)
39 assert !issue.safe_attribute?(attribute, user)
40 end
40 end
41 end
41 end
42 end
42 end
43
43
44 def test_parent_dates_should_be_lowest_start_and_highest_due_dates_with_parent_issue_dates_set_to_derived
44 def test_parent_dates_should_be_lowest_start_and_highest_due_dates_with_parent_issue_dates_set_to_derived
45 with_settings :parent_issue_dates => 'derived' do
45 with_settings :parent_issue_dates => 'derived' do
46 parent = Issue.generate!
46 parent = Issue.generate!
47 parent.generate_child!(:start_date => '2010-01-25', :due_date => '2010-02-15')
47 parent.generate_child!(:start_date => '2010-01-25', :due_date => '2010-02-15')
48 parent.generate_child!( :due_date => '2010-02-13')
48 parent.generate_child!( :due_date => '2010-02-13')
49 parent.generate_child!(:start_date => '2010-02-01', :due_date => '2010-02-22')
49 parent.generate_child!(:start_date => '2010-02-01', :due_date => '2010-02-22')
50 parent.reload
50 parent.reload
51 assert_equal Date.parse('2010-01-25'), parent.start_date
51 assert_equal Date.parse('2010-01-25'), parent.start_date
52 assert_equal Date.parse('2010-02-22'), parent.due_date
52 assert_equal Date.parse('2010-02-22'), parent.due_date
53 end
53 end
54 end
54 end
55
55
56 def test_reschuling_a_parent_should_reschedule_subtasks_with_parent_issue_dates_set_to_derived
56 def test_reschuling_a_parent_should_reschedule_subtasks_with_parent_issue_dates_set_to_derived
57 with_settings :parent_issue_dates => 'derived' do
57 with_settings :parent_issue_dates => 'derived' do
58 parent = Issue.generate!
58 parent = Issue.generate!
59 c1 = parent.generate_child!(:start_date => '2010-05-12', :due_date => '2010-05-18')
59 c1 = parent.generate_child!(:start_date => '2010-05-12', :due_date => '2010-05-18')
60 c2 = parent.generate_child!(:start_date => '2010-06-03', :due_date => '2010-06-10')
60 c2 = parent.generate_child!(:start_date => '2010-06-03', :due_date => '2010-06-10')
61 parent.reload.reschedule_on!(Date.parse('2010-06-02'))
61 parent.reload.reschedule_on!(Date.parse('2010-06-02'))
62 c1.reload
62 c1.reload
63 assert_equal [Date.parse('2010-06-02'), Date.parse('2010-06-08')], [c1.start_date, c1.due_date]
63 assert_equal [Date.parse('2010-06-02'), Date.parse('2010-06-08')], [c1.start_date, c1.due_date]
64 c2.reload
64 c2.reload
65 assert_equal [Date.parse('2010-06-03'), Date.parse('2010-06-10')], [c2.start_date, c2.due_date] # no change
65 assert_equal [Date.parse('2010-06-03'), Date.parse('2010-06-10')], [c2.start_date, c2.due_date] # no change
66 parent.reload
66 parent.reload
67 assert_equal [Date.parse('2010-06-02'), Date.parse('2010-06-10')], [parent.start_date, parent.due_date]
67 assert_equal [Date.parse('2010-06-02'), Date.parse('2010-06-10')], [parent.start_date, parent.due_date]
68 end
68 end
69 end
69 end
70
70
71 def test_parent_priority_should_be_read_only_with_parent_issue_priority_set_to_derived
71 def test_parent_priority_should_be_read_only_with_parent_issue_priority_set_to_derived
72 with_settings :parent_issue_priority => 'derived' do
72 with_settings :parent_issue_priority => 'derived' do
73 issue = Issue.generate_with_child!
73 issue = Issue.generate_with_child!
74 user = User.find(1)
74 user = User.find(1)
75 assert !issue.safe_attribute?('priority_id', user)
75 assert !issue.safe_attribute?('priority_id', user)
76 end
76 end
77 end
77 end
78
78
79 def test_parent_priority_should_be_the_highest_child_priority
79 def test_parent_priority_should_be_the_highest_child_priority
80 with_settings :parent_issue_priority => 'derived' do
80 with_settings :parent_issue_priority => 'derived' do
81 parent = Issue.generate!(:priority => IssuePriority.find_by_name('Normal'))
81 parent = Issue.generate!(:priority => IssuePriority.find_by_name('Normal'))
82 # Create children
82 # Create children
83 child1 = parent.generate_child!(:priority => IssuePriority.find_by_name('High'))
83 child1 = parent.generate_child!(:priority => IssuePriority.find_by_name('High'))
84 assert_equal 'High', parent.reload.priority.name
84 assert_equal 'High', parent.reload.priority.name
85 child2 = child1.generate_child!(:priority => IssuePriority.find_by_name('Immediate'))
85 child2 = child1.generate_child!(:priority => IssuePriority.find_by_name('Immediate'))
86 assert_equal 'Immediate', child1.reload.priority.name
86 assert_equal 'Immediate', child1.reload.priority.name
87 assert_equal 'Immediate', parent.reload.priority.name
87 assert_equal 'Immediate', parent.reload.priority.name
88 child3 = parent.generate_child!(:priority => IssuePriority.find_by_name('Low'))
88 child3 = parent.generate_child!(:priority => IssuePriority.find_by_name('Low'))
89 assert_equal 'Immediate', parent.reload.priority.name
89 assert_equal 'Immediate', parent.reload.priority.name
90 # Destroy a child
90 # Destroy a child
91 child1.destroy
91 child1.destroy
92 assert_equal 'Low', parent.reload.priority.name
92 assert_equal 'Low', parent.reload.priority.name
93 # Update a child
93 # Update a child
94 child3.reload.priority = IssuePriority.find_by_name('Normal')
94 child3.reload.priority = IssuePriority.find_by_name('Normal')
95 child3.save!
95 child3.save!
96 assert_equal 'Normal', parent.reload.priority.name
96 assert_equal 'Normal', parent.reload.priority.name
97 end
97 end
98 end
98 end
99
99
100 def test_parent_done_ratio_should_be_read_only_with_parent_issue_done_ratio_set_to_derived
100 def test_parent_done_ratio_should_be_read_only_with_parent_issue_done_ratio_set_to_derived
101 with_settings :parent_issue_done_ratio => 'derived' do
101 with_settings :parent_issue_done_ratio => 'derived' do
102 issue = Issue.generate_with_child!
102 issue = Issue.generate_with_child!
103 user = User.find(1)
103 user = User.find(1)
104 assert !issue.safe_attribute?('done_ratio', user)
104 assert !issue.safe_attribute?('done_ratio', user)
105 end
105 end
106 end
106 end
107
107
108 def test_parent_done_ratio_should_be_average_done_ratio_of_leaves
108 def test_parent_done_ratio_should_be_average_done_ratio_of_leaves
109 with_settings :parent_issue_done_ratio => 'derived' do
109 with_settings :parent_issue_done_ratio => 'derived' do
110 parent = Issue.generate!
110 parent = Issue.generate!
111 parent.generate_child!(:done_ratio => 20)
111 parent.generate_child!(:done_ratio => 20)
112 assert_equal 20, parent.reload.done_ratio
112 assert_equal 20, parent.reload.done_ratio
113 parent.generate_child!(:done_ratio => 70)
113 parent.generate_child!(:done_ratio => 70)
114 assert_equal 45, parent.reload.done_ratio
114 assert_equal 45, parent.reload.done_ratio
115
115
116 child = parent.generate_child!(:done_ratio => 0)
116 child = parent.generate_child!(:done_ratio => 0)
117 assert_equal 30, parent.reload.done_ratio
117 assert_equal 30, parent.reload.done_ratio
118
118
119 child.generate_child!(:done_ratio => 30)
119 child.generate_child!(:done_ratio => 30)
120 assert_equal 30, child.reload.done_ratio
120 assert_equal 30, child.reload.done_ratio
121 assert_equal 40, parent.reload.done_ratio
121 assert_equal 40, parent.reload.done_ratio
122 end
122 end
123 end
123 end
124
124
125 def test_parent_done_ratio_should_be_weighted_by_estimated_times_if_any
125 def test_parent_done_ratio_should_be_weighted_by_estimated_times_if_any
126 with_settings :parent_issue_done_ratio => 'derived' do
126 with_settings :parent_issue_done_ratio => 'derived' do
127 parent = Issue.generate!
127 parent = Issue.generate!
128 parent.generate_child!(:estimated_hours => 10, :done_ratio => 20)
128 parent.generate_child!(:estimated_hours => 10, :done_ratio => 20)
129 assert_equal 20, parent.reload.done_ratio
129 assert_equal 20, parent.reload.done_ratio
130 parent.generate_child!(:estimated_hours => 20, :done_ratio => 50)
130 parent.generate_child!(:estimated_hours => 20, :done_ratio => 50)
131 assert_equal (50 * 20 + 20 * 10) / 30, parent.reload.done_ratio
131 assert_equal (50 * 20 + 20 * 10) / 30, parent.reload.done_ratio
132 end
132 end
133 end
133 end
134
134
135 def test_parent_done_ratio_with_child_estimate_to_0_should_reach_100
135 def test_parent_done_ratio_with_child_estimate_to_0_should_reach_100
136 with_settings :parent_issue_done_ratio => 'derived' do
136 with_settings :parent_issue_done_ratio => 'derived' do
137 parent = Issue.generate!
137 parent = Issue.generate!
138 issue1 = parent.generate_child!
138 issue1 = parent.generate_child!
139 issue2 = parent.generate_child!(:estimated_hours => 0)
139 issue2 = parent.generate_child!(:estimated_hours => 0)
140 assert_equal 0, parent.reload.done_ratio
140 assert_equal 0, parent.reload.done_ratio
141 issue1.reload.close!
141 issue1.reload.close!
142 assert_equal 50, parent.reload.done_ratio
142 assert_equal 50, parent.reload.done_ratio
143 issue2.reload.close!
143 issue2.reload.close!
144 assert_equal 100, parent.reload.done_ratio
144 assert_equal 100, parent.reload.done_ratio
145 end
145 end
146 end
146 end
147
147
148 def test_done_ratio_of_parent_with_a_child_without_estimated_time_should_not_exceed_100
148 def test_done_ratio_of_parent_with_a_child_without_estimated_time_should_not_exceed_100
149 parent = Issue.generate!
149 with_settings :parent_issue_done_ratio => 'derived' do
150 parent.generate_child!(:estimated_hours => 40)
150 parent = Issue.generate!
151 parent.generate_child!(:estimated_hours => 40)
151 parent.generate_child!(:estimated_hours => 40)
152 parent.generate_child!(:estimated_hours => 20)
152 parent.generate_child!(:estimated_hours => 40)
153 parent.generate_child!
153 parent.generate_child!(:estimated_hours => 20)
154 parent.reload.children.each(&:close!)
154 parent.generate_child!
155 assert_equal 100, parent.reload.done_ratio
155 parent.reload.children.each(&:close!)
156 assert_equal 100, parent.reload.done_ratio
157 end
156 end
158 end
157
159
158 def test_done_ratio_of_parent_with_a_child_with_estimated_time_at_0_should_not_exceed_100
160 def test_done_ratio_of_parent_with_a_child_with_estimated_time_at_0_should_not_exceed_100
159 parent = Issue.generate!
161 with_settings :parent_issue_done_ratio => 'derived' do
160 parent.generate_child!(:estimated_hours => 40)
162 parent = Issue.generate!
161 parent.generate_child!(:estimated_hours => 40)
163 parent.generate_child!(:estimated_hours => 40)
162 parent.generate_child!(:estimated_hours => 20)
164 parent.generate_child!(:estimated_hours => 40)
163 parent.generate_child!(:estimated_hours => 0)
165 parent.generate_child!(:estimated_hours => 20)
164 parent.reload.children.each(&:close!)
166 parent.generate_child!(:estimated_hours => 0)
165 assert_equal 100, parent.reload.done_ratio
167 parent.reload.children.each(&:close!)
168 assert_equal 100, parent.reload.done_ratio
169 end
170 end
171
172 def test_changing_parent_should_update_previous_parent_done_ratio
173 with_settings :parent_issue_done_ratio => 'derived' do
174 first_parent = Issue.generate!
175 second_parent = Issue.generate!
176 first_parent.generate_child!(:done_ratio => 40)
177 child = first_parent.generate_child!(:done_ratio => 20)
178 assert_equal 30, first_parent.reload.done_ratio
179 assert_equal 0, second_parent.reload.done_ratio
180 child.update_attributes(:parent_issue_id => second_parent.id)
181 assert_equal 40, first_parent.reload.done_ratio
182 assert_equal 20, second_parent.reload.done_ratio
183 end
166 end
184 end
167
185
168 def test_parent_dates_should_be_editable_with_parent_issue_dates_set_to_independent
186 def test_parent_dates_should_be_editable_with_parent_issue_dates_set_to_independent
169 with_settings :parent_issue_dates => 'independent' do
187 with_settings :parent_issue_dates => 'independent' do
170 issue = Issue.generate_with_child!
188 issue = Issue.generate_with_child!
171 user = User.find(1)
189 user = User.find(1)
172 %w(start_date due_date).each do |attribute|
190 %w(start_date due_date).each do |attribute|
173 assert issue.safe_attribute?(attribute, user)
191 assert issue.safe_attribute?(attribute, user)
174 end
192 end
175 end
193 end
176 end
194 end
177
195
178 def test_parent_dates_should_not_be_updated_with_parent_issue_dates_set_to_independent
196 def test_parent_dates_should_not_be_updated_with_parent_issue_dates_set_to_independent
179 with_settings :parent_issue_dates => 'independent' do
197 with_settings :parent_issue_dates => 'independent' do
180 parent = Issue.generate!(:start_date => '2015-07-01', :due_date => '2015-08-01')
198 parent = Issue.generate!(:start_date => '2015-07-01', :due_date => '2015-08-01')
181 parent.generate_child!(:start_date => '2015-06-01', :due_date => '2015-09-01')
199 parent.generate_child!(:start_date => '2015-06-01', :due_date => '2015-09-01')
182 parent.reload
200 parent.reload
183 assert_equal Date.parse('2015-07-01'), parent.start_date
201 assert_equal Date.parse('2015-07-01'), parent.start_date
184 assert_equal Date.parse('2015-08-01'), parent.due_date
202 assert_equal Date.parse('2015-08-01'), parent.due_date
185 end
203 end
186 end
204 end
187
205
188 def test_reschuling_a_parent_should_not_reschedule_subtasks_with_parent_issue_dates_set_to_independent
206 def test_reschuling_a_parent_should_not_reschedule_subtasks_with_parent_issue_dates_set_to_independent
189 with_settings :parent_issue_dates => 'independent' do
207 with_settings :parent_issue_dates => 'independent' do
190 parent = Issue.generate!(:start_date => '2010-05-01', :due_date => '2010-05-20')
208 parent = Issue.generate!(:start_date => '2010-05-01', :due_date => '2010-05-20')
191 c1 = parent.generate_child!(:start_date => '2010-05-12', :due_date => '2010-05-18')
209 c1 = parent.generate_child!(:start_date => '2010-05-12', :due_date => '2010-05-18')
192 parent.reload.reschedule_on!(Date.parse('2010-06-01'))
210 parent.reload.reschedule_on!(Date.parse('2010-06-01'))
193 assert_equal Date.parse('2010-06-01'), parent.reload.start_date
211 assert_equal Date.parse('2010-06-01'), parent.reload.start_date
194 c1.reload
212 c1.reload
195 assert_equal [Date.parse('2010-05-12'), Date.parse('2010-05-18')], [c1.start_date, c1.due_date]
213 assert_equal [Date.parse('2010-05-12'), Date.parse('2010-05-18')], [c1.start_date, c1.due_date]
196 end
214 end
197 end
215 end
198
216
199 def test_parent_priority_should_be_editable_with_parent_issue_priority_set_to_independent
217 def test_parent_priority_should_be_editable_with_parent_issue_priority_set_to_independent
200 with_settings :parent_issue_priority => 'independent' do
218 with_settings :parent_issue_priority => 'independent' do
201 issue = Issue.generate_with_child!
219 issue = Issue.generate_with_child!
202 user = User.find(1)
220 user = User.find(1)
203 assert issue.safe_attribute?('priority_id', user)
221 assert issue.safe_attribute?('priority_id', user)
204 end
222 end
205 end
223 end
206
224
207 def test_parent_priority_should_not_be_updated_with_parent_issue_priority_set_to_independent
225 def test_parent_priority_should_not_be_updated_with_parent_issue_priority_set_to_independent
208 with_settings :parent_issue_priority => 'independent' do
226 with_settings :parent_issue_priority => 'independent' do
209 parent = Issue.generate!(:priority => IssuePriority.find_by_name('Normal'))
227 parent = Issue.generate!(:priority => IssuePriority.find_by_name('Normal'))
210 child1 = parent.generate_child!(:priority => IssuePriority.find_by_name('High'))
228 child1 = parent.generate_child!(:priority => IssuePriority.find_by_name('High'))
211 assert_equal 'Normal', parent.reload.priority.name
229 assert_equal 'Normal', parent.reload.priority.name
212 end
230 end
213 end
231 end
214
232
215 def test_parent_done_ratio_should_be_editable_with_parent_issue_done_ratio_set_to_independent
233 def test_parent_done_ratio_should_be_editable_with_parent_issue_done_ratio_set_to_independent
216 with_settings :parent_issue_done_ratio => 'independent' do
234 with_settings :parent_issue_done_ratio => 'independent' do
217 issue = Issue.generate_with_child!
235 issue = Issue.generate_with_child!
218 user = User.find(1)
236 user = User.find(1)
219 assert issue.safe_attribute?('done_ratio', user)
237 assert issue.safe_attribute?('done_ratio', user)
220 end
238 end
221 end
239 end
222
240
223 def test_parent_done_ratio_should_not_be_updated_with_parent_issue_done_ratio_set_to_independent
241 def test_parent_done_ratio_should_not_be_updated_with_parent_issue_done_ratio_set_to_independent
224 with_settings :parent_issue_done_ratio => 'independent' do
242 with_settings :parent_issue_done_ratio => 'independent' do
225 parent = Issue.generate!(:done_ratio => 0)
243 parent = Issue.generate!(:done_ratio => 0)
226 child1 = parent.generate_child!(:done_ratio => 10)
244 child1 = parent.generate_child!(:done_ratio => 10)
227 assert_equal 0, parent.reload.done_ratio
245 assert_equal 0, parent.reload.done_ratio
228 end
246 end
229 end
247 end
248
249 def test_parent_total_estimated_hours_should_be_sum_of_descendants
250 parent = Issue.generate!
251 parent.generate_child!(:estimated_hours => nil)
252 assert_equal 0, parent.reload.total_estimated_hours
253 parent.generate_child!(:estimated_hours => 5)
254 assert_equal 5, parent.reload.total_estimated_hours
255 parent.generate_child!(:estimated_hours => 7)
256 assert_equal 12, parent.reload.total_estimated_hours
257 end
230 end
258 end
General Comments 0
You need to be logged in to leave comments. Login now