##// END OF EJS Templates
Restores commits reverted when rails-4.1 branch was merged (#18174)....
Jean-Philippe Lang -
r13122:67c4936908e6
parent child
Show More

The requested changes are too big and content was truncated. Show full diff

@@ -1,1339 +1,1346
1 1 # encoding: utf-8
2 2 #
3 3 # Redmine - project management software
4 4 # Copyright (C) 2006-2014 Jean-Philippe Lang
5 5 #
6 6 # This program is free software; you can redistribute it and/or
7 7 # modify it under the terms of the GNU General Public License
8 8 # as published by the Free Software Foundation; either version 2
9 9 # of the License, or (at your option) any later version.
10 10 #
11 11 # This program is distributed in the hope that it will be useful,
12 12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 14 # GNU General Public License for more details.
15 15 #
16 16 # You should have received a copy of the GNU General Public License
17 17 # along with this program; if not, write to the Free Software
18 18 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19 19
20 20 require 'forwardable'
21 21 require 'cgi'
22 22
23 23 module ApplicationHelper
24 24 include Redmine::WikiFormatting::Macros::Definitions
25 25 include Redmine::I18n
26 26 include GravatarHelper::PublicMethods
27 27 include Redmine::Pagination::Helper
28 28
29 29 extend Forwardable
30 30 def_delegators :wiki_helper, :wikitoolbar_for, :heads_for_wiki_formatter
31 31
32 32 # Return true if user is authorized for controller/action, otherwise false
33 33 def authorize_for(controller, action)
34 34 User.current.allowed_to?({:controller => controller, :action => action}, @project)
35 35 end
36 36
37 37 # Display a link if user is authorized
38 38 #
39 39 # @param [String] name Anchor text (passed to link_to)
40 40 # @param [Hash] options Hash params. This will checked by authorize_for to see if the user is authorized
41 41 # @param [optional, Hash] html_options Options passed to link_to
42 42 # @param [optional, Hash] parameters_for_method_reference Extra parameters for link_to
43 43 def link_to_if_authorized(name, options = {}, html_options = nil, *parameters_for_method_reference)
44 44 link_to(name, options, html_options, *parameters_for_method_reference) if authorize_for(options[:controller] || params[:controller], options[:action])
45 45 end
46 46
47 47 # Displays a link to user's account page if active
48 48 def link_to_user(user, options={})
49 49 if user.is_a?(User)
50 50 name = h(user.name(options[:format]))
51 51 if user.active? || (User.current.admin? && user.logged?)
52 52 link_to name, user_path(user), :class => user.css_classes
53 53 else
54 54 name
55 55 end
56 56 else
57 57 h(user.to_s)
58 58 end
59 59 end
60 60
61 61 # Displays a link to +issue+ with its subject.
62 62 # Examples:
63 63 #
64 64 # link_to_issue(issue) # => Defect #6: This is the subject
65 65 # link_to_issue(issue, :truncate => 6) # => Defect #6: This i...
66 66 # link_to_issue(issue, :subject => false) # => Defect #6
67 67 # link_to_issue(issue, :project => true) # => Foo - Defect #6
68 68 # link_to_issue(issue, :subject => false, :tracker => false) # => #6
69 69 #
70 70 def link_to_issue(issue, options={})
71 71 title = nil
72 72 subject = nil
73 73 text = options[:tracker] == false ? "##{issue.id}" : "#{issue.tracker} ##{issue.id}"
74 74 if options[:subject] == false
75 75 title = issue.subject.truncate(60)
76 76 else
77 77 subject = issue.subject
78 78 if truncate_length = options[:truncate]
79 79 subject = subject.truncate(truncate_length)
80 80 end
81 81 end
82 82 only_path = options[:only_path].nil? ? true : options[:only_path]
83 83 s = link_to(text, issue_path(issue, :only_path => only_path),
84 84 :class => issue.css_classes, :title => title)
85 85 s << h(": #{subject}") if subject
86 86 s = h("#{issue.project} - ") + s if options[:project]
87 87 s
88 88 end
89 89
90 90 # Generates a link to an attachment.
91 91 # Options:
92 92 # * :text - Link text (default to attachment filename)
93 93 # * :download - Force download (default: false)
94 94 def link_to_attachment(attachment, options={})
95 95 text = options.delete(:text) || attachment.filename
96 96 route_method = options.delete(:download) ? :download_named_attachment_path : :named_attachment_path
97 97 html_options = options.slice!(:only_path)
98 98 url = send(route_method, attachment, attachment.filename, options)
99 99 link_to text, url, html_options
100 100 end
101 101
102 102 # Generates a link to a SCM revision
103 103 # Options:
104 104 # * :text - Link text (default to the formatted revision)
105 105 def link_to_revision(revision, repository, options={})
106 106 if repository.is_a?(Project)
107 107 repository = repository.repository
108 108 end
109 109 text = options.delete(:text) || format_revision(revision)
110 110 rev = revision.respond_to?(:identifier) ? revision.identifier : revision
111 111 link_to(
112 112 h(text),
113 113 {:controller => 'repositories', :action => 'revision', :id => repository.project, :repository_id => repository.identifier_param, :rev => rev},
114 114 :title => l(:label_revision_id, format_revision(revision))
115 115 )
116 116 end
117 117
118 118 # Generates a link to a message
119 119 def link_to_message(message, options={}, html_options = nil)
120 120 link_to(
121 121 message.subject.truncate(60),
122 122 board_message_path(message.board_id, message.parent_id || message.id, {
123 123 :r => (message.parent_id && message.id),
124 124 :anchor => (message.parent_id ? "message-#{message.id}" : nil)
125 125 }.merge(options)),
126 126 html_options
127 127 )
128 128 end
129 129
130 130 # Generates a link to a project if active
131 131 # Examples:
132 132 #
133 133 # link_to_project(project) # => link to the specified project overview
134 134 # link_to_project(project, {:only_path => false}, :class => "project") # => 3rd arg adds html options
135 135 # link_to_project(project, {}, :class => "project") # => html options with default url (project overview)
136 136 #
137 137 def link_to_project(project, options={}, html_options = nil)
138 138 if project.archived?
139 139 h(project.name)
140 140 else
141 141 link_to project.name, project_path(project, options), html_options
142 142 end
143 143 end
144 144
145 145 # Generates a link to a project settings if active
146 146 def link_to_project_settings(project, options={}, html_options=nil)
147 147 if project.active?
148 148 link_to project.name, settings_project_path(project, options), html_options
149 149 elsif project.archived?
150 150 h(project.name)
151 151 else
152 152 link_to project.name, project_path(project, options), html_options
153 153 end
154 154 end
155 155
156 # Generates a link to a version
157 def link_to_version(version, options = {})
158 return '' unless version && version.is_a?(Version)
159 options = {:title => format_date(version.effective_date)}.merge(options)
160 link_to_if version.visible?, format_version_name(version), version_path(version), options
161 end
162
156 163 # Helper that formats object for html or text rendering
157 164 def format_object(object, html=true, &block)
158 165 if block_given?
159 166 object = yield object
160 167 end
161 168 case object.class.name
162 169 when 'Array'
163 170 object.map {|o| format_object(o, html)}.join(', ').html_safe
164 171 when 'Time'
165 172 format_time(object)
166 173 when 'Date'
167 174 format_date(object)
168 175 when 'Fixnum'
169 176 object.to_s
170 177 when 'Float'
171 178 sprintf "%.2f", object
172 179 when 'User'
173 180 html ? link_to_user(object) : object.to_s
174 181 when 'Project'
175 182 html ? link_to_project(object) : object.to_s
176 183 when 'Version'
177 html ? link_to(object.name, version_path(object)) : object.to_s
184 html ? link_to_version(object) : object.to_s
178 185 when 'TrueClass'
179 186 l(:general_text_Yes)
180 187 when 'FalseClass'
181 188 l(:general_text_No)
182 189 when 'Issue'
183 190 object.visible? && html ? link_to_issue(object) : "##{object.id}"
184 191 when 'CustomValue', 'CustomFieldValue'
185 192 if object.custom_field
186 193 f = object.custom_field.format.formatted_custom_value(self, object, html)
187 194 if f.nil? || f.is_a?(String)
188 195 f
189 196 else
190 197 format_object(f, html, &block)
191 198 end
192 199 else
193 200 object.value.to_s
194 201 end
195 202 else
196 203 html ? h(object) : object.to_s
197 204 end
198 205 end
199 206
200 207 def wiki_page_path(page, options={})
201 208 url_for({:controller => 'wiki', :action => 'show', :project_id => page.project, :id => page.title}.merge(options))
202 209 end
203 210
204 211 def thumbnail_tag(attachment)
205 212 link_to image_tag(thumbnail_path(attachment)),
206 213 named_attachment_path(attachment, attachment.filename),
207 214 :title => attachment.filename
208 215 end
209 216
210 217 def toggle_link(name, id, options={})
211 218 onclick = "$('##{id}').toggle(); "
212 219 onclick << (options[:focus] ? "$('##{options[:focus]}').focus(); " : "this.blur(); ")
213 220 onclick << "return false;"
214 221 link_to(name, "#", :onclick => onclick)
215 222 end
216 223
217 224 def image_to_function(name, function, html_options = {})
218 225 html_options.symbolize_keys!
219 226 tag(:input, html_options.merge({
220 227 :type => "image", :src => image_path(name),
221 228 :onclick => (html_options[:onclick] ? "#{html_options[:onclick]}; " : "") + "#{function};"
222 229 }))
223 230 end
224 231
225 232 def format_activity_title(text)
226 233 h(truncate_single_line_raw(text, 100))
227 234 end
228 235
229 236 def format_activity_day(date)
230 237 date == User.current.today ? l(:label_today).titleize : format_date(date)
231 238 end
232 239
233 240 def format_activity_description(text)
234 241 h(text.to_s.truncate(120).gsub(%r{[\r\n]*<(pre|code)>.*$}m, '...')
235 242 ).gsub(/[\r\n]+/, "<br />").html_safe
236 243 end
237 244
238 245 def format_version_name(version)
239 if version.project == @project
246 if !version.shared? || version.project == @project
240 247 h(version)
241 248 else
242 249 h("#{version.project} - #{version}")
243 250 end
244 251 end
245 252
246 253 def due_date_distance_in_words(date)
247 254 if date
248 255 l((date < Date.today ? :label_roadmap_overdue : :label_roadmap_due_in), distance_of_date_in_words(Date.today, date))
249 256 end
250 257 end
251 258
252 259 # Renders a tree of projects as a nested set of unordered lists
253 260 # The given collection may be a subset of the whole project tree
254 261 # (eg. some intermediate nodes are private and can not be seen)
255 262 def render_project_nested_lists(projects, &block)
256 263 s = ''
257 264 if projects.any?
258 265 ancestors = []
259 266 original_project = @project
260 267 projects.sort_by(&:lft).each do |project|
261 268 # set the project environment to please macros.
262 269 @project = project
263 270 if (ancestors.empty? || project.is_descendant_of?(ancestors.last))
264 271 s << "<ul class='projects #{ ancestors.empty? ? 'root' : nil}'>\n"
265 272 else
266 273 ancestors.pop
267 274 s << "</li>"
268 275 while (ancestors.any? && !project.is_descendant_of?(ancestors.last))
269 276 ancestors.pop
270 277 s << "</ul></li>\n"
271 278 end
272 279 end
273 280 classes = (ancestors.empty? ? 'root' : 'child')
274 281 s << "<li class='#{classes}'><div class='#{classes}'>"
275 282 s << h(block_given? ? capture(project, &block) : project.name)
276 283 s << "</div>\n"
277 284 ancestors << project
278 285 end
279 286 s << ("</li></ul>\n" * ancestors.size)
280 287 @project = original_project
281 288 end
282 289 s.html_safe
283 290 end
284 291
285 292 def render_page_hierarchy(pages, node=nil, options={})
286 293 content = ''
287 294 if pages[node]
288 295 content << "<ul class=\"pages-hierarchy\">\n"
289 296 pages[node].each do |page|
290 297 content << "<li>"
291 298 content << link_to(h(page.pretty_title), {:controller => 'wiki', :action => 'show', :project_id => page.project, :id => page.title, :version => nil},
292 299 :title => (options[:timestamp] && page.updated_on ? l(:label_updated_time, distance_of_time_in_words(Time.now, page.updated_on)) : nil))
293 300 content << "\n" + render_page_hierarchy(pages, page.id, options) if pages[page.id]
294 301 content << "</li>\n"
295 302 end
296 303 content << "</ul>\n"
297 304 end
298 305 content.html_safe
299 306 end
300 307
301 308 # Renders flash messages
302 309 def render_flash_messages
303 310 s = ''
304 311 flash.each do |k,v|
305 312 s << content_tag('div', v.html_safe, :class => "flash #{k}", :id => "flash_#{k}")
306 313 end
307 314 s.html_safe
308 315 end
309 316
310 317 # Renders tabs and their content
311 318 def render_tabs(tabs, selected=params[:tab])
312 319 if tabs.any?
313 320 unless tabs.detect {|tab| tab[:name] == selected}
314 321 selected = nil
315 322 end
316 323 selected ||= tabs.first[:name]
317 324 render :partial => 'common/tabs', :locals => {:tabs => tabs, :selected_tab => selected}
318 325 else
319 326 content_tag 'p', l(:label_no_data), :class => "nodata"
320 327 end
321 328 end
322 329
323 330 # Renders the project quick-jump box
324 331 def render_project_jump_box
325 332 return unless User.current.logged?
326 333 projects = User.current.memberships.collect(&:project).compact.select(&:active?).uniq
327 334 if projects.any?
328 335 options =
329 336 ("<option value=''>#{ l(:label_jump_to_a_project) }</option>" +
330 337 '<option value="" disabled="disabled">---</option>').html_safe
331 338
332 339 options << project_tree_options_for_select(projects, :selected => @project) do |p|
333 340 { :value => project_path(:id => p, :jump => current_menu_item) }
334 341 end
335 342
336 343 select_tag('project_quick_jump_box', options, :onchange => 'if (this.value != \'\') { window.location = this.value; }')
337 344 end
338 345 end
339 346
340 347 def project_tree_options_for_select(projects, options = {})
341 348 s = ''.html_safe
342 349 if options[:include_blank]
343 350 s << content_tag('option', '&nbsp;'.html_safe, :value => '')
344 351 end
345 352 project_tree(projects) do |project, level|
346 353 name_prefix = (level > 0 ? '&nbsp;' * 2 * level + '&#187; ' : '').html_safe
347 354 tag_options = {:value => project.id}
348 355 if project == options[:selected] || (options[:selected].respond_to?(:include?) && options[:selected].include?(project))
349 356 tag_options[:selected] = 'selected'
350 357 else
351 358 tag_options[:selected] = nil
352 359 end
353 360 tag_options.merge!(yield(project)) if block_given?
354 361 s << content_tag('option', name_prefix + h(project), tag_options)
355 362 end
356 363 s.html_safe
357 364 end
358 365
359 366 # Yields the given block for each project with its level in the tree
360 367 #
361 368 # Wrapper for Project#project_tree
362 369 def project_tree(projects, &block)
363 370 Project.project_tree(projects, &block)
364 371 end
365 372
366 373 def principals_check_box_tags(name, principals)
367 374 s = ''
368 375 principals.each do |principal|
369 376 s << "<label>#{ check_box_tag name, principal.id, false, :id => nil } #{h principal}</label>\n"
370 377 end
371 378 s.html_safe
372 379 end
373 380
374 381 # Returns a string for users/groups option tags
375 382 def principals_options_for_select(collection, selected=nil)
376 383 s = ''
377 384 if collection.include?(User.current)
378 385 s << content_tag('option', "<< #{l(:label_me)} >>", :value => User.current.id)
379 386 end
380 387 groups = ''
381 388 collection.sort.each do |element|
382 389 selected_attribute = ' selected="selected"' if option_value_selected?(element, selected) || element.id.to_s == selected
383 390 (element.is_a?(Group) ? groups : s) << %(<option value="#{element.id}"#{selected_attribute}>#{h element.name}</option>)
384 391 end
385 392 unless groups.empty?
386 393 s << %(<optgroup label="#{h(l(:label_group_plural))}">#{groups}</optgroup>)
387 394 end
388 395 s.html_safe
389 396 end
390 397
391 398 # Options for the new membership projects combo-box
392 399 def options_for_membership_project_select(principal, projects)
393 400 options = content_tag('option', "--- #{l(:actionview_instancetag_blank_option)} ---")
394 401 options << project_tree_options_for_select(projects) do |p|
395 402 {:disabled => principal.projects.to_a.include?(p)}
396 403 end
397 404 options
398 405 end
399 406
400 407 def option_tag(name, text, value, selected=nil, options={})
401 408 content_tag 'option', value, options.merge(:value => value, :selected => (value == selected))
402 409 end
403 410
404 411 # Truncates and returns the string as a single line
405 412 def truncate_single_line(string, *args)
406 413 ActiveSupport::Deprecation.warn(
407 414 "ApplicationHelper#truncate_single_line is deprecated and will be removed in Rails 4 poring")
408 415 # Rails 4 ActionView::Helpers::TextHelper#truncate escapes.
409 416 # So, result is broken.
410 417 truncate(string.to_s, *args).gsub(%r{[\r\n]+}m, ' ')
411 418 end
412 419
413 420 def truncate_single_line_raw(string, length)
414 421 string.truncate(length).gsub(%r{[\r\n]+}m, ' ')
415 422 end
416 423
417 424 # Truncates at line break after 250 characters or options[:length]
418 425 def truncate_lines(string, options={})
419 426 length = options[:length] || 250
420 427 if string.to_s =~ /\A(.{#{length}}.*?)$/m
421 428 "#{$1}..."
422 429 else
423 430 string
424 431 end
425 432 end
426 433
427 434 def anchor(text)
428 435 text.to_s.gsub(' ', '_')
429 436 end
430 437
431 438 def html_hours(text)
432 439 text.gsub(%r{(\d+)\.(\d+)}, '<span class="hours hours-int">\1</span><span class="hours hours-dec">.\2</span>').html_safe
433 440 end
434 441
435 442 def authoring(created, author, options={})
436 443 l(options[:label] || :label_added_time_by, :author => link_to_user(author), :age => time_tag(created)).html_safe
437 444 end
438 445
439 446 def time_tag(time)
440 447 text = distance_of_time_in_words(Time.now, time)
441 448 if @project
442 449 link_to(text, {:controller => 'activities', :action => 'index', :id => @project, :from => User.current.time_to_date(time)}, :title => format_time(time))
443 450 else
444 451 content_tag('abbr', text, :title => format_time(time))
445 452 end
446 453 end
447 454
448 455 def syntax_highlight_lines(name, content)
449 456 lines = []
450 457 syntax_highlight(name, content).each_line { |line| lines << line }
451 458 lines
452 459 end
453 460
454 461 def syntax_highlight(name, content)
455 462 Redmine::SyntaxHighlighting.highlight_by_filename(content, name)
456 463 end
457 464
458 465 def to_path_param(path)
459 466 str = path.to_s.split(%r{[/\\]}).select{|p| !p.blank?}.join("/")
460 467 str.blank? ? nil : str
461 468 end
462 469
463 470 def reorder_links(name, url, method = :post)
464 471 link_to(image_tag('2uparrow.png', :alt => l(:label_sort_highest)),
465 472 url.merge({"#{name}[move_to]" => 'highest'}),
466 473 :method => method, :title => l(:label_sort_highest)) +
467 474 link_to(image_tag('1uparrow.png', :alt => l(:label_sort_higher)),
468 475 url.merge({"#{name}[move_to]" => 'higher'}),
469 476 :method => method, :title => l(:label_sort_higher)) +
470 477 link_to(image_tag('1downarrow.png', :alt => l(:label_sort_lower)),
471 478 url.merge({"#{name}[move_to]" => 'lower'}),
472 479 :method => method, :title => l(:label_sort_lower)) +
473 480 link_to(image_tag('2downarrow.png', :alt => l(:label_sort_lowest)),
474 481 url.merge({"#{name}[move_to]" => 'lowest'}),
475 482 :method => method, :title => l(:label_sort_lowest))
476 483 end
477 484
478 485 def breadcrumb(*args)
479 486 elements = args.flatten
480 487 elements.any? ? content_tag('p', (args.join(" \xc2\xbb ") + " \xc2\xbb ").html_safe, :class => 'breadcrumb') : nil
481 488 end
482 489
483 490 def other_formats_links(&block)
484 491 concat('<p class="other-formats">'.html_safe + l(:label_export_to))
485 492 yield Redmine::Views::OtherFormatsBuilder.new(self)
486 493 concat('</p>'.html_safe)
487 494 end
488 495
489 496 def page_header_title
490 497 if @project.nil? || @project.new_record?
491 498 h(Setting.app_title)
492 499 else
493 500 b = []
494 501 ancestors = (@project.root? ? [] : @project.ancestors.visible.to_a)
495 502 if ancestors.any?
496 503 root = ancestors.shift
497 504 b << link_to_project(root, {:jump => current_menu_item}, :class => 'root')
498 505 if ancestors.size > 2
499 506 b << "\xe2\x80\xa6"
500 507 ancestors = ancestors[-2, 2]
501 508 end
502 509 b += ancestors.collect {|p| link_to_project(p, {:jump => current_menu_item}, :class => 'ancestor') }
503 510 end
504 511 b << h(@project)
505 512 b.join(" \xc2\xbb ").html_safe
506 513 end
507 514 end
508 515
509 516 # Returns a h2 tag and sets the html title with the given arguments
510 517 def title(*args)
511 518 strings = args.map do |arg|
512 519 if arg.is_a?(Array) && arg.size >= 2
513 520 link_to(*arg)
514 521 else
515 522 h(arg.to_s)
516 523 end
517 524 end
518 525 html_title args.reverse.map {|s| (s.is_a?(Array) ? s.first : s).to_s}
519 526 content_tag('h2', strings.join(' &#187; ').html_safe)
520 527 end
521 528
522 529 # Sets the html title
523 530 # Returns the html title when called without arguments
524 531 # Current project name and app_title and automatically appended
525 532 # Exemples:
526 533 # html_title 'Foo', 'Bar'
527 534 # html_title # => 'Foo - Bar - My Project - Redmine'
528 535 def html_title(*args)
529 536 if args.empty?
530 537 title = @html_title || []
531 538 title << @project.name if @project
532 539 title << Setting.app_title unless Setting.app_title == title.last
533 540 title.reject(&:blank?).join(' - ')
534 541 else
535 542 @html_title ||= []
536 543 @html_title += args
537 544 end
538 545 end
539 546
540 547 # Returns the theme, controller name, and action as css classes for the
541 548 # HTML body.
542 549 def body_css_classes
543 550 css = []
544 551 if theme = Redmine::Themes.theme(Setting.ui_theme)
545 552 css << 'theme-' + theme.name
546 553 end
547 554
548 555 css << 'project-' + @project.identifier if @project && @project.identifier.present?
549 556 css << 'controller-' + controller_name
550 557 css << 'action-' + action_name
551 558 css.join(' ')
552 559 end
553 560
554 561 def accesskey(s)
555 562 @used_accesskeys ||= []
556 563 key = Redmine::AccessKeys.key_for(s)
557 564 return nil if @used_accesskeys.include?(key)
558 565 @used_accesskeys << key
559 566 key
560 567 end
561 568
562 569 # Formats text according to system settings.
563 570 # 2 ways to call this method:
564 571 # * with a String: textilizable(text, options)
565 572 # * with an object and one of its attribute: textilizable(issue, :description, options)
566 573 def textilizable(*args)
567 574 options = args.last.is_a?(Hash) ? args.pop : {}
568 575 case args.size
569 576 when 1
570 577 obj = options[:object]
571 578 text = args.shift
572 579 when 2
573 580 obj = args.shift
574 581 attr = args.shift
575 582 text = obj.send(attr).to_s
576 583 else
577 584 raise ArgumentError, 'invalid arguments to textilizable'
578 585 end
579 586 return '' if text.blank?
580 587 project = options[:project] || @project || (obj && obj.respond_to?(:project) ? obj.project : nil)
581 588 @only_path = only_path = options.delete(:only_path) == false ? false : true
582 589
583 590 text = text.dup
584 591 macros = catch_macros(text)
585 592 text = Redmine::WikiFormatting.to_html(Setting.text_formatting, text, :object => obj, :attribute => attr)
586 593
587 594 @parsed_headings = []
588 595 @heading_anchors = {}
589 596 @current_section = 0 if options[:edit_section_links]
590 597
591 598 parse_sections(text, project, obj, attr, only_path, options)
592 599 text = parse_non_pre_blocks(text, obj, macros) do |text|
593 600 [:parse_inline_attachments, :parse_wiki_links, :parse_redmine_links].each do |method_name|
594 601 send method_name, text, project, obj, attr, only_path, options
595 602 end
596 603 end
597 604 parse_headings(text, project, obj, attr, only_path, options)
598 605
599 606 if @parsed_headings.any?
600 607 replace_toc(text, @parsed_headings)
601 608 end
602 609
603 610 text.html_safe
604 611 end
605 612
606 613 def parse_non_pre_blocks(text, obj, macros)
607 614 s = StringScanner.new(text)
608 615 tags = []
609 616 parsed = ''
610 617 while !s.eos?
611 618 s.scan(/(.*?)(<(\/)?(pre|code)(.*?)>|\z)/im)
612 619 text, full_tag, closing, tag = s[1], s[2], s[3], s[4]
613 620 if tags.empty?
614 621 yield text
615 622 inject_macros(text, obj, macros) if macros.any?
616 623 else
617 624 inject_macros(text, obj, macros, false) if macros.any?
618 625 end
619 626 parsed << text
620 627 if tag
621 628 if closing
622 629 if tags.last == tag.downcase
623 630 tags.pop
624 631 end
625 632 else
626 633 tags << tag.downcase
627 634 end
628 635 parsed << full_tag
629 636 end
630 637 end
631 638 # Close any non closing tags
632 639 while tag = tags.pop
633 640 parsed << "</#{tag}>"
634 641 end
635 642 parsed
636 643 end
637 644
638 645 def parse_inline_attachments(text, project, obj, attr, only_path, options)
639 646 # when using an image link, try to use an attachment, if possible
640 647 attachments = options[:attachments] || []
641 648 attachments += obj.attachments if obj.respond_to?(:attachments)
642 649 if attachments.present?
643 650 text.gsub!(/src="([^\/"]+\.(bmp|gif|jpg|jpe|jpeg|png))"(\s+alt="([^"]*)")?/i) do |m|
644 651 filename, ext, alt, alttext = $1.downcase, $2, $3, $4
645 652 # search for the picture in attachments
646 653 if found = Attachment.latest_attach(attachments, filename)
647 654 image_url = download_named_attachment_path(found, found.filename, :only_path => only_path)
648 655 desc = found.description.to_s.gsub('"', '')
649 656 if !desc.blank? && alttext.blank?
650 657 alt = " title=\"#{desc}\" alt=\"#{desc}\""
651 658 end
652 659 "src=\"#{image_url}\"#{alt}"
653 660 else
654 661 m
655 662 end
656 663 end
657 664 end
658 665 end
659 666
660 667 # Wiki links
661 668 #
662 669 # Examples:
663 670 # [[mypage]]
664 671 # [[mypage|mytext]]
665 672 # wiki links can refer other project wikis, using project name or identifier:
666 673 # [[project:]] -> wiki starting page
667 674 # [[project:|mytext]]
668 675 # [[project:mypage]]
669 676 # [[project:mypage|mytext]]
670 677 def parse_wiki_links(text, project, obj, attr, only_path, options)
671 678 text.gsub!(/(!)?(\[\[([^\]\n\|]+)(\|([^\]\n\|]+))?\]\])/) do |m|
672 679 link_project = project
673 680 esc, all, page, title = $1, $2, $3, $5
674 681 if esc.nil?
675 682 if page =~ /^([^\:]+)\:(.*)$/
676 683 identifier, page = $1, $2
677 684 link_project = Project.find_by_identifier(identifier) || Project.find_by_name(identifier)
678 685 title ||= identifier if page.blank?
679 686 end
680 687
681 688 if link_project && link_project.wiki
682 689 # extract anchor
683 690 anchor = nil
684 691 if page =~ /^(.+?)\#(.+)$/
685 692 page, anchor = $1, $2
686 693 end
687 694 anchor = sanitize_anchor_name(anchor) if anchor.present?
688 695 # check if page exists
689 696 wiki_page = link_project.wiki.find_page(page)
690 697 url = if anchor.present? && wiki_page.present? && (obj.is_a?(WikiContent) || obj.is_a?(WikiContent::Version)) && obj.page == wiki_page
691 698 "##{anchor}"
692 699 else
693 700 case options[:wiki_links]
694 701 when :local; "#{page.present? ? Wiki.titleize(page) : ''}.html" + (anchor.present? ? "##{anchor}" : '')
695 702 when :anchor; "##{page.present? ? Wiki.titleize(page) : title}" + (anchor.present? ? "_#{anchor}" : '') # used for single-file wiki export
696 703 else
697 704 wiki_page_id = page.present? ? Wiki.titleize(page) : nil
698 705 parent = wiki_page.nil? && obj.is_a?(WikiContent) && obj.page && project == link_project ? obj.page.title : nil
699 706 url_for(:only_path => only_path, :controller => 'wiki', :action => 'show', :project_id => link_project,
700 707 :id => wiki_page_id, :version => nil, :anchor => anchor, :parent => parent)
701 708 end
702 709 end
703 710 link_to(title.present? ? title.html_safe : h(page), url, :class => ('wiki-page' + (wiki_page ? '' : ' new')))
704 711 else
705 712 # project or wiki doesn't exist
706 713 all
707 714 end
708 715 else
709 716 all
710 717 end
711 718 end
712 719 end
713 720
714 721 # Redmine links
715 722 #
716 723 # Examples:
717 724 # Issues:
718 725 # #52 -> Link to issue #52
719 726 # Changesets:
720 727 # r52 -> Link to revision 52
721 728 # commit:a85130f -> Link to scmid starting with a85130f
722 729 # Documents:
723 730 # document#17 -> Link to document with id 17
724 731 # document:Greetings -> Link to the document with title "Greetings"
725 732 # document:"Some document" -> Link to the document with title "Some document"
726 733 # Versions:
727 734 # version#3 -> Link to version with id 3
728 735 # version:1.0.0 -> Link to version named "1.0.0"
729 736 # version:"1.0 beta 2" -> Link to version named "1.0 beta 2"
730 737 # Attachments:
731 738 # attachment:file.zip -> Link to the attachment of the current object named file.zip
732 739 # Source files:
733 740 # source:some/file -> Link to the file located at /some/file in the project's repository
734 741 # source:some/file@52 -> Link to the file's revision 52
735 742 # source:some/file#L120 -> Link to line 120 of the file
736 743 # source:some/file@52#L120 -> Link to line 120 of the file's revision 52
737 744 # export:some/file -> Force the download of the file
738 745 # Forum messages:
739 746 # message#1218 -> Link to message with id 1218
740 747 # Projects:
741 748 # project:someproject -> Link to project named "someproject"
742 749 # project#3 -> Link to project with id 3
743 750 #
744 751 # Links can refer other objects from other projects, using project identifier:
745 752 # identifier:r52
746 753 # identifier:document:"Some document"
747 754 # identifier:version:1.0.0
748 755 # identifier:source:some/file
749 756 def parse_redmine_links(text, default_project, obj, attr, only_path, options)
750 757 text.gsub!(%r{([\s\(,\-\[\>]|^)(!)?(([a-z0-9\-_]+):)?(attachment|document|version|forum|news|message|project|commit|source|export)?(((#)|((([a-z0-9\-_]+)\|)?(r)))((\d+)((#note)?-(\d+))?)|(:)([^"\s<>][^\s<>]*?|"[^"]+?"))(?=(?=[[:punct:]][^A-Za-z0-9_/])|,|\s|\]|<|$)}) do |m|
751 758 leading, esc, project_prefix, project_identifier, prefix, repo_prefix, repo_identifier, sep, identifier, comment_suffix, comment_id = $1, $2, $3, $4, $5, $10, $11, $8 || $12 || $18, $14 || $19, $15, $17
752 759 link = nil
753 760 project = default_project
754 761 if project_identifier
755 762 project = Project.visible.find_by_identifier(project_identifier)
756 763 end
757 764 if esc.nil?
758 765 if prefix.nil? && sep == 'r'
759 766 if project
760 767 repository = nil
761 768 if repo_identifier
762 769 repository = project.repositories.detect {|repo| repo.identifier == repo_identifier}
763 770 else
764 771 repository = project.repository
765 772 end
766 773 # project.changesets.visible raises an SQL error because of a double join on repositories
767 774 if repository &&
768 775 (changeset = Changeset.visible.
769 776 find_by_repository_id_and_revision(repository.id, identifier))
770 777 link = link_to(h("#{project_prefix}#{repo_prefix}r#{identifier}"),
771 778 {:only_path => only_path, :controller => 'repositories',
772 779 :action => 'revision', :id => project,
773 780 :repository_id => repository.identifier_param,
774 781 :rev => changeset.revision},
775 782 :class => 'changeset',
776 783 :title => truncate_single_line_raw(changeset.comments, 100))
777 784 end
778 785 end
779 786 elsif sep == '#'
780 787 oid = identifier.to_i
781 788 case prefix
782 789 when nil
783 790 if oid.to_s == identifier &&
784 791 issue = Issue.visible.includes(:status).find_by_id(oid)
785 792 anchor = comment_id ? "note-#{comment_id}" : nil
786 793 link = link_to(h("##{oid}#{comment_suffix}"),
787 794 {:only_path => only_path, :controller => 'issues',
788 795 :action => 'show', :id => oid, :anchor => anchor},
789 796 :class => issue.css_classes,
790 797 :title => "#{issue.subject.truncate(100)} (#{issue.status.name})")
791 798 end
792 799 when 'document'
793 800 if document = Document.visible.find_by_id(oid)
794 801 link = link_to h(document.title), {:only_path => only_path, :controller => 'documents', :action => 'show', :id => document},
795 802 :class => 'document'
796 803 end
797 804 when 'version'
798 805 if version = Version.visible.find_by_id(oid)
799 806 link = link_to h(version.name), {:only_path => only_path, :controller => 'versions', :action => 'show', :id => version},
800 807 :class => 'version'
801 808 end
802 809 when 'message'
803 810 if message = Message.visible.includes(:parent).find_by_id(oid)
804 811 link = link_to_message(message, {:only_path => only_path}, :class => 'message')
805 812 end
806 813 when 'forum'
807 814 if board = Board.visible.find_by_id(oid)
808 815 link = link_to h(board.name), {:only_path => only_path, :controller => 'boards', :action => 'show', :id => board, :project_id => board.project},
809 816 :class => 'board'
810 817 end
811 818 when 'news'
812 819 if news = News.visible.find_by_id(oid)
813 820 link = link_to h(news.title), {:only_path => only_path, :controller => 'news', :action => 'show', :id => news},
814 821 :class => 'news'
815 822 end
816 823 when 'project'
817 824 if p = Project.visible.find_by_id(oid)
818 825 link = link_to_project(p, {:only_path => only_path}, :class => 'project')
819 826 end
820 827 end
821 828 elsif sep == ':'
822 829 # removes the double quotes if any
823 830 name = identifier.gsub(%r{^"(.*)"$}, "\\1")
824 831 name = CGI.unescapeHTML(name)
825 832 case prefix
826 833 when 'document'
827 834 if project && document = project.documents.visible.find_by_title(name)
828 835 link = link_to h(document.title), {:only_path => only_path, :controller => 'documents', :action => 'show', :id => document},
829 836 :class => 'document'
830 837 end
831 838 when 'version'
832 839 if project && version = project.versions.visible.find_by_name(name)
833 840 link = link_to h(version.name), {:only_path => only_path, :controller => 'versions', :action => 'show', :id => version},
834 841 :class => 'version'
835 842 end
836 843 when 'forum'
837 844 if project && board = project.boards.visible.find_by_name(name)
838 845 link = link_to h(board.name), {:only_path => only_path, :controller => 'boards', :action => 'show', :id => board, :project_id => board.project},
839 846 :class => 'board'
840 847 end
841 848 when 'news'
842 849 if project && news = project.news.visible.find_by_title(name)
843 850 link = link_to h(news.title), {:only_path => only_path, :controller => 'news', :action => 'show', :id => news},
844 851 :class => 'news'
845 852 end
846 853 when 'commit', 'source', 'export'
847 854 if project
848 855 repository = nil
849 856 if name =~ %r{^(([a-z0-9\-_]+)\|)(.+)$}
850 857 repo_prefix, repo_identifier, name = $1, $2, $3
851 858 repository = project.repositories.detect {|repo| repo.identifier == repo_identifier}
852 859 else
853 860 repository = project.repository
854 861 end
855 862 if prefix == 'commit'
856 863 if repository && (changeset = Changeset.visible.where("repository_id = ? AND scmid LIKE ?", repository.id, "#{name}%").first)
857 864 link = link_to h("#{project_prefix}#{repo_prefix}#{name}"), {:only_path => only_path, :controller => 'repositories', :action => 'revision', :id => project, :repository_id => repository.identifier_param, :rev => changeset.identifier},
858 865 :class => 'changeset',
859 866 :title => truncate_single_line_raw(changeset.comments, 100)
860 867 end
861 868 else
862 869 if repository && User.current.allowed_to?(:browse_repository, project)
863 870 name =~ %r{^[/\\]*(.*?)(@([^/\\@]+?))?(#(L\d+))?$}
864 871 path, rev, anchor = $1, $3, $5
865 872 link = link_to h("#{project_prefix}#{prefix}:#{repo_prefix}#{name}"), {:only_path => only_path, :controller => 'repositories', :action => (prefix == 'export' ? 'raw' : 'entry'), :id => project, :repository_id => repository.identifier_param,
866 873 :path => to_path_param(path),
867 874 :rev => rev,
868 875 :anchor => anchor},
869 876 :class => (prefix == 'export' ? 'source download' : 'source')
870 877 end
871 878 end
872 879 repo_prefix = nil
873 880 end
874 881 when 'attachment'
875 882 attachments = options[:attachments] || []
876 883 attachments += obj.attachments if obj.respond_to?(:attachments)
877 884 if attachments && attachment = Attachment.latest_attach(attachments, name)
878 885 link = link_to_attachment(attachment, :only_path => only_path, :download => true, :class => 'attachment')
879 886 end
880 887 when 'project'
881 888 if p = Project.visible.where("identifier = :s OR LOWER(name) = :s", :s => name.downcase).first
882 889 link = link_to_project(p, {:only_path => only_path}, :class => 'project')
883 890 end
884 891 end
885 892 end
886 893 end
887 894 (leading + (link || "#{project_prefix}#{prefix}#{repo_prefix}#{sep}#{identifier}#{comment_suffix}"))
888 895 end
889 896 end
890 897
891 898 HEADING_RE = /(<h(\d)( [^>]+)?>(.+?)<\/h(\d)>)/i unless const_defined?(:HEADING_RE)
892 899
893 900 def parse_sections(text, project, obj, attr, only_path, options)
894 901 return unless options[:edit_section_links]
895 902 text.gsub!(HEADING_RE) do
896 903 heading = $1
897 904 @current_section += 1
898 905 if @current_section > 1
899 906 content_tag('div',
900 907 link_to(image_tag('edit.png'), options[:edit_section_links].merge(:section => @current_section)),
901 908 :class => 'contextual',
902 909 :title => l(:button_edit_section),
903 910 :id => "section-#{@current_section}") + heading.html_safe
904 911 else
905 912 heading
906 913 end
907 914 end
908 915 end
909 916
910 917 # Headings and TOC
911 918 # Adds ids and links to headings unless options[:headings] is set to false
912 919 def parse_headings(text, project, obj, attr, only_path, options)
913 920 return if options[:headings] == false
914 921
915 922 text.gsub!(HEADING_RE) do
916 923 level, attrs, content = $2.to_i, $3, $4
917 924 item = strip_tags(content).strip
918 925 anchor = sanitize_anchor_name(item)
919 926 # used for single-file wiki export
920 927 anchor = "#{obj.page.title}_#{anchor}" if options[:wiki_links] == :anchor && (obj.is_a?(WikiContent) || obj.is_a?(WikiContent::Version))
921 928 @heading_anchors[anchor] ||= 0
922 929 idx = (@heading_anchors[anchor] += 1)
923 930 if idx > 1
924 931 anchor = "#{anchor}-#{idx}"
925 932 end
926 933 @parsed_headings << [level, anchor, item]
927 934 "<a name=\"#{anchor}\"></a>\n<h#{level} #{attrs}>#{content}<a href=\"##{anchor}\" class=\"wiki-anchor\">&para;</a></h#{level}>"
928 935 end
929 936 end
930 937
931 938 MACROS_RE = /(
932 939 (!)? # escaping
933 940 (
934 941 \{\{ # opening tag
935 942 ([\w]+) # macro name
936 943 (\(([^\n\r]*?)\))? # optional arguments
937 944 ([\n\r].*?[\n\r])? # optional block of text
938 945 \}\} # closing tag
939 946 )
940 947 )/mx unless const_defined?(:MACROS_RE)
941 948
942 949 MACRO_SUB_RE = /(
943 950 \{\{
944 951 macro\((\d+)\)
945 952 \}\}
946 953 )/x unless const_defined?(:MACRO_SUB_RE)
947 954
948 955 # Extracts macros from text
949 956 def catch_macros(text)
950 957 macros = {}
951 958 text.gsub!(MACROS_RE) do
952 959 all, macro = $1, $4.downcase
953 960 if macro_exists?(macro) || all =~ MACRO_SUB_RE
954 961 index = macros.size
955 962 macros[index] = all
956 963 "{{macro(#{index})}}"
957 964 else
958 965 all
959 966 end
960 967 end
961 968 macros
962 969 end
963 970
964 971 # Executes and replaces macros in text
965 972 def inject_macros(text, obj, macros, execute=true)
966 973 text.gsub!(MACRO_SUB_RE) do
967 974 all, index = $1, $2.to_i
968 975 orig = macros.delete(index)
969 976 if execute && orig && orig =~ MACROS_RE
970 977 esc, all, macro, args, block = $2, $3, $4.downcase, $6.to_s, $7.try(:strip)
971 978 if esc.nil?
972 979 h(exec_macro(macro, obj, args, block) || all)
973 980 else
974 981 h(all)
975 982 end
976 983 elsif orig
977 984 h(orig)
978 985 else
979 986 h(all)
980 987 end
981 988 end
982 989 end
983 990
984 991 TOC_RE = /<p>\{\{((<|&lt;)|(>|&gt;))?toc\}\}<\/p>/i unless const_defined?(:TOC_RE)
985 992
986 993 # Renders the TOC with given headings
987 994 def replace_toc(text, headings)
988 995 text.gsub!(TOC_RE) do
989 996 left_align, right_align = $2, $3
990 997 # Keep only the 4 first levels
991 998 headings = headings.select{|level, anchor, item| level <= 4}
992 999 if headings.empty?
993 1000 ''
994 1001 else
995 1002 div_class = 'toc'
996 1003 div_class << ' right' if right_align
997 1004 div_class << ' left' if left_align
998 1005 out = "<ul class=\"#{div_class}\"><li>"
999 1006 root = headings.map(&:first).min
1000 1007 current = root
1001 1008 started = false
1002 1009 headings.each do |level, anchor, item|
1003 1010 if level > current
1004 1011 out << '<ul><li>' * (level - current)
1005 1012 elsif level < current
1006 1013 out << "</li></ul>\n" * (current - level) + "</li><li>"
1007 1014 elsif started
1008 1015 out << '</li><li>'
1009 1016 end
1010 1017 out << "<a href=\"##{anchor}\">#{item}</a>"
1011 1018 current = level
1012 1019 started = true
1013 1020 end
1014 1021 out << '</li></ul>' * (current - root)
1015 1022 out << '</li></ul>'
1016 1023 end
1017 1024 end
1018 1025 end
1019 1026
1020 1027 # Same as Rails' simple_format helper without using paragraphs
1021 1028 def simple_format_without_paragraph(text)
1022 1029 text.to_s.
1023 1030 gsub(/\r\n?/, "\n"). # \r\n and \r -> \n
1024 1031 gsub(/\n\n+/, "<br /><br />"). # 2+ newline -> 2 br
1025 1032 gsub(/([^\n]\n)(?=[^\n])/, '\1<br />'). # 1 newline -> br
1026 1033 html_safe
1027 1034 end
1028 1035
1029 1036 def lang_options_for_select(blank=true)
1030 1037 (blank ? [["(auto)", ""]] : []) + languages_options
1031 1038 end
1032 1039
1033 1040 def label_tag_for(name, option_tags = nil, options = {})
1034 1041 label_text = l(("field_"+field.to_s.gsub(/\_id$/, "")).to_sym) + (options.delete(:required) ? @template.content_tag("span", " *", :class => "required"): "")
1035 1042 content_tag("label", label_text)
1036 1043 end
1037 1044
1038 1045 def labelled_form_for(*args, &proc)
1039 1046 args << {} unless args.last.is_a?(Hash)
1040 1047 options = args.last
1041 1048 if args.first.is_a?(Symbol)
1042 1049 options.merge!(:as => args.shift)
1043 1050 end
1044 1051 options.merge!({:builder => Redmine::Views::LabelledFormBuilder})
1045 1052 form_for(*args, &proc)
1046 1053 end
1047 1054
1048 1055 def labelled_fields_for(*args, &proc)
1049 1056 args << {} unless args.last.is_a?(Hash)
1050 1057 options = args.last
1051 1058 options.merge!({:builder => Redmine::Views::LabelledFormBuilder})
1052 1059 fields_for(*args, &proc)
1053 1060 end
1054 1061
1055 1062 def error_messages_for(*objects)
1056 1063 html = ""
1057 1064 objects = objects.map {|o| o.is_a?(String) ? instance_variable_get("@#{o}") : o}.compact
1058 1065 errors = objects.map {|o| o.errors.full_messages}.flatten
1059 1066 if errors.any?
1060 1067 html << "<div id='errorExplanation'><ul>\n"
1061 1068 errors.each do |error|
1062 1069 html << "<li>#{h error}</li>\n"
1063 1070 end
1064 1071 html << "</ul></div>\n"
1065 1072 end
1066 1073 html.html_safe
1067 1074 end
1068 1075
1069 1076 def delete_link(url, options={})
1070 1077 options = {
1071 1078 :method => :delete,
1072 1079 :data => {:confirm => l(:text_are_you_sure)},
1073 1080 :class => 'icon icon-del'
1074 1081 }.merge(options)
1075 1082
1076 1083 link_to l(:button_delete), url, options
1077 1084 end
1078 1085
1079 1086 def preview_link(url, form, target='preview', options={})
1080 1087 content_tag 'a', l(:label_preview), {
1081 1088 :href => "#",
1082 1089 :onclick => %|submitPreview("#{escape_javascript url_for(url)}", "#{escape_javascript form}", "#{escape_javascript target}"); return false;|,
1083 1090 :accesskey => accesskey(:preview)
1084 1091 }.merge(options)
1085 1092 end
1086 1093
1087 1094 def link_to_function(name, function, html_options={})
1088 1095 content_tag(:a, name, {:href => '#', :onclick => "#{function}; return false;"}.merge(html_options))
1089 1096 end
1090 1097
1091 1098 # Helper to render JSON in views
1092 1099 def raw_json(arg)
1093 1100 arg.to_json.to_s.gsub('/', '\/').html_safe
1094 1101 end
1095 1102
1096 1103 def back_url
1097 1104 url = params[:back_url]
1098 1105 if url.nil? && referer = request.env['HTTP_REFERER']
1099 1106 url = CGI.unescape(referer.to_s)
1100 1107 end
1101 1108 url
1102 1109 end
1103 1110
1104 1111 def back_url_hidden_field_tag
1105 1112 url = back_url
1106 1113 hidden_field_tag('back_url', url, :id => nil) unless url.blank?
1107 1114 end
1108 1115
1109 1116 def check_all_links(form_name)
1110 1117 link_to_function(l(:button_check_all), "checkAll('#{form_name}', true)") +
1111 1118 " | ".html_safe +
1112 1119 link_to_function(l(:button_uncheck_all), "checkAll('#{form_name}', false)")
1113 1120 end
1114 1121
1115 1122 def progress_bar(pcts, options={})
1116 1123 pcts = [pcts, pcts] unless pcts.is_a?(Array)
1117 1124 pcts = pcts.collect(&:round)
1118 1125 pcts[1] = pcts[1] - pcts[0]
1119 1126 pcts << (100 - pcts[1] - pcts[0])
1120 1127 width = options[:width] || '100px;'
1121 1128 legend = options[:legend] || ''
1122 1129 content_tag('table',
1123 1130 content_tag('tr',
1124 1131 (pcts[0] > 0 ? content_tag('td', '', :style => "width: #{pcts[0]}%;", :class => 'closed') : ''.html_safe) +
1125 1132 (pcts[1] > 0 ? content_tag('td', '', :style => "width: #{pcts[1]}%;", :class => 'done') : ''.html_safe) +
1126 1133 (pcts[2] > 0 ? content_tag('td', '', :style => "width: #{pcts[2]}%;", :class => 'todo') : ''.html_safe)
1127 1134 ), :class => "progress progress-#{pcts[0]}", :style => "width: #{width};").html_safe +
1128 1135 content_tag('p', legend, :class => 'percent').html_safe
1129 1136 end
1130 1137
1131 1138 def checked_image(checked=true)
1132 1139 if checked
1133 1140 image_tag 'toggle_check.png'
1134 1141 end
1135 1142 end
1136 1143
1137 1144 def context_menu(url)
1138 1145 unless @context_menu_included
1139 1146 content_for :header_tags do
1140 1147 javascript_include_tag('context_menu') +
1141 1148 stylesheet_link_tag('context_menu')
1142 1149 end
1143 1150 if l(:direction) == 'rtl'
1144 1151 content_for :header_tags do
1145 1152 stylesheet_link_tag('context_menu_rtl')
1146 1153 end
1147 1154 end
1148 1155 @context_menu_included = true
1149 1156 end
1150 1157 javascript_tag "contextMenuInit('#{ url_for(url) }')"
1151 1158 end
1152 1159
1153 1160 def calendar_for(field_id)
1154 1161 include_calendar_headers_tags
1155 1162 javascript_tag("$(function() { $('##{field_id}').datepicker(datepickerOptions); });")
1156 1163 end
1157 1164
1158 1165 def include_calendar_headers_tags
1159 1166 unless @calendar_headers_tags_included
1160 1167 tags = javascript_include_tag("datepicker")
1161 1168 @calendar_headers_tags_included = true
1162 1169 content_for :header_tags do
1163 1170 start_of_week = Setting.start_of_week
1164 1171 start_of_week = l(:general_first_day_of_week, :default => '1') if start_of_week.blank?
1165 1172 # Redmine uses 1..7 (monday..sunday) in settings and locales
1166 1173 # JQuery uses 0..6 (sunday..saturday), 7 needs to be changed to 0
1167 1174 start_of_week = start_of_week.to_i % 7
1168 1175 tags << javascript_tag(
1169 1176 "var datepickerOptions={dateFormat: 'yy-mm-dd', firstDay: #{start_of_week}, " +
1170 1177 "showOn: 'button', buttonImageOnly: true, buttonImage: '" +
1171 1178 path_to_image('/images/calendar.png') +
1172 1179 "', showButtonPanel: true, showWeek: true, showOtherMonths: true, " +
1173 1180 "selectOtherMonths: true, changeMonth: true, changeYear: true, " +
1174 1181 "beforeShow: beforeShowDatePicker};")
1175 1182 jquery_locale = l('jquery.locale', :default => current_language.to_s)
1176 1183 unless jquery_locale == 'en'
1177 1184 tags << javascript_include_tag("i18n/datepicker-#{jquery_locale}.js")
1178 1185 end
1179 1186 tags
1180 1187 end
1181 1188 end
1182 1189 end
1183 1190
1184 1191 # Overrides Rails' stylesheet_link_tag with themes and plugins support.
1185 1192 # Examples:
1186 1193 # stylesheet_link_tag('styles') # => picks styles.css from the current theme or defaults
1187 1194 # stylesheet_link_tag('styles', :plugin => 'foo) # => picks styles.css from plugin's assets
1188 1195 #
1189 1196 def stylesheet_link_tag(*sources)
1190 1197 options = sources.last.is_a?(Hash) ? sources.pop : {}
1191 1198 plugin = options.delete(:plugin)
1192 1199 sources = sources.map do |source|
1193 1200 if plugin
1194 1201 "/plugin_assets/#{plugin}/stylesheets/#{source}"
1195 1202 elsif current_theme && current_theme.stylesheets.include?(source)
1196 1203 current_theme.stylesheet_path(source)
1197 1204 else
1198 1205 source
1199 1206 end
1200 1207 end
1201 1208 super *sources, options
1202 1209 end
1203 1210
1204 1211 # Overrides Rails' image_tag with themes and plugins support.
1205 1212 # Examples:
1206 1213 # image_tag('image.png') # => picks image.png from the current theme or defaults
1207 1214 # image_tag('image.png', :plugin => 'foo) # => picks image.png from plugin's assets
1208 1215 #
1209 1216 def image_tag(source, options={})
1210 1217 if plugin = options.delete(:plugin)
1211 1218 source = "/plugin_assets/#{plugin}/images/#{source}"
1212 1219 elsif current_theme && current_theme.images.include?(source)
1213 1220 source = current_theme.image_path(source)
1214 1221 end
1215 1222 super source, options
1216 1223 end
1217 1224
1218 1225 # Overrides Rails' javascript_include_tag with plugins support
1219 1226 # Examples:
1220 1227 # javascript_include_tag('scripts') # => picks scripts.js from defaults
1221 1228 # javascript_include_tag('scripts', :plugin => 'foo) # => picks scripts.js from plugin's assets
1222 1229 #
1223 1230 def javascript_include_tag(*sources)
1224 1231 options = sources.last.is_a?(Hash) ? sources.pop : {}
1225 1232 if plugin = options.delete(:plugin)
1226 1233 sources = sources.map do |source|
1227 1234 if plugin
1228 1235 "/plugin_assets/#{plugin}/javascripts/#{source}"
1229 1236 else
1230 1237 source
1231 1238 end
1232 1239 end
1233 1240 end
1234 1241 super *sources, options
1235 1242 end
1236 1243
1237 1244 # TODO: remove this in 2.5.0
1238 1245 def has_content?(name)
1239 1246 content_for?(name)
1240 1247 end
1241 1248
1242 1249 def sidebar_content?
1243 1250 content_for?(:sidebar) || view_layouts_base_sidebar_hook_response.present?
1244 1251 end
1245 1252
1246 1253 def view_layouts_base_sidebar_hook_response
1247 1254 @view_layouts_base_sidebar_hook_response ||= call_hook(:view_layouts_base_sidebar)
1248 1255 end
1249 1256
1250 1257 def email_delivery_enabled?
1251 1258 !!ActionMailer::Base.perform_deliveries
1252 1259 end
1253 1260
1254 1261 # Returns the avatar image tag for the given +user+ if avatars are enabled
1255 1262 # +user+ can be a User or a string that will be scanned for an email address (eg. 'joe <joe@foo.bar>')
1256 1263 def avatar(user, options = { })
1257 1264 if Setting.gravatar_enabled?
1258 1265 options.merge!({:ssl => (request && request.ssl?), :default => Setting.gravatar_default})
1259 1266 email = nil
1260 1267 if user.respond_to?(:mail)
1261 1268 email = user.mail
1262 1269 elsif user.to_s =~ %r{<(.+?)>}
1263 1270 email = $1
1264 1271 end
1265 1272 return gravatar(email.to_s.downcase, options) unless email.blank? rescue nil
1266 1273 else
1267 1274 ''
1268 1275 end
1269 1276 end
1270 1277
1271 1278 def sanitize_anchor_name(anchor)
1272 1279 anchor.gsub(%r{[^\s\-\p{Word}]}, '').gsub(%r{\s+(\-+\s*)?}, '-')
1273 1280 end
1274 1281
1275 1282 # Returns the javascript tags that are included in the html layout head
1276 1283 def javascript_heads
1277 1284 tags = javascript_include_tag('jquery-1.11.1-ui-1.11.0-ujs-3.1.1', 'application')
1278 1285 unless User.current.pref.warn_on_leaving_unsaved == '0'
1279 1286 tags << "\n".html_safe + javascript_tag("$(window).load(function(){ warnLeavingUnsaved('#{escape_javascript l(:text_warn_on_leaving_unsaved)}'); });")
1280 1287 end
1281 1288 tags
1282 1289 end
1283 1290
1284 1291 def favicon
1285 1292 "<link rel='shortcut icon' href='#{favicon_path}' />".html_safe
1286 1293 end
1287 1294
1288 1295 # Returns the path to the favicon
1289 1296 def favicon_path
1290 1297 icon = (current_theme && current_theme.favicon?) ? current_theme.favicon_path : '/favicon.ico'
1291 1298 image_path(icon)
1292 1299 end
1293 1300
1294 1301 # Returns the full URL to the favicon
1295 1302 def favicon_url
1296 1303 # TODO: use #image_url introduced in Rails4
1297 1304 path = favicon_path
1298 1305 base = url_for(:controller => 'welcome', :action => 'index', :only_path => false)
1299 1306 base.sub(%r{/+$},'') + '/' + path.sub(%r{^/+},'')
1300 1307 end
1301 1308
1302 1309 def robot_exclusion_tag
1303 1310 '<meta name="robots" content="noindex,follow,noarchive" />'.html_safe
1304 1311 end
1305 1312
1306 1313 # Returns true if arg is expected in the API response
1307 1314 def include_in_api_response?(arg)
1308 1315 unless @included_in_api_response
1309 1316 param = params[:include]
1310 1317 @included_in_api_response = param.is_a?(Array) ? param.collect(&:to_s) : param.to_s.split(',')
1311 1318 @included_in_api_response.collect!(&:strip)
1312 1319 end
1313 1320 @included_in_api_response.include?(arg.to_s)
1314 1321 end
1315 1322
1316 1323 # Returns options or nil if nometa param or X-Redmine-Nometa header
1317 1324 # was set in the request
1318 1325 def api_meta(options)
1319 1326 if params[:nometa].present? || request.headers['X-Redmine-Nometa']
1320 1327 # compatibility mode for activeresource clients that raise
1321 1328 # an error when deserializing an array with attributes
1322 1329 nil
1323 1330 else
1324 1331 options
1325 1332 end
1326 1333 end
1327 1334
1328 1335 private
1329 1336
1330 1337 def wiki_helper
1331 1338 helper = Redmine::WikiFormatting.helper_for(Setting.text_formatting)
1332 1339 extend helper
1333 1340 return self
1334 1341 end
1335 1342
1336 1343 def link_to_content_update(text, url_params = {}, html_options = {})
1337 1344 link_to(text, url_params, html_options)
1338 1345 end
1339 1346 end
@@ -1,120 +1,115
1 1 # encoding: utf-8
2 2 #
3 3 # Redmine - project management software
4 4 # Copyright (C) 2006-2014 Jean-Philippe Lang
5 5 #
6 6 # This program is free software; you can redistribute it and/or
7 7 # modify it under the terms of the GNU General Public License
8 8 # as published by the Free Software Foundation; either version 2
9 9 # of the License, or (at your option) any later version.
10 10 #
11 11 # This program is distributed in the hope that it will be useful,
12 12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 14 # GNU General Public License for more details.
15 15 #
16 16 # You should have received a copy of the GNU General Public License
17 17 # along with this program; if not, write to the Free Software
18 18 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19 19
20 20 module ProjectsHelper
21 def link_to_version(version, options = {})
22 return '' unless version && version.is_a?(Version)
23 link_to_if version.visible?, format_version_name(version), version_path(version), options
24 end
25
26 21 def project_settings_tabs
27 22 tabs = [{:name => 'info', :action => :edit_project, :partial => 'projects/edit', :label => :label_information_plural},
28 23 {:name => 'modules', :action => :select_project_modules, :partial => 'projects/settings/modules', :label => :label_module_plural},
29 24 {:name => 'members', :action => :manage_members, :partial => 'projects/settings/members', :label => :label_member_plural},
30 25 {:name => 'versions', :action => :manage_versions, :partial => 'projects/settings/versions', :label => :label_version_plural},
31 26 {:name => 'categories', :action => :manage_categories, :partial => 'projects/settings/issue_categories', :label => :label_issue_category_plural},
32 27 {:name => 'wiki', :action => :manage_wiki, :partial => 'projects/settings/wiki', :label => :label_wiki},
33 28 {:name => 'repositories', :action => :manage_repository, :partial => 'projects/settings/repositories', :label => :label_repository_plural},
34 29 {:name => 'boards', :action => :manage_boards, :partial => 'projects/settings/boards', :label => :label_board_plural},
35 30 {:name => 'activities', :action => :manage_project_activities, :partial => 'projects/settings/activities', :label => :enumeration_activities}
36 31 ]
37 32 tabs.select {|tab| User.current.allowed_to?(tab[:action], @project)}
38 33 end
39 34
40 35 def parent_project_select_tag(project)
41 36 selected = project.parent
42 37 # retrieve the requested parent project
43 38 parent_id = (params[:project] && params[:project][:parent_id]) || params[:parent_id]
44 39 if parent_id
45 40 selected = (parent_id.blank? ? nil : Project.find(parent_id))
46 41 end
47 42
48 43 options = ''
49 44 options << "<option value=''>&nbsp;</option>" if project.allowed_parents.include?(nil)
50 45 options << project_tree_options_for_select(project.allowed_parents.compact, :selected => selected)
51 46 content_tag('select', options.html_safe, :name => 'project[parent_id]', :id => 'project_parent_id')
52 47 end
53 48
54 49 def render_project_action_links
55 50 links = []
56 51 if User.current.allowed_to?(:add_project, nil, :global => true)
57 52 links << link_to(l(:label_project_new), new_project_path, :class => 'icon icon-add')
58 53 end
59 54 if User.current.allowed_to?(:view_issues, nil, :global => true)
60 55 links << link_to(l(:label_issue_view_all), issues_path)
61 56 end
62 57 if User.current.allowed_to?(:view_time_entries, nil, :global => true)
63 58 links << link_to(l(:label_overall_spent_time), time_entries_path)
64 59 end
65 60 links << link_to(l(:label_overall_activity), activity_path)
66 61 links.join(" | ").html_safe
67 62 end
68 63
69 64 # Renders the projects index
70 65 def render_project_hierarchy(projects)
71 66 render_project_nested_lists(projects) do |project|
72 67 s = link_to_project(project, {}, :class => "#{project.css_classes} #{User.current.member_of?(project) ? 'my-project' : nil}")
73 68 if project.description.present?
74 69 s << content_tag('div', textilizable(project.short_description, :project => project), :class => 'wiki description')
75 70 end
76 71 s
77 72 end
78 73 end
79 74
80 75 # Returns a set of options for a select field, grouped by project.
81 76 def version_options_for_select(versions, selected=nil)
82 77 grouped = Hash.new {|h,k| h[k] = []}
83 78 versions.each do |version|
84 79 grouped[version.project.name] << [version.name, version.id]
85 80 end
86 81
87 82 selected = selected.is_a?(Version) ? selected.id : selected
88 83 if grouped.keys.size > 1
89 84 grouped_options_for_select(grouped, selected)
90 85 else
91 86 options_for_select((grouped.values.first || []), selected)
92 87 end
93 88 end
94 89
95 90 def format_version_sharing(sharing)
96 91 sharing = 'none' unless Version::VERSION_SHARINGS.include?(sharing)
97 92 l("label_version_sharing_#{sharing}")
98 93 end
99 94
100 95 def render_api_includes(project, api)
101 96 api.array :trackers do
102 97 project.trackers.each do |tracker|
103 98 api.tracker(:id => tracker.id, :name => tracker.name)
104 99 end
105 100 end if include_in_api_response?('trackers')
106 101
107 102 api.array :issue_categories do
108 103 project.issue_categories.each do |category|
109 104 api.issue_category(:id => category.id, :name => category.name)
110 105 end
111 106 end if include_in_api_response?('issue_categories')
112 107
113 108 api.array :enabled_modules do
114 109 project.enabled_modules.each do |enabled_module|
115 110 api.enabled_module(:id => enabled_module.id, :name => enabled_module.name)
116 111 end
117 112 end if include_in_api_response?('enabled_modules')
118 113
119 114 end
120 115 end
@@ -1,1592 +1,1591
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2014 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class Issue < ActiveRecord::Base
19 19 include Redmine::SafeAttributes
20 20 include Redmine::Utils::DateCalculation
21 21 include Redmine::I18n
22 22
23 23 belongs_to :project
24 24 belongs_to :tracker
25 25 belongs_to :status, :class_name => 'IssueStatus'
26 26 belongs_to :author, :class_name => 'User'
27 27 belongs_to :assigned_to, :class_name => 'Principal'
28 28 belongs_to :fixed_version, :class_name => 'Version'
29 29 belongs_to :priority, :class_name => 'IssuePriority'
30 30 belongs_to :category, :class_name => 'IssueCategory'
31 31
32 32 has_many :journals, :as => :journalized, :dependent => :destroy
33 33 has_many :visible_journals,
34 34 lambda {where(["(#{Journal.table_name}.private_notes = ? OR (#{Project.allowed_to_condition(User.current, :view_private_notes)}))", false])},
35 35 :class_name => 'Journal',
36 36 :as => :journalized
37 37
38 38 has_many :time_entries, :dependent => :destroy
39 39 has_and_belongs_to_many :changesets, lambda {order("#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC")}
40 40
41 41 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
42 42 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
43 43
44 44 acts_as_nested_set :scope => 'root_id', :dependent => :destroy
45 45 acts_as_attachable :after_add => :attachment_added, :after_remove => :attachment_removed
46 46 acts_as_customizable
47 47 acts_as_watchable
48 48 acts_as_searchable :columns => ['subject', "#{table_name}.description", "#{Journal.table_name}.notes"],
49 49 # sort by id so that limited eager loading doesn't break with postgresql
50 50 :order_column => "#{table_name}.id",
51 51 :scope => lambda { joins(:project).
52 52 joins("LEFT OUTER JOIN #{Journal.table_name} ON #{Journal.table_name}.journalized_type='Issue'" +
53 53 " AND #{Journal.table_name}.journalized_id = #{Issue.table_name}.id" +
54 54 " AND (#{Journal.table_name}.private_notes = #{connection.quoted_false}" +
55 55 " OR (#{Project.allowed_to_condition(User.current, :view_private_notes)}))") }
56 56
57 57 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
58 58 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
59 59 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
60 60
61 61 acts_as_activity_provider :scope => preload(:project, :author, :tracker),
62 62 :author_key => :author_id
63 63
64 64 DONE_RATIO_OPTIONS = %w(issue_field issue_status)
65 65
66 66 attr_reader :current_journal
67 67 delegate :notes, :notes=, :private_notes, :private_notes=, :to => :current_journal, :allow_nil => true
68 68
69 69 validates_presence_of :subject, :priority, :project, :tracker, :author, :status
70 70
71 71 validates_length_of :subject, :maximum => 255
72 72 validates_inclusion_of :done_ratio, :in => 0..100
73 73 validates :estimated_hours, :numericality => {:greater_than_or_equal_to => 0, :allow_nil => true, :message => :invalid}
74 74 validates :start_date, :date => true
75 75 validates :due_date, :date => true
76 76 validate :validate_issue, :validate_required_fields
77 77 attr_protected :id
78 78
79 79 scope :visible, lambda {|*args|
80 80 includes(:project).
81 81 references(:project).
82 82 where(Issue.visible_condition(args.shift || User.current, *args))
83 83 }
84 84
85 85 scope :open, lambda {|*args|
86 86 is_closed = args.size > 0 ? !args.first : false
87 87 joins(:status).
88 88 where("#{IssueStatus.table_name}.is_closed = ?", is_closed)
89 89 }
90 90
91 91 scope :recently_updated, lambda { order("#{Issue.table_name}.updated_on DESC") }
92 92 scope :on_active_project, lambda {
93 93 joins(:project).
94 94 where("#{Project.table_name}.status = ?", Project::STATUS_ACTIVE)
95 95 }
96 96 scope :fixed_version, lambda {|versions|
97 97 ids = [versions].flatten.compact.map {|v| v.is_a?(Version) ? v.id : v}
98 98 ids.any? ? where(:fixed_version_id => ids) : where('1=0')
99 99 }
100 100
101 101 before_create :default_assign
102 102 before_save :close_duplicates, :update_done_ratio_from_issue_status,
103 103 :force_updated_on_change, :update_closed_on, :set_assigned_to_was
104 104 after_save {|issue| issue.send :after_project_change if !issue.id_changed? && issue.project_id_changed?}
105 105 after_save :reschedule_following_issues, :update_nested_set_attributes,
106 106 :update_parent_attributes, :create_journal
107 107 # Should be after_create but would be called before previous after_save callbacks
108 108 after_save :after_create_from_copy
109 109 after_destroy :update_parent_attributes
110 110 after_create :send_notification
111 111 # Keep it at the end of after_save callbacks
112 112 after_save :clear_assigned_to_was
113 113
114 114 # Returns a SQL conditions string used to find all issues visible by the specified user
115 115 def self.visible_condition(user, options={})
116 116 Project.allowed_to_condition(user, :view_issues, options) do |role, user|
117 117 if user.id && user.logged?
118 118 case role.issues_visibility
119 119 when 'all'
120 120 nil
121 121 when 'default'
122 122 user_ids = [user.id] + user.groups.map(&:id).compact
123 123 "(#{table_name}.is_private = #{connection.quoted_false} OR #{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
124 124 when 'own'
125 125 user_ids = [user.id] + user.groups.map(&:id).compact
126 126 "(#{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
127 127 else
128 128 '1=0'
129 129 end
130 130 else
131 131 "(#{table_name}.is_private = #{connection.quoted_false})"
132 132 end
133 133 end
134 134 end
135 135
136 136 # Returns true if usr or current user is allowed to view the issue
137 137 def visible?(usr=nil)
138 138 (usr || User.current).allowed_to?(:view_issues, self.project) do |role, user|
139 139 if user.logged?
140 140 case role.issues_visibility
141 141 when 'all'
142 142 true
143 143 when 'default'
144 144 !self.is_private? || (self.author == user || user.is_or_belongs_to?(assigned_to))
145 145 when 'own'
146 146 self.author == user || user.is_or_belongs_to?(assigned_to)
147 147 else
148 148 false
149 149 end
150 150 else
151 151 !self.is_private?
152 152 end
153 153 end
154 154 end
155 155
156 156 # Returns true if user or current user is allowed to edit or add a note to the issue
157 157 def editable?(user=User.current)
158 158 user.allowed_to?(:edit_issues, project) || user.allowed_to?(:add_issue_notes, project)
159 159 end
160 160
161 161 def initialize(attributes=nil, *args)
162 162 super
163 163 if new_record?
164 164 # set default values for new records only
165 165 self.status ||= IssueStatus.default
166 166 self.priority ||= IssuePriority.default
167 167 self.watcher_user_ids = []
168 168 end
169 169 end
170 170
171 171 def create_or_update
172 172 super
173 173 ensure
174 174 @status_was = nil
175 175 end
176 176 private :create_or_update
177 177
178 178 # AR#Persistence#destroy would raise and RecordNotFound exception
179 179 # if the issue was already deleted or updated (non matching lock_version).
180 180 # This is a problem when bulk deleting issues or deleting a project
181 181 # (because an issue may already be deleted if its parent was deleted
182 182 # first).
183 183 # The issue is reloaded by the nested_set before being deleted so
184 184 # the lock_version condition should not be an issue but we handle it.
185 185 def destroy
186 186 super
187 187 rescue ActiveRecord::RecordNotFound
188 188 # Stale or already deleted
189 189 begin
190 190 reload
191 191 rescue ActiveRecord::RecordNotFound
192 192 # The issue was actually already deleted
193 193 @destroyed = true
194 194 return freeze
195 195 end
196 196 # The issue was stale, retry to destroy
197 197 super
198 198 end
199 199
200 200 alias :base_reload :reload
201 201 def reload(*args)
202 202 @workflow_rule_by_attribute = nil
203 203 @assignable_versions = nil
204 204 @relations = nil
205 205 base_reload(*args)
206 206 end
207 207
208 208 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
209 209 def available_custom_fields
210 210 (project && tracker) ? (project.all_issue_custom_fields & tracker.custom_fields) : []
211 211 end
212 212
213 213 def visible_custom_field_values(user=nil)
214 214 user_real = user || User.current
215 215 custom_field_values.select do |value|
216 216 value.custom_field.visible_by?(project, user_real)
217 217 end
218 218 end
219 219
220 220 # Copies attributes from another issue, arg can be an id or an Issue
221 221 def copy_from(arg, options={})
222 222 issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg)
223 223 self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
224 224 self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
225 225 self.status = issue.status
226 226 self.author = User.current
227 227 unless options[:attachments] == false
228 228 self.attachments = issue.attachments.map do |attachement|
229 229 attachement.copy(:container => self)
230 230 end
231 231 end
232 232 @copied_from = issue
233 233 @copy_options = options
234 234 self
235 235 end
236 236
237 237 # Returns an unsaved copy of the issue
238 238 def copy(attributes=nil, copy_options={})
239 239 copy = self.class.new.copy_from(self, copy_options)
240 240 copy.attributes = attributes if attributes
241 241 copy
242 242 end
243 243
244 244 # Returns true if the issue is a copy
245 245 def copy?
246 246 @copied_from.present?
247 247 end
248 248
249 249 # Moves/copies an issue to a new project and tracker
250 250 # Returns the moved/copied issue on success, false on failure
251 251 def move_to_project(new_project, new_tracker=nil, options={})
252 252 ActiveSupport::Deprecation.warn "Issue#move_to_project is deprecated, use #project= instead."
253 253
254 254 if options[:copy]
255 255 issue = self.copy
256 256 else
257 257 issue = self
258 258 end
259 259
260 260 issue.init_journal(User.current, options[:notes])
261 261
262 262 # Preserve previous behaviour
263 263 # #move_to_project doesn't change tracker automatically
264 264 issue.send :project=, new_project, true
265 265 if new_tracker
266 266 issue.tracker = new_tracker
267 267 end
268 268 # Allow bulk setting of attributes on the issue
269 269 if options[:attributes]
270 270 issue.attributes = options[:attributes]
271 271 end
272 272
273 273 issue.save ? issue : false
274 274 end
275 275
276 276 def status_id=(sid)
277 277 self.status = nil
278 278 result = write_attribute(:status_id, sid)
279 279 @workflow_rule_by_attribute = nil
280 280 result
281 281 end
282 282
283 283 def priority_id=(pid)
284 284 self.priority = nil
285 285 write_attribute(:priority_id, pid)
286 286 end
287 287
288 288 def category_id=(cid)
289 289 self.category = nil
290 290 write_attribute(:category_id, cid)
291 291 end
292 292
293 293 def fixed_version_id=(vid)
294 294 self.fixed_version = nil
295 295 write_attribute(:fixed_version_id, vid)
296 296 end
297 297
298 298 def tracker_id=(tid)
299 299 self.tracker = nil
300 300 result = write_attribute(:tracker_id, tid)
301 301 @custom_field_values = nil
302 302 @workflow_rule_by_attribute = nil
303 303 result
304 304 end
305 305
306 306 def project_id=(project_id)
307 307 if project_id.to_s != self.project_id.to_s
308 308 self.project = (project_id.present? ? Project.find_by_id(project_id) : nil)
309 309 end
310 310 end
311 311
312 312 def project=(project, keep_tracker=false)
313 313 project_was = self.project
314 314 write_attribute(:project_id, project ? project.id : nil)
315 315 association_instance_set('project', project)
316 316 if project_was && project && project_was != project
317 317 @assignable_versions = nil
318 318
319 319 unless keep_tracker || project.trackers.include?(tracker)
320 320 self.tracker = project.trackers.first
321 321 end
322 322 # Reassign to the category with same name if any
323 323 if category
324 324 self.category = project.issue_categories.find_by_name(category.name)
325 325 end
326 326 # Keep the fixed_version if it's still valid in the new_project
327 327 if fixed_version && fixed_version.project != project && !project.shared_versions.include?(fixed_version)
328 328 self.fixed_version = nil
329 329 end
330 330 # Clear the parent task if it's no longer valid
331 331 unless valid_parent_project?
332 332 self.parent_issue_id = nil
333 333 end
334 334 @custom_field_values = nil
335 335 end
336 336 end
337 337
338 338 def description=(arg)
339 339 if arg.is_a?(String)
340 340 arg = arg.gsub(/(\r\n|\n|\r)/, "\r\n")
341 341 end
342 342 write_attribute(:description, arg)
343 343 end
344 344
345 345 # Overrides assign_attributes so that project and tracker get assigned first
346 346 def assign_attributes_with_project_and_tracker_first(new_attributes, *args)
347 347 return if new_attributes.nil?
348 348 attrs = new_attributes.dup
349 349 attrs.stringify_keys!
350 350
351 351 %w(project project_id tracker tracker_id).each do |attr|
352 352 if attrs.has_key?(attr)
353 353 send "#{attr}=", attrs.delete(attr)
354 354 end
355 355 end
356 356 send :assign_attributes_without_project_and_tracker_first, attrs, *args
357 357 end
358 358 # Do not redefine alias chain on reload (see #4838)
359 359 alias_method_chain(:assign_attributes, :project_and_tracker_first) unless method_defined?(:assign_attributes_without_project_and_tracker_first)
360 360
361 361 def attributes=(new_attributes)
362 362 assign_attributes new_attributes
363 363 end
364 364
365 365 def estimated_hours=(h)
366 366 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
367 367 end
368 368
369 369 safe_attributes 'project_id',
370 370 :if => lambda {|issue, user|
371 371 if issue.new_record?
372 372 issue.copy?
373 373 elsif user.allowed_to?(:move_issues, issue.project)
374 374 Issue.allowed_target_projects_on_move.count > 1
375 375 end
376 376 }
377 377
378 378 safe_attributes 'tracker_id',
379 379 'status_id',
380 380 'category_id',
381 381 'assigned_to_id',
382 382 'priority_id',
383 383 'fixed_version_id',
384 384 'subject',
385 385 'description',
386 386 'start_date',
387 387 'due_date',
388 388 'done_ratio',
389 389 'estimated_hours',
390 390 'custom_field_values',
391 391 'custom_fields',
392 392 'lock_version',
393 393 'notes',
394 394 :if => lambda {|issue, user| issue.new_record? || user.allowed_to?(:edit_issues, issue.project) }
395 395
396 396 safe_attributes 'status_id',
397 397 'assigned_to_id',
398 398 'fixed_version_id',
399 399 'done_ratio',
400 400 'lock_version',
401 401 'notes',
402 402 :if => lambda {|issue, user| issue.new_statuses_allowed_to(user).any? }
403 403
404 404 safe_attributes 'notes',
405 405 :if => lambda {|issue, user| user.allowed_to?(:add_issue_notes, issue.project)}
406 406
407 407 safe_attributes 'private_notes',
408 408 :if => lambda {|issue, user| !issue.new_record? && user.allowed_to?(:set_notes_private, issue.project)}
409 409
410 410 safe_attributes 'watcher_user_ids',
411 411 :if => lambda {|issue, user| issue.new_record? && user.allowed_to?(:add_issue_watchers, issue.project)}
412 412
413 413 safe_attributes 'is_private',
414 414 :if => lambda {|issue, user|
415 415 user.allowed_to?(:set_issues_private, issue.project) ||
416 416 (issue.author == user && user.allowed_to?(:set_own_issues_private, issue.project))
417 417 }
418 418
419 419 safe_attributes 'parent_issue_id',
420 420 :if => lambda {|issue, user| (issue.new_record? || user.allowed_to?(:edit_issues, issue.project)) &&
421 421 user.allowed_to?(:manage_subtasks, issue.project)}
422 422
423 423 def safe_attribute_names(user=nil)
424 424 names = super
425 425 names -= disabled_core_fields
426 426 names -= read_only_attribute_names(user)
427 427 names
428 428 end
429 429
430 430 # Safely sets attributes
431 431 # Should be called from controllers instead of #attributes=
432 432 # attr_accessible is too rough because we still want things like
433 433 # Issue.new(:project => foo) to work
434 434 def safe_attributes=(attrs, user=User.current)
435 435 return unless attrs.is_a?(Hash)
436 436
437 437 attrs = attrs.deep_dup
438 438
439 439 # Project and Tracker must be set before since new_statuses_allowed_to depends on it.
440 440 if (p = attrs.delete('project_id')) && safe_attribute?('project_id')
441 441 if allowed_target_projects(user).where(:id => p.to_i).exists?
442 442 self.project_id = p
443 443 end
444 444 end
445 445
446 446 if (t = attrs.delete('tracker_id')) && safe_attribute?('tracker_id')
447 447 self.tracker_id = t
448 448 end
449 449
450 450 if (s = attrs.delete('status_id')) && safe_attribute?('status_id')
451 451 if new_statuses_allowed_to(user).collect(&:id).include?(s.to_i)
452 452 self.status_id = s
453 453 end
454 454 end
455 455
456 456 attrs = delete_unsafe_attributes(attrs, user)
457 457 return if attrs.empty?
458 458
459 459 unless leaf?
460 460 attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
461 461 end
462 462
463 463 if attrs['parent_issue_id'].present?
464 464 s = attrs['parent_issue_id'].to_s
465 465 unless (m = s.match(%r{\A#?(\d+)\z})) && (m[1] == parent_id.to_s || Issue.visible(user).exists?(m[1]))
466 466 @invalid_parent_issue_id = attrs.delete('parent_issue_id')
467 467 end
468 468 end
469 469
470 470 if attrs['custom_field_values'].present?
471 471 editable_custom_field_ids = editable_custom_field_values(user).map {|v| v.custom_field_id.to_s}
472 472 attrs['custom_field_values'].select! {|k, v| editable_custom_field_ids.include?(k.to_s)}
473 473 end
474 474
475 475 if attrs['custom_fields'].present?
476 476 editable_custom_field_ids = editable_custom_field_values(user).map {|v| v.custom_field_id.to_s}
477 477 attrs['custom_fields'].select! {|c| editable_custom_field_ids.include?(c['id'].to_s)}
478 478 end
479 479
480 480 # mass-assignment security bypass
481 481 assign_attributes attrs, :without_protection => true
482 482 end
483 483
484 484 def disabled_core_fields
485 485 tracker ? tracker.disabled_core_fields : []
486 486 end
487 487
488 488 # Returns the custom_field_values that can be edited by the given user
489 489 def editable_custom_field_values(user=nil)
490 490 visible_custom_field_values(user).reject do |value|
491 491 read_only_attribute_names(user).include?(value.custom_field_id.to_s)
492 492 end
493 493 end
494 494
495 495 # Returns the custom fields that can be edited by the given user
496 496 def editable_custom_fields(user=nil)
497 497 editable_custom_field_values(user).map(&:custom_field).uniq
498 498 end
499 499
500 500 # Returns the names of attributes that are read-only for user or the current user
501 501 # For users with multiple roles, the read-only fields are the intersection of
502 502 # read-only fields of each role
503 503 # The result is an array of strings where sustom fields are represented with their ids
504 504 #
505 505 # Examples:
506 506 # issue.read_only_attribute_names # => ['due_date', '2']
507 507 # issue.read_only_attribute_names(user) # => []
508 508 def read_only_attribute_names(user=nil)
509 509 workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'readonly'}.keys
510 510 end
511 511
512 512 # Returns the names of required attributes for user or the current user
513 513 # For users with multiple roles, the required fields are the intersection of
514 514 # required fields of each role
515 515 # The result is an array of strings where sustom fields are represented with their ids
516 516 #
517 517 # Examples:
518 518 # issue.required_attribute_names # => ['due_date', '2']
519 519 # issue.required_attribute_names(user) # => []
520 520 def required_attribute_names(user=nil)
521 521 workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'required'}.keys
522 522 end
523 523
524 524 # Returns true if the attribute is required for user
525 525 def required_attribute?(name, user=nil)
526 526 required_attribute_names(user).include?(name.to_s)
527 527 end
528 528
529 529 # Returns a hash of the workflow rule by attribute for the given user
530 530 #
531 531 # Examples:
532 532 # issue.workflow_rule_by_attribute # => {'due_date' => 'required', 'start_date' => 'readonly'}
533 533 def workflow_rule_by_attribute(user=nil)
534 534 return @workflow_rule_by_attribute if @workflow_rule_by_attribute && user.nil?
535 535
536 536 user_real = user || User.current
537 537 roles = user_real.admin ? Role.all : user_real.roles_for_project(project)
538 538 return {} if roles.empty?
539 539
540 540 result = {}
541 541 workflow_permissions = WorkflowPermission.where(:tracker_id => tracker_id, :old_status_id => status_id, :role_id => roles.map(&:id))
542 542 if workflow_permissions.any?
543 543 workflow_rules = workflow_permissions.inject({}) do |h, wp|
544 544 h[wp.field_name] ||= []
545 545 h[wp.field_name] << wp.rule
546 546 h
547 547 end
548 548 workflow_rules.each do |attr, rules|
549 549 next if rules.size < roles.size
550 550 uniq_rules = rules.uniq
551 551 if uniq_rules.size == 1
552 552 result[attr] = uniq_rules.first
553 553 else
554 554 result[attr] = 'required'
555 555 end
556 556 end
557 557 end
558 558 @workflow_rule_by_attribute = result if user.nil?
559 559 result
560 560 end
561 561 private :workflow_rule_by_attribute
562 562
563 563 def done_ratio
564 564 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
565 565 status.default_done_ratio
566 566 else
567 567 read_attribute(:done_ratio)
568 568 end
569 569 end
570 570
571 571 def self.use_status_for_done_ratio?
572 572 Setting.issue_done_ratio == 'issue_status'
573 573 end
574 574
575 575 def self.use_field_for_done_ratio?
576 576 Setting.issue_done_ratio == 'issue_field'
577 577 end
578 578
579 579 def validate_issue
580 580 if due_date && start_date && (start_date_changed? || due_date_changed?) && due_date < start_date
581 581 errors.add :due_date, :greater_than_start_date
582 582 end
583 583
584 584 if start_date && start_date_changed? && soonest_start && start_date < soonest_start
585 585 errors.add :start_date, :earlier_than_minimum_start_date, :date => format_date(soonest_start)
586 586 end
587 587
588 588 if fixed_version
589 589 if !assignable_versions.include?(fixed_version)
590 590 errors.add :fixed_version_id, :inclusion
591 591 elsif reopened? && fixed_version.closed?
592 592 errors.add :base, I18n.t(:error_can_not_reopen_issue_on_closed_version)
593 593 end
594 594 end
595 595
596 596 # Checks that the issue can not be added/moved to a disabled tracker
597 597 if project && (tracker_id_changed? || project_id_changed?)
598 598 unless project.trackers.include?(tracker)
599 599 errors.add :tracker_id, :inclusion
600 600 end
601 601 end
602 602
603 603 # Checks parent issue assignment
604 604 if @invalid_parent_issue_id.present?
605 605 errors.add :parent_issue_id, :invalid
606 606 elsif @parent_issue
607 607 if !valid_parent_project?(@parent_issue)
608 608 errors.add :parent_issue_id, :invalid
609 609 elsif (@parent_issue != parent) && (all_dependent_issues.include?(@parent_issue) || @parent_issue.all_dependent_issues.include?(self))
610 610 errors.add :parent_issue_id, :invalid
611 611 elsif !new_record?
612 612 # moving an existing issue
613 613 if @parent_issue.root_id != root_id
614 614 # we can always move to another tree
615 615 elsif move_possible?(@parent_issue)
616 616 # move accepted inside tree
617 617 else
618 618 errors.add :parent_issue_id, :invalid
619 619 end
620 620 end
621 621 end
622 622 end
623 623
624 624 # Validates the issue against additional workflow requirements
625 625 def validate_required_fields
626 626 user = new_record? ? author : current_journal.try(:user)
627 627
628 628 required_attribute_names(user).each do |attribute|
629 629 if attribute =~ /^\d+$/
630 630 attribute = attribute.to_i
631 631 v = custom_field_values.detect {|v| v.custom_field_id == attribute }
632 632 if v && v.value.blank?
633 633 errors.add :base, v.custom_field.name + ' ' + l('activerecord.errors.messages.blank')
634 634 end
635 635 else
636 636 if respond_to?(attribute) && send(attribute).blank?
637 637 errors.add attribute, :blank
638 638 end
639 639 end
640 640 end
641 641 end
642 642
643 643 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
644 644 # even if the user turns off the setting later
645 645 def update_done_ratio_from_issue_status
646 646 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
647 647 self.done_ratio = status.default_done_ratio
648 648 end
649 649 end
650 650
651 651 def init_journal(user, notes = "")
652 652 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
653 653 if new_record?
654 654 @current_journal.notify = false
655 655 else
656 656 @attributes_before_change = attributes.dup
657 657 @custom_values_before_change = {}
658 658 self.custom_field_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
659 659 end
660 660 @current_journal
661 661 end
662 662
663 663 # Returns the id of the last journal or nil
664 664 def last_journal_id
665 665 if new_record?
666 666 nil
667 667 else
668 668 journals.maximum(:id)
669 669 end
670 670 end
671 671
672 672 # Returns a scope for journals that have an id greater than journal_id
673 673 def journals_after(journal_id)
674 674 scope = journals.reorder("#{Journal.table_name}.id ASC")
675 675 if journal_id.present?
676 676 scope = scope.where("#{Journal.table_name}.id > ?", journal_id.to_i)
677 677 end
678 678 scope
679 679 end
680 680
681 681 # Returns the initial status of the issue
682 682 # Returns nil for a new issue
683 683 def status_was
684 684 if status_id_was && status_id_was.to_i > 0
685 685 @status_was ||= IssueStatus.find_by_id(status_id_was)
686 686 end
687 687 end
688 688
689 689 # Return true if the issue is closed, otherwise false
690 690 def closed?
691 691 self.status.is_closed?
692 692 end
693 693
694 694 # Return true if the issue is being reopened
695 695 def reopened?
696 696 if !new_record? && status_id_changed?
697 697 status_was = IssueStatus.find_by_id(status_id_was)
698 698 status_new = IssueStatus.find_by_id(status_id)
699 699 if status_was && status_new && status_was.is_closed? && !status_new.is_closed?
700 700 return true
701 701 end
702 702 end
703 703 false
704 704 end
705 705
706 706 # Return true if the issue is being closed
707 707 def closing?
708 708 if !new_record? && status_id_changed?
709 709 if status_was && status && !status_was.is_closed? && status.is_closed?
710 710 return true
711 711 end
712 712 end
713 713 false
714 714 end
715 715
716 716 # Returns true if the issue is overdue
717 717 def overdue?
718 718 !due_date.nil? && (due_date < Date.today) && !status.is_closed?
719 719 end
720 720
721 721 # Is the amount of work done less than it should for the due date
722 722 def behind_schedule?
723 723 return false if start_date.nil? || due_date.nil?
724 724 done_date = start_date + ((due_date - start_date + 1) * done_ratio / 100).floor
725 725 return done_date <= Date.today
726 726 end
727 727
728 728 # Does this issue have children?
729 729 def children?
730 730 !leaf?
731 731 end
732 732
733 733 # Users the issue can be assigned to
734 734 def assignable_users
735 735 users = project.assignable_users
736 736 users << author if author
737 737 users << assigned_to if assigned_to
738 738 users.uniq.sort
739 739 end
740 740
741 741 # Versions that the issue can be assigned to
742 742 def assignable_versions
743 743 return @assignable_versions if @assignable_versions
744 744
745 745 versions = project.shared_versions.open.to_a
746 746 if fixed_version
747 747 if fixed_version_id_changed?
748 748 # nothing to do
749 749 elsif project_id_changed?
750 750 if project.shared_versions.include?(fixed_version)
751 751 versions << fixed_version
752 752 end
753 753 else
754 754 versions << fixed_version
755 755 end
756 756 end
757 757 @assignable_versions = versions.uniq.sort
758 758 end
759 759
760 760 # Returns true if this issue is blocked by another issue that is still open
761 761 def blocked?
762 762 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
763 763 end
764 764
765 765 # Returns an array of statuses that user is able to apply
766 766 def new_statuses_allowed_to(user=User.current, include_default=false)
767 767 if new_record? && @copied_from
768 768 [IssueStatus.default, @copied_from.status].compact.uniq.sort
769 769 else
770 770 initial_status = nil
771 771 if new_record?
772 772 initial_status = IssueStatus.default
773 773 elsif status_id_was
774 774 initial_status = IssueStatus.find_by_id(status_id_was)
775 775 end
776 776 initial_status ||= status
777 777
778 778 initial_assigned_to_id = assigned_to_id_changed? ? assigned_to_id_was : assigned_to_id
779 779 assignee_transitions_allowed = initial_assigned_to_id.present? &&
780 780 (user.id == initial_assigned_to_id || user.group_ids.include?(initial_assigned_to_id))
781 781
782 782 statuses = initial_status.find_new_statuses_allowed_to(
783 783 user.admin ? Role.all : user.roles_for_project(project),
784 784 tracker,
785 785 author == user,
786 786 assignee_transitions_allowed
787 787 )
788 788 statuses << initial_status unless statuses.empty?
789 789 statuses << IssueStatus.default if include_default
790 790 statuses = statuses.compact.uniq.sort
791 791 blocked? ? statuses.reject {|s| s.is_closed?} : statuses
792 792 end
793 793 end
794 794
795 795 # Returns the previous assignee if changed
796 796 def assigned_to_was
797 797 # assigned_to_id_was is reset before after_save callbacks
798 798 user_id = @previous_assigned_to_id || assigned_to_id_was
799 799 if user_id && user_id != assigned_to_id
800 800 @assigned_to_was ||= User.find_by_id(user_id)
801 801 end
802 802 end
803 803
804 804 # Returns the users that should be notified
805 805 def notified_users
806 806 notified = []
807 807 # Author and assignee are always notified unless they have been
808 808 # locked or don't want to be notified
809 809 notified << author if author
810 810 if assigned_to
811 811 notified += (assigned_to.is_a?(Group) ? assigned_to.users : [assigned_to])
812 812 end
813 813 if assigned_to_was
814 814 notified += (assigned_to_was.is_a?(Group) ? assigned_to_was.users : [assigned_to_was])
815 815 end
816 816 notified = notified.select {|u| u.active? && u.notify_about?(self)}
817 817
818 818 notified += project.notified_users
819 819 notified.uniq!
820 820 # Remove users that can not view the issue
821 821 notified.reject! {|user| !visible?(user)}
822 822 notified
823 823 end
824 824
825 825 # Returns the email addresses that should be notified
826 826 def recipients
827 827 notified_users.collect(&:mail)
828 828 end
829 829
830 830 def each_notification(users, &block)
831 831 if users.any?
832 832 if custom_field_values.detect {|value| !value.custom_field.visible?}
833 833 users_by_custom_field_visibility = users.group_by do |user|
834 834 visible_custom_field_values(user).map(&:custom_field_id).sort
835 835 end
836 836 users_by_custom_field_visibility.values.each do |users|
837 837 yield(users)
838 838 end
839 839 else
840 840 yield(users)
841 841 end
842 842 end
843 843 end
844 844
845 845 # Returns the number of hours spent on this issue
846 846 def spent_hours
847 847 @spent_hours ||= time_entries.sum(:hours) || 0
848 848 end
849 849
850 850 # Returns the total number of hours spent on this issue and its descendants
851 851 #
852 852 # Example:
853 853 # spent_hours => 0.0
854 854 # spent_hours => 50.2
855 855 def total_spent_hours
856 856 @total_spent_hours ||=
857 857 self_and_descendants.
858 858 joins("LEFT JOIN #{TimeEntry.table_name} ON #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id").
859 859 sum("#{TimeEntry.table_name}.hours").to_f || 0.0
860 860 end
861 861
862 862 def relations
863 863 @relations ||= IssueRelation::Relations.new(self, (relations_from + relations_to).sort)
864 864 end
865 865
866 866 # Preloads relations for a collection of issues
867 867 def self.load_relations(issues)
868 868 if issues.any?
869 869 relations = IssueRelation.where("issue_from_id IN (:ids) OR issue_to_id IN (:ids)", :ids => issues.map(&:id)).all
870 870 issues.each do |issue|
871 871 issue.instance_variable_set "@relations", relations.select {|r| r.issue_from_id == issue.id || r.issue_to_id == issue.id}
872 872 end
873 873 end
874 874 end
875 875
876 876 # Preloads visible spent time for a collection of issues
877 877 def self.load_visible_spent_hours(issues, user=User.current)
878 878 if issues.any?
879 879 hours_by_issue_id = TimeEntry.visible(user).group(:issue_id).sum(:hours)
880 880 issues.each do |issue|
881 881 issue.instance_variable_set "@spent_hours", (hours_by_issue_id[issue.id] || 0)
882 882 end
883 883 end
884 884 end
885 885
886 886 # Preloads visible relations for a collection of issues
887 887 def self.load_visible_relations(issues, user=User.current)
888 888 if issues.any?
889 889 issue_ids = issues.map(&:id)
890 890 # Relations with issue_from in given issues and visible issue_to
891 891 relations_from = IssueRelation.joins(:issue_to => :project).
892 892 where(visible_condition(user)).where(:issue_from_id => issue_ids).to_a
893 893 # Relations with issue_to in given issues and visible issue_from
894 894 relations_to = IssueRelation.joins(:issue_from => :project).
895 895 where(visible_condition(user)).
896 896 where(:issue_to_id => issue_ids).to_a
897 897 issues.each do |issue|
898 898 relations =
899 899 relations_from.select {|relation| relation.issue_from_id == issue.id} +
900 900 relations_to.select {|relation| relation.issue_to_id == issue.id}
901 901
902 902 issue.instance_variable_set "@relations", IssueRelation::Relations.new(issue, relations.sort)
903 903 end
904 904 end
905 905 end
906 906
907 907 # Finds an issue relation given its id.
908 908 def find_relation(relation_id)
909 909 IssueRelation.where("issue_to_id = ? OR issue_from_id = ?", id, id).find(relation_id)
910 910 end
911 911
912 912 # Returns all the other issues that depend on the issue
913 913 # The algorithm is a modified breadth first search (bfs)
914 914 def all_dependent_issues(except=[])
915 915 # The found dependencies
916 916 dependencies = []
917 917
918 918 # The visited flag for every node (issue) used by the breadth first search
919 919 eNOT_DISCOVERED = 0 # The issue is "new" to the algorithm, it has not seen it before.
920 920
921 921 ePROCESS_ALL = 1 # The issue is added to the queue. Process both children and relations of
922 922 # the issue when it is processed.
923 923
924 924 ePROCESS_RELATIONS_ONLY = 2 # The issue was added to the queue and will be output as dependent issue,
925 925 # but its children will not be added to the queue when it is processed.
926 926
927 927 eRELATIONS_PROCESSED = 3 # The related issues, the parent issue and the issue itself have been added to
928 928 # the queue, but its children have not been added.
929 929
930 930 ePROCESS_CHILDREN_ONLY = 4 # The relations and the parent of the issue have been added to the queue, but
931 931 # the children still need to be processed.
932 932
933 933 eALL_PROCESSED = 5 # The issue and all its children, its parent and its related issues have been
934 934 # added as dependent issues. It needs no further processing.
935 935
936 936 issue_status = Hash.new(eNOT_DISCOVERED)
937 937
938 938 # The queue
939 939 queue = []
940 940
941 941 # Initialize the bfs, add start node (self) to the queue
942 942 queue << self
943 943 issue_status[self] = ePROCESS_ALL
944 944
945 945 while (!queue.empty?) do
946 946 current_issue = queue.shift
947 947 current_issue_status = issue_status[current_issue]
948 948 dependencies << current_issue
949 949
950 950 # Add parent to queue, if not already in it.
951 951 parent = current_issue.parent
952 952 parent_status = issue_status[parent]
953 953
954 954 if parent && (parent_status == eNOT_DISCOVERED) && !except.include?(parent)
955 955 queue << parent
956 956 issue_status[parent] = ePROCESS_RELATIONS_ONLY
957 957 end
958 958
959 959 # Add children to queue, but only if they are not already in it and
960 960 # the children of the current node need to be processed.
961 961 if (current_issue_status == ePROCESS_CHILDREN_ONLY || current_issue_status == ePROCESS_ALL)
962 962 current_issue.children.each do |child|
963 963 next if except.include?(child)
964 964
965 965 if (issue_status[child] == eNOT_DISCOVERED)
966 966 queue << child
967 967 issue_status[child] = ePROCESS_ALL
968 968 elsif (issue_status[child] == eRELATIONS_PROCESSED)
969 969 queue << child
970 970 issue_status[child] = ePROCESS_CHILDREN_ONLY
971 971 elsif (issue_status[child] == ePROCESS_RELATIONS_ONLY)
972 972 queue << child
973 973 issue_status[child] = ePROCESS_ALL
974 974 end
975 975 end
976 976 end
977 977
978 978 # Add related issues to the queue, if they are not already in it.
979 979 current_issue.relations_from.map(&:issue_to).each do |related_issue|
980 980 next if except.include?(related_issue)
981 981
982 982 if (issue_status[related_issue] == eNOT_DISCOVERED)
983 983 queue << related_issue
984 984 issue_status[related_issue] = ePROCESS_ALL
985 985 elsif (issue_status[related_issue] == eRELATIONS_PROCESSED)
986 986 queue << related_issue
987 987 issue_status[related_issue] = ePROCESS_CHILDREN_ONLY
988 988 elsif (issue_status[related_issue] == ePROCESS_RELATIONS_ONLY)
989 989 queue << related_issue
990 990 issue_status[related_issue] = ePROCESS_ALL
991 991 end
992 992 end
993 993
994 994 # Set new status for current issue
995 995 if (current_issue_status == ePROCESS_ALL) || (current_issue_status == ePROCESS_CHILDREN_ONLY)
996 996 issue_status[current_issue] = eALL_PROCESSED
997 997 elsif (current_issue_status == ePROCESS_RELATIONS_ONLY)
998 998 issue_status[current_issue] = eRELATIONS_PROCESSED
999 999 end
1000 1000 end # while
1001 1001
1002 1002 # Remove the issues from the "except" parameter from the result array
1003 1003 dependencies -= except
1004 1004 dependencies.delete(self)
1005 1005
1006 1006 dependencies
1007 1007 end
1008 1008
1009 1009 # Returns an array of issues that duplicate this one
1010 1010 def duplicates
1011 1011 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
1012 1012 end
1013 1013
1014 1014 # Returns the due date or the target due date if any
1015 1015 # Used on gantt chart
1016 1016 def due_before
1017 1017 due_date || (fixed_version ? fixed_version.effective_date : nil)
1018 1018 end
1019 1019
1020 1020 # Returns the time scheduled for this issue.
1021 1021 #
1022 1022 # Example:
1023 1023 # Start Date: 2/26/09, End Date: 3/04/09
1024 1024 # duration => 6
1025 1025 def duration
1026 1026 (start_date && due_date) ? due_date - start_date : 0
1027 1027 end
1028 1028
1029 1029 # Returns the duration in working days
1030 1030 def working_duration
1031 1031 (start_date && due_date) ? working_days(start_date, due_date) : 0
1032 1032 end
1033 1033
1034 1034 def soonest_start(reload=false)
1035 1035 @soonest_start = nil if reload
1036 1036 @soonest_start ||= (
1037 1037 relations_to(reload).collect{|relation| relation.successor_soonest_start} +
1038 1038 [(@parent_issue || parent).try(:soonest_start)]
1039 1039 ).compact.max
1040 1040 end
1041 1041
1042 1042 # Sets start_date on the given date or the next working day
1043 1043 # and changes due_date to keep the same working duration.
1044 1044 def reschedule_on(date)
1045 1045 wd = working_duration
1046 1046 date = next_working_date(date)
1047 1047 self.start_date = date
1048 1048 self.due_date = add_working_days(date, wd)
1049 1049 end
1050 1050
1051 1051 # Reschedules the issue on the given date or the next working day and saves the record.
1052 1052 # If the issue is a parent task, this is done by rescheduling its subtasks.
1053 1053 def reschedule_on!(date)
1054 1054 return if date.nil?
1055 1055 if leaf?
1056 1056 if start_date.nil? || start_date != date
1057 1057 if start_date && start_date > date
1058 1058 # Issue can not be moved earlier than its soonest start date
1059 1059 date = [soonest_start(true), date].compact.max
1060 1060 end
1061 1061 reschedule_on(date)
1062 1062 begin
1063 1063 save
1064 1064 rescue ActiveRecord::StaleObjectError
1065 1065 reload
1066 1066 reschedule_on(date)
1067 1067 save
1068 1068 end
1069 1069 end
1070 1070 else
1071 1071 leaves.each do |leaf|
1072 1072 if leaf.start_date
1073 1073 # Only move subtask if it starts at the same date as the parent
1074 1074 # or if it starts before the given date
1075 1075 if start_date == leaf.start_date || date > leaf.start_date
1076 1076 leaf.reschedule_on!(date)
1077 1077 end
1078 1078 else
1079 1079 leaf.reschedule_on!(date)
1080 1080 end
1081 1081 end
1082 1082 end
1083 1083 end
1084 1084
1085 1085 def <=>(issue)
1086 1086 if issue.nil?
1087 1087 -1
1088 1088 elsif root_id != issue.root_id
1089 1089 (root_id || 0) <=> (issue.root_id || 0)
1090 1090 else
1091 1091 (lft || 0) <=> (issue.lft || 0)
1092 1092 end
1093 1093 end
1094 1094
1095 1095 def to_s
1096 1096 "#{tracker} ##{id}: #{subject}"
1097 1097 end
1098 1098
1099 1099 # Returns a string of css classes that apply to the issue
1100 1100 def css_classes(user=User.current)
1101 1101 s = "issue tracker-#{tracker_id} status-#{status_id} #{priority.try(:css_classes)}"
1102 1102 s << ' closed' if closed?
1103 1103 s << ' overdue' if overdue?
1104 1104 s << ' child' if child?
1105 1105 s << ' parent' unless leaf?
1106 1106 s << ' private' if is_private?
1107 1107 if user.logged?
1108 1108 s << ' created-by-me' if author_id == user.id
1109 1109 s << ' assigned-to-me' if assigned_to_id == user.id
1110 1110 s << ' assigned-to-my-group' if user.groups.any? {|g| g.id == assigned_to_id}
1111 1111 end
1112 1112 s
1113 1113 end
1114 1114
1115 1115 # Unassigns issues from +version+ if it's no longer shared with issue's project
1116 1116 def self.update_versions_from_sharing_change(version)
1117 1117 # Update issues assigned to the version
1118 1118 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
1119 1119 end
1120 1120
1121 1121 # Unassigns issues from versions that are no longer shared
1122 1122 # after +project+ was moved
1123 1123 def self.update_versions_from_hierarchy_change(project)
1124 1124 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
1125 1125 # Update issues of the moved projects and issues assigned to a version of a moved project
1126 1126 Issue.update_versions(
1127 1127 ["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)",
1128 1128 moved_project_ids, moved_project_ids]
1129 1129 )
1130 1130 end
1131 1131
1132 1132 def parent_issue_id=(arg)
1133 1133 s = arg.to_s.strip.presence
1134 1134 if s && (m = s.match(%r{\A#?(\d+)\z})) && (@parent_issue = Issue.find_by_id(m[1]))
1135 @parent_issue.id
1136 1135 @invalid_parent_issue_id = nil
1137 1136 elsif s.blank?
1138 1137 @parent_issue = nil
1139 1138 @invalid_parent_issue_id = nil
1140 1139 else
1141 1140 @parent_issue = nil
1142 1141 @invalid_parent_issue_id = arg
1143 1142 end
1144 1143 end
1145 1144
1146 1145 def parent_issue_id
1147 1146 if @invalid_parent_issue_id
1148 1147 @invalid_parent_issue_id
1149 1148 elsif instance_variable_defined? :@parent_issue
1150 1149 @parent_issue.nil? ? nil : @parent_issue.id
1151 1150 else
1152 1151 parent_id
1153 1152 end
1154 1153 end
1155 1154
1156 1155 # Returns true if issue's project is a valid
1157 1156 # parent issue project
1158 1157 def valid_parent_project?(issue=parent)
1159 1158 return true if issue.nil? || issue.project_id == project_id
1160 1159
1161 1160 case Setting.cross_project_subtasks
1162 1161 when 'system'
1163 1162 true
1164 1163 when 'tree'
1165 1164 issue.project.root == project.root
1166 1165 when 'hierarchy'
1167 1166 issue.project.is_or_is_ancestor_of?(project) || issue.project.is_descendant_of?(project)
1168 1167 when 'descendants'
1169 1168 issue.project.is_or_is_ancestor_of?(project)
1170 1169 else
1171 1170 false
1172 1171 end
1173 1172 end
1174 1173
1175 1174 # Returns an issue scope based on project and scope
1176 1175 def self.cross_project_scope(project, scope=nil)
1177 1176 if project.nil?
1178 1177 return Issue
1179 1178 end
1180 1179 case scope
1181 1180 when 'all', 'system'
1182 1181 Issue
1183 1182 when 'tree'
1184 1183 Issue.joins(:project).where("(#{Project.table_name}.lft >= :lft AND #{Project.table_name}.rgt <= :rgt)",
1185 1184 :lft => project.root.lft, :rgt => project.root.rgt)
1186 1185 when 'hierarchy'
1187 1186 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)",
1188 1187 :lft => project.lft, :rgt => project.rgt)
1189 1188 when 'descendants'
1190 1189 Issue.joins(:project).where("(#{Project.table_name}.lft >= :lft AND #{Project.table_name}.rgt <= :rgt)",
1191 1190 :lft => project.lft, :rgt => project.rgt)
1192 1191 else
1193 1192 Issue.where(:project_id => project.id)
1194 1193 end
1195 1194 end
1196 1195
1197 1196 def self.by_tracker(project)
1198 1197 count_and_group_by(:project => project, :association => :tracker)
1199 1198 end
1200 1199
1201 1200 def self.by_version(project)
1202 1201 count_and_group_by(:project => project, :association => :fixed_version)
1203 1202 end
1204 1203
1205 1204 def self.by_priority(project)
1206 1205 count_and_group_by(:project => project, :association => :priority)
1207 1206 end
1208 1207
1209 1208 def self.by_category(project)
1210 1209 count_and_group_by(:project => project, :association => :category)
1211 1210 end
1212 1211
1213 1212 def self.by_assigned_to(project)
1214 1213 count_and_group_by(:project => project, :association => :assigned_to)
1215 1214 end
1216 1215
1217 1216 def self.by_author(project)
1218 1217 count_and_group_by(:project => project, :association => :author)
1219 1218 end
1220 1219
1221 1220 def self.by_subproject(project)
1222 1221 r = count_and_group_by(:project => project, :with_subprojects => true, :association => :project)
1223 1222 r.reject {|r| r["project_id"] == project.id.to_s}
1224 1223 end
1225 1224
1226 1225 # Query generator for selecting groups of issue counts for a project
1227 1226 # based on specific criteria
1228 1227 #
1229 1228 # Options
1230 1229 # * project - Project to search in.
1231 1230 # * with_subprojects - Includes subprojects issues if set to true.
1232 1231 # * association - Symbol. Association for grouping.
1233 1232 def self.count_and_group_by(options)
1234 1233 assoc = reflect_on_association(options[:association])
1235 1234 select_field = assoc.foreign_key
1236 1235
1237 1236 Issue.
1238 1237 visible(User.current, :project => options[:project], :with_subprojects => options[:with_subprojects]).
1239 1238 joins(:status, assoc.name).
1240 1239 group(:status_id, :is_closed, select_field).
1241 1240 count.
1242 1241 map do |columns, total|
1243 1242 status_id, is_closed, field_value = columns
1244 1243 is_closed = ['t', 'true', '1'].include?(is_closed.to_s)
1245 1244 {
1246 1245 "status_id" => status_id.to_s,
1247 1246 "closed" => is_closed,
1248 1247 select_field => field_value.to_s,
1249 1248 "total" => total.to_s
1250 1249 }
1251 1250 end
1252 1251 end
1253 1252
1254 1253 # Returns a scope of projects that user can assign the issue to
1255 1254 def allowed_target_projects(user=User.current)
1256 1255 if new_record?
1257 1256 Project.where(Project.allowed_to_condition(user, :add_issues))
1258 1257 else
1259 1258 self.class.allowed_target_projects_on_move(user)
1260 1259 end
1261 1260 end
1262 1261
1263 1262 # Returns a scope of projects that user can move issues to
1264 1263 def self.allowed_target_projects_on_move(user=User.current)
1265 1264 Project.where(Project.allowed_to_condition(user, :move_issues))
1266 1265 end
1267 1266
1268 1267 private
1269 1268
1270 1269 def after_project_change
1271 1270 # Update project_id on related time entries
1272 1271 TimeEntry.where({:issue_id => id}).update_all(["project_id = ?", project_id])
1273 1272
1274 1273 # Delete issue relations
1275 1274 unless Setting.cross_project_issue_relations?
1276 1275 relations_from.clear
1277 1276 relations_to.clear
1278 1277 end
1279 1278
1280 1279 # Move subtasks that were in the same project
1281 1280 children.each do |child|
1282 1281 next unless child.project_id == project_id_was
1283 1282 # Change project and keep project
1284 1283 child.send :project=, project, true
1285 1284 unless child.save
1286 1285 raise ActiveRecord::Rollback
1287 1286 end
1288 1287 end
1289 1288 end
1290 1289
1291 1290 # Callback for after the creation of an issue by copy
1292 1291 # * adds a "copied to" relation with the copied issue
1293 1292 # * copies subtasks from the copied issue
1294 1293 def after_create_from_copy
1295 1294 return unless copy? && !@after_create_from_copy_handled
1296 1295
1297 1296 if (@copied_from.project_id == project_id || Setting.cross_project_issue_relations?) && @copy_options[:link] != false
1298 1297 relation = IssueRelation.new(:issue_from => @copied_from, :issue_to => self, :relation_type => IssueRelation::TYPE_COPIED_TO)
1299 1298 unless relation.save
1300 1299 logger.error "Could not create relation while copying ##{@copied_from.id} to ##{id} due to validation errors: #{relation.errors.full_messages.join(', ')}" if logger
1301 1300 end
1302 1301 end
1303 1302
1304 1303 unless @copied_from.leaf? || @copy_options[:subtasks] == false
1305 1304 copy_options = (@copy_options || {}).merge(:subtasks => false)
1306 1305 copied_issue_ids = {@copied_from.id => self.id}
1307 1306 @copied_from.reload.descendants.reorder("#{Issue.table_name}.lft").each do |child|
1308 1307 # Do not copy self when copying an issue as a descendant of the copied issue
1309 1308 next if child == self
1310 1309 # Do not copy subtasks of issues that were not copied
1311 1310 next unless copied_issue_ids[child.parent_id]
1312 1311 # Do not copy subtasks that are not visible to avoid potential disclosure of private data
1313 1312 unless child.visible?
1314 1313 logger.error "Subtask ##{child.id} was not copied during ##{@copied_from.id} copy because it is not visible to the current user" if logger
1315 1314 next
1316 1315 end
1317 1316 copy = Issue.new.copy_from(child, copy_options)
1318 1317 copy.author = author
1319 1318 copy.project = project
1320 1319 copy.parent_issue_id = copied_issue_ids[child.parent_id]
1321 1320 unless copy.save
1322 1321 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
1323 1322 next
1324 1323 end
1325 1324 copied_issue_ids[child.id] = copy.id
1326 1325 end
1327 1326 end
1328 1327 @after_create_from_copy_handled = true
1329 1328 end
1330 1329
1331 1330 def update_nested_set_attributes
1332 1331 if root_id.nil?
1333 1332 # issue was just created
1334 1333 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
1335 1334 Issue.where(["id = ?", id]).update_all(["root_id = ?", root_id])
1336 1335 if @parent_issue
1337 1336 move_to_child_of(@parent_issue)
1338 1337 end
1339 1338 elsif parent_issue_id != parent_id
1340 1339 update_nested_set_attributes_on_parent_change
1341 1340 end
1342 1341 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
1343 1342 end
1344 1343
1345 1344 # Updates the nested set for when an existing issue is moved
1346 1345 def update_nested_set_attributes_on_parent_change
1347 1346 former_parent_id = parent_id
1348 1347 # moving an existing issue
1349 1348 if @parent_issue && @parent_issue.root_id == root_id
1350 1349 # inside the same tree
1351 1350 move_to_child_of(@parent_issue)
1352 1351 else
1353 1352 # to another tree
1354 1353 unless root?
1355 1354 move_to_right_of(root)
1356 1355 end
1357 1356 old_root_id = root_id
1358 1357 in_tenacious_transaction do
1359 1358 @parent_issue.reload_nested_set if @parent_issue
1360 1359 self.reload_nested_set
1361 1360 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
1362 1361 cond = ["root_id = ? AND lft >= ? AND rgt <= ? ", old_root_id, lft, rgt]
1363 1362 self.class.base_class.select('id').lock(true).where(cond)
1364 1363 offset = rdm_right_most_bound + 1 - lft
1365 1364 Issue.where(cond).
1366 1365 update_all(["root_id = ?, lft = lft + ?, rgt = rgt + ?", root_id, offset, offset])
1367 1366 end
1368 1367 if @parent_issue
1369 1368 move_to_child_of(@parent_issue)
1370 1369 end
1371 1370 end
1372 1371 # delete invalid relations of all descendants
1373 1372 self_and_descendants.each do |issue|
1374 1373 issue.relations.each do |relation|
1375 1374 relation.destroy unless relation.valid?
1376 1375 end
1377 1376 end
1378 1377 # update former parent
1379 1378 recalculate_attributes_for(former_parent_id) if former_parent_id
1380 1379 end
1381 1380
1382 1381 def rdm_right_most_bound
1383 1382 right_most_node =
1384 1383 self.class.base_class.unscoped.
1385 1384 order("#{quoted_right_column_full_name} desc").limit(1).lock(true).first
1386 1385 right_most_node ? (right_most_node[right_column_name] || 0 ) : 0
1387 1386 end
1388 1387 private :rdm_right_most_bound
1389 1388
1390 1389 def update_parent_attributes
1391 1390 recalculate_attributes_for(parent_id) if parent_id
1392 1391 end
1393 1392
1394 1393 def recalculate_attributes_for(issue_id)
1395 1394 if issue_id && p = Issue.find_by_id(issue_id)
1396 1395 # priority = highest priority of children
1397 1396 if priority_position = p.children.joins(:priority).maximum("#{IssuePriority.table_name}.position")
1398 1397 p.priority = IssuePriority.find_by_position(priority_position)
1399 1398 end
1400 1399
1401 1400 # start/due dates = lowest/highest dates of children
1402 1401 p.start_date = p.children.minimum(:start_date)
1403 1402 p.due_date = p.children.maximum(:due_date)
1404 1403 if p.start_date && p.due_date && p.due_date < p.start_date
1405 1404 p.start_date, p.due_date = p.due_date, p.start_date
1406 1405 end
1407 1406
1408 1407 # done ratio = weighted average ratio of leaves
1409 1408 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
1410 1409 leaves_count = p.leaves.count
1411 1410 if leaves_count > 0
1412 1411 average = p.leaves.where("estimated_hours > 0").average(:estimated_hours).to_f
1413 1412 if average == 0
1414 1413 average = 1
1415 1414 end
1416 1415 done = p.leaves.joins(:status).
1417 1416 sum("COALESCE(CASE WHEN estimated_hours > 0 THEN estimated_hours ELSE NULL END, #{average}) " +
1418 1417 "* (CASE WHEN is_closed = #{self.class.connection.quoted_true} THEN 100 ELSE COALESCE(done_ratio, 0) END)").to_f
1419 1418 progress = done / (average * leaves_count)
1420 1419 p.done_ratio = progress.round
1421 1420 end
1422 1421 end
1423 1422
1424 1423 # estimate = sum of leaves estimates
1425 1424 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
1426 1425 p.estimated_hours = nil if p.estimated_hours == 0.0
1427 1426
1428 1427 # ancestors will be recursively updated
1429 1428 p.save(:validate => false)
1430 1429 end
1431 1430 end
1432 1431
1433 1432 # Update issues so their versions are not pointing to a
1434 1433 # fixed_version that is not shared with the issue's project
1435 1434 def self.update_versions(conditions=nil)
1436 1435 # Only need to update issues with a fixed_version from
1437 1436 # a different project and that is not systemwide shared
1438 1437 Issue.joins(:project, :fixed_version).
1439 1438 where("#{Issue.table_name}.fixed_version_id IS NOT NULL" +
1440 1439 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
1441 1440 " AND #{Version.table_name}.sharing <> 'system'").
1442 1441 where(conditions).each do |issue|
1443 1442 next if issue.project.nil? || issue.fixed_version.nil?
1444 1443 unless issue.project.shared_versions.include?(issue.fixed_version)
1445 1444 issue.init_journal(User.current)
1446 1445 issue.fixed_version = nil
1447 1446 issue.save
1448 1447 end
1449 1448 end
1450 1449 end
1451 1450
1452 1451 # Callback on file attachment
1453 1452 def attachment_added(obj)
1454 1453 if @current_journal && !obj.new_record?
1455 1454 @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => obj.id, :value => obj.filename)
1456 1455 end
1457 1456 end
1458 1457
1459 1458 # Callback on attachment deletion
1460 1459 def attachment_removed(obj)
1461 1460 if @current_journal && !obj.new_record?
1462 1461 @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => obj.id, :old_value => obj.filename)
1463 1462 @current_journal.save
1464 1463 end
1465 1464 end
1466 1465
1467 1466 # Default assignment based on category
1468 1467 def default_assign
1469 1468 if assigned_to.nil? && category && category.assigned_to
1470 1469 self.assigned_to = category.assigned_to
1471 1470 end
1472 1471 end
1473 1472
1474 1473 # Updates start/due dates of following issues
1475 1474 def reschedule_following_issues
1476 1475 if start_date_changed? || due_date_changed?
1477 1476 relations_from.each do |relation|
1478 1477 relation.set_issue_to_dates
1479 1478 end
1480 1479 end
1481 1480 end
1482 1481
1483 1482 # Closes duplicates if the issue is being closed
1484 1483 def close_duplicates
1485 1484 if closing?
1486 1485 duplicates.each do |duplicate|
1487 1486 # Reload is needed in case the duplicate was updated by a previous duplicate
1488 1487 duplicate.reload
1489 1488 # Don't re-close it if it's already closed
1490 1489 next if duplicate.closed?
1491 1490 # Same user and notes
1492 1491 if @current_journal
1493 1492 duplicate.init_journal(@current_journal.user, @current_journal.notes)
1494 1493 end
1495 1494 duplicate.update_attribute :status, self.status
1496 1495 end
1497 1496 end
1498 1497 end
1499 1498
1500 1499 # Make sure updated_on is updated when adding a note and set updated_on now
1501 1500 # so we can set closed_on with the same value on closing
1502 1501 def force_updated_on_change
1503 1502 if @current_journal || changed?
1504 1503 self.updated_on = current_time_from_proper_timezone
1505 1504 if new_record?
1506 1505 self.created_on = updated_on
1507 1506 end
1508 1507 end
1509 1508 end
1510 1509
1511 1510 # Callback for setting closed_on when the issue is closed.
1512 1511 # The closed_on attribute stores the time of the last closing
1513 1512 # and is preserved when the issue is reopened.
1514 1513 def update_closed_on
1515 1514 if closing? || (new_record? && closed?)
1516 1515 self.closed_on = updated_on
1517 1516 end
1518 1517 end
1519 1518
1520 1519 # Saves the changes in a Journal
1521 1520 # Called after_save
1522 1521 def create_journal
1523 1522 if @current_journal
1524 1523 # attributes changes
1525 1524 if @attributes_before_change
1526 1525 (Issue.column_names - %w(id root_id lft rgt lock_version created_on updated_on closed_on)).each {|c|
1527 1526 before = @attributes_before_change[c]
1528 1527 after = send(c)
1529 1528 next if before == after || (before.blank? && after.blank?)
1530 1529 @current_journal.details << JournalDetail.new(:property => 'attr',
1531 1530 :prop_key => c,
1532 1531 :old_value => before,
1533 1532 :value => after)
1534 1533 }
1535 1534 end
1536 1535 if @custom_values_before_change
1537 1536 # custom fields changes
1538 1537 custom_field_values.each {|c|
1539 1538 before = @custom_values_before_change[c.custom_field_id]
1540 1539 after = c.value
1541 1540 next if before == after || (before.blank? && after.blank?)
1542 1541
1543 1542 if before.is_a?(Array) || after.is_a?(Array)
1544 1543 before = [before] unless before.is_a?(Array)
1545 1544 after = [after] unless after.is_a?(Array)
1546 1545
1547 1546 # values removed
1548 1547 (before - after).reject(&:blank?).each do |value|
1549 1548 @current_journal.details << JournalDetail.new(:property => 'cf',
1550 1549 :prop_key => c.custom_field_id,
1551 1550 :old_value => value,
1552 1551 :value => nil)
1553 1552 end
1554 1553 # values added
1555 1554 (after - before).reject(&:blank?).each do |value|
1556 1555 @current_journal.details << JournalDetail.new(:property => 'cf',
1557 1556 :prop_key => c.custom_field_id,
1558 1557 :old_value => nil,
1559 1558 :value => value)
1560 1559 end
1561 1560 else
1562 1561 @current_journal.details << JournalDetail.new(:property => 'cf',
1563 1562 :prop_key => c.custom_field_id,
1564 1563 :old_value => before,
1565 1564 :value => after)
1566 1565 end
1567 1566 }
1568 1567 end
1569 1568 @current_journal.save
1570 1569 # reset current journal
1571 1570 init_journal @current_journal.user, @current_journal.notes
1572 1571 end
1573 1572 end
1574 1573
1575 1574 def send_notification
1576 1575 if Setting.notified_events.include?('issue_added')
1577 1576 Mailer.deliver_issue_add(self)
1578 1577 end
1579 1578 end
1580 1579
1581 1580 # Stores the previous assignee so we can still have access
1582 1581 # to it during after_save callbacks (assigned_to_id_was is reset)
1583 1582 def set_assigned_to_was
1584 1583 @previous_assigned_to_id = assigned_to_id_was
1585 1584 end
1586 1585
1587 1586 # Clears the previous assignee at the end of after_save callbacks
1588 1587 def clear_assigned_to_was
1589 1588 @assigned_to_was = nil
1590 1589 @previous_assigned_to_id = nil
1591 1590 end
1592 1591 end
@@ -1,1067 +1,1068
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2014 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class Project < ActiveRecord::Base
19 19 include Redmine::SafeAttributes
20 20
21 21 # Project statuses
22 22 STATUS_ACTIVE = 1
23 23 STATUS_CLOSED = 5
24 24 STATUS_ARCHIVED = 9
25 25
26 26 # Maximum length for project identifiers
27 27 IDENTIFIER_MAX_LENGTH = 100
28 28
29 29 # Specific overridden Activities
30 30 has_many :time_entry_activities
31 31 has_many :members,
32 32 lambda { joins(:principal, :roles).
33 33 where("#{Principal.table_name}.type='User' AND #{Principal.table_name}.status=#{Principal::STATUS_ACTIVE}") }
34 34 has_many :memberships, :class_name => 'Member'
35 35 has_many :member_principals,
36 36 lambda { joins(:principal).
37 37 where("#{Principal.table_name}.status=#{Principal::STATUS_ACTIVE}")},
38 38 :class_name => 'Member'
39 39 has_many :enabled_modules, :dependent => :delete_all
40 40 has_and_belongs_to_many :trackers, lambda {order("#{Tracker.table_name}.position")}
41 41 has_many :issues, :dependent => :destroy
42 42 has_many :issue_changes, :through => :issues, :source => :journals
43 43 has_many :versions, lambda {order("#{Version.table_name}.effective_date DESC, #{Version.table_name}.name DESC")}, :dependent => :destroy
44 44 has_many :time_entries, :dependent => :destroy
45 45 has_many :queries, :class_name => 'IssueQuery', :dependent => :delete_all
46 46 has_many :documents, :dependent => :destroy
47 47 has_many :news, lambda {includes(:author)}, :dependent => :destroy
48 48 has_many :issue_categories, lambda {order("#{IssueCategory.table_name}.name")}, :dependent => :delete_all
49 49 has_many :boards, lambda {order("position ASC")}, :dependent => :destroy
50 50 has_one :repository, lambda {where(["is_default = ?", true])}
51 51 has_many :repositories, :dependent => :destroy
52 52 has_many :changesets, :through => :repository
53 53 has_one :wiki, :dependent => :destroy
54 54 # Custom field for the project issues
55 55 has_and_belongs_to_many :issue_custom_fields,
56 56 lambda {order("#{CustomField.table_name}.position")},
57 57 :class_name => 'IssueCustomField',
58 58 :join_table => "#{table_name_prefix}custom_fields_projects#{table_name_suffix}",
59 59 :association_foreign_key => 'custom_field_id'
60 60
61 61 acts_as_nested_set :dependent => :destroy
62 62 acts_as_attachable :view_permission => :view_files,
63 63 :delete_permission => :manage_files
64 64
65 65 acts_as_customizable
66 66 acts_as_searchable :columns => ['name', 'identifier', 'description'], :project_key => 'id', :permission => nil
67 67 acts_as_event :title => Proc.new {|o| "#{l(:label_project)}: #{o.name}"},
68 68 :url => Proc.new {|o| {:controller => 'projects', :action => 'show', :id => o}},
69 69 :author => nil
70 70
71 71 attr_protected :status
72 72
73 73 validates_presence_of :name, :identifier
74 74 validates_uniqueness_of :identifier
75 75 validates_associated :repository, :wiki
76 76 validates_length_of :name, :maximum => 255
77 77 validates_length_of :homepage, :maximum => 255
78 78 validates_length_of :identifier, :in => 1..IDENTIFIER_MAX_LENGTH
79 79 # downcase letters, digits, dashes but not digits only
80 80 validates_format_of :identifier, :with => /\A(?!\d+$)[a-z0-9\-_]*\z/, :if => Proc.new { |p| p.identifier_changed? }
81 81 # reserved words
82 82 validates_exclusion_of :identifier, :in => %w( new )
83 83
84 84 after_save :update_position_under_parent, :if => Proc.new {|project| project.name_changed?}
85 85 after_save :update_inherited_members, :if => Proc.new {|project| project.inherit_members_changed?}
86 86 before_destroy :delete_all_members
87 87
88 88 scope :has_module, lambda {|mod|
89 89 where("#{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name=?)", mod.to_s)
90 90 }
91 91 scope :active, lambda { where(:status => STATUS_ACTIVE) }
92 92 scope :status, lambda {|arg| where(arg.blank? ? nil : {:status => arg.to_i}) }
93 93 scope :all_public, lambda { where(:is_public => true) }
94 94 scope :visible, lambda {|*args| where(Project.visible_condition(args.shift || User.current, *args)) }
95 95 scope :allowed_to, lambda {|*args|
96 96 user = User.current
97 97 permission = nil
98 98 if args.first.is_a?(Symbol)
99 99 permission = args.shift
100 100 else
101 101 user = args.shift
102 102 permission = args.shift
103 103 end
104 104 where(Project.allowed_to_condition(user, permission, *args))
105 105 }
106 106 scope :like, lambda {|arg|
107 107 if arg.blank?
108 108 where(nil)
109 109 else
110 110 pattern = "%#{arg.to_s.strip.downcase}%"
111 111 where("LOWER(identifier) LIKE :p OR LOWER(name) LIKE :p", :p => pattern)
112 112 end
113 113 }
114 114
115 115 def initialize(attributes=nil, *args)
116 116 super
117 117
118 118 initialized = (attributes || {}).stringify_keys
119 119 if !initialized.key?('identifier') && Setting.sequential_project_identifiers?
120 120 self.identifier = Project.next_identifier
121 121 end
122 122 if !initialized.key?('is_public')
123 123 self.is_public = Setting.default_projects_public?
124 124 end
125 125 if !initialized.key?('enabled_module_names')
126 126 self.enabled_module_names = Setting.default_projects_modules
127 127 end
128 128 if !initialized.key?('trackers') && !initialized.key?('tracker_ids')
129 129 default = Setting.default_projects_tracker_ids
130 130 if default.is_a?(Array)
131 131 self.trackers = Tracker.where(:id => default.map(&:to_i)).sorted.to_a
132 132 else
133 133 self.trackers = Tracker.sorted.to_a
134 134 end
135 135 end
136 136 end
137 137
138 138 def identifier=(identifier)
139 139 super unless identifier_frozen?
140 140 end
141 141
142 142 def identifier_frozen?
143 143 errors[:identifier].blank? && !(new_record? || identifier.blank?)
144 144 end
145 145
146 146 # returns latest created projects
147 147 # non public projects will be returned only if user is a member of those
148 148 def self.latest(user=nil, count=5)
149 149 visible(user).limit(count).order("created_on DESC").to_a
150 150 end
151 151
152 152 # Returns true if the project is visible to +user+ or to the current user.
153 153 def visible?(user=User.current)
154 154 user.allowed_to?(:view_project, self)
155 155 end
156 156
157 157 # Returns a SQL conditions string used to find all projects visible by the specified user.
158 158 #
159 159 # Examples:
160 160 # Project.visible_condition(admin) => "projects.status = 1"
161 161 # Project.visible_condition(normal_user) => "((projects.status = 1) AND (projects.is_public = 1 OR projects.id IN (1,3,4)))"
162 162 # Project.visible_condition(anonymous) => "((projects.status = 1) AND (projects.is_public = 1))"
163 163 def self.visible_condition(user, options={})
164 164 allowed_to_condition(user, :view_project, options)
165 165 end
166 166
167 167 # Returns a SQL conditions string used to find all projects for which +user+ has the given +permission+
168 168 #
169 169 # Valid options:
170 170 # * :project => limit the condition to project
171 171 # * :with_subprojects => limit the condition to project and its subprojects
172 172 # * :member => limit the condition to the user projects
173 173 def self.allowed_to_condition(user, permission, options={})
174 174 perm = Redmine::AccessControl.permission(permission)
175 175 base_statement = (perm && perm.read? ? "#{Project.table_name}.status <> #{Project::STATUS_ARCHIVED}" : "#{Project.table_name}.status = #{Project::STATUS_ACTIVE}")
176 176 if perm && perm.project_module
177 177 # If the permission belongs to a project module, make sure the module is enabled
178 178 base_statement << " AND #{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name='#{perm.project_module}')"
179 179 end
180 180 if project = options[:project]
181 181 project_statement = project.project_condition(options[:with_subprojects])
182 182 base_statement = "(#{project_statement}) AND (#{base_statement})"
183 183 end
184 184
185 185 if user.admin?
186 186 base_statement
187 187 else
188 188 statement_by_role = {}
189 189 unless options[:member]
190 190 role = user.builtin_role
191 191 if role.allowed_to?(permission)
192 192 statement_by_role[role] = "#{Project.table_name}.is_public = #{connection.quoted_true}"
193 193 end
194 194 end
195 195 user.projects_by_role.each do |role, projects|
196 196 if role.allowed_to?(permission) && projects.any?
197 197 statement_by_role[role] = "#{Project.table_name}.id IN (#{projects.collect(&:id).join(',')})"
198 198 end
199 199 end
200 200 if statement_by_role.empty?
201 201 "1=0"
202 202 else
203 203 if block_given?
204 204 statement_by_role.each do |role, statement|
205 205 if s = yield(role, user)
206 206 statement_by_role[role] = "(#{statement} AND (#{s}))"
207 207 end
208 208 end
209 209 end
210 210 "((#{base_statement}) AND (#{statement_by_role.values.join(' OR ')}))"
211 211 end
212 212 end
213 213 end
214 214
215 215 def override_roles(role)
216 216 group_class = role.anonymous? ? GroupAnonymous : GroupNonMember
217 217 member = member_principals.where("#{Principal.table_name}.type = ?", group_class.name).first
218 218 member ? member.roles.to_a : [role]
219 219 end
220 220
221 221 def principals
222 222 @principals ||= Principal.active.joins(:members).where("#{Member.table_name}.project_id = ?", id).uniq
223 223 end
224 224
225 225 def users
226 226 @users ||= User.active.joins(:members).where("#{Member.table_name}.project_id = ?", id).uniq
227 227 end
228 228
229 229 # Returns the Systemwide and project specific activities
230 230 def activities(include_inactive=false)
231 231 if include_inactive
232 232 return all_activities
233 233 else
234 234 return active_activities
235 235 end
236 236 end
237 237
238 238 # Will create a new Project specific Activity or update an existing one
239 239 #
240 240 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
241 241 # does not successfully save.
242 242 def update_or_create_time_entry_activity(id, activity_hash)
243 243 if activity_hash.respond_to?(:has_key?) && activity_hash.has_key?('parent_id')
244 244 self.create_time_entry_activity_if_needed(activity_hash)
245 245 else
246 246 activity = project.time_entry_activities.find_by_id(id.to_i)
247 247 activity.update_attributes(activity_hash) if activity
248 248 end
249 249 end
250 250
251 251 # Create a new TimeEntryActivity if it overrides a system TimeEntryActivity
252 252 #
253 253 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
254 254 # does not successfully save.
255 255 def create_time_entry_activity_if_needed(activity)
256 256 if activity['parent_id']
257 257 parent_activity = TimeEntryActivity.find(activity['parent_id'])
258 258 activity['name'] = parent_activity.name
259 259 activity['position'] = parent_activity.position
260 260 if Enumeration.overriding_change?(activity, parent_activity)
261 261 project_activity = self.time_entry_activities.create(activity)
262 262 if project_activity.new_record?
263 263 raise ActiveRecord::Rollback, "Overriding TimeEntryActivity was not successfully saved"
264 264 else
265 265 self.time_entries.
266 266 where(["activity_id = ?", parent_activity.id]).
267 267 update_all("activity_id = #{project_activity.id}")
268 268 end
269 269 end
270 270 end
271 271 end
272 272
273 273 # Returns a :conditions SQL string that can be used to find the issues associated with this project.
274 274 #
275 275 # Examples:
276 276 # project.project_condition(true) => "(projects.id = 1 OR (projects.lft > 1 AND projects.rgt < 10))"
277 277 # project.project_condition(false) => "projects.id = 1"
278 278 def project_condition(with_subprojects)
279 279 cond = "#{Project.table_name}.id = #{id}"
280 280 cond = "(#{cond} OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt}))" if with_subprojects
281 281 cond
282 282 end
283 283
284 284 def self.find(*args)
285 285 if args.first && args.first.is_a?(String) && !args.first.match(/^\d*$/)
286 286 project = find_by_identifier(*args)
287 287 raise ActiveRecord::RecordNotFound, "Couldn't find Project with identifier=#{args.first}" if project.nil?
288 288 project
289 289 else
290 290 super
291 291 end
292 292 end
293 293
294 294 def self.find_by_param(*args)
295 295 self.find(*args)
296 296 end
297 297
298 298 alias :base_reload :reload
299 299 def reload(*args)
300 300 @principals = nil
301 301 @users = nil
302 302 @shared_versions = nil
303 303 @rolled_up_versions = nil
304 304 @rolled_up_trackers = nil
305 305 @all_issue_custom_fields = nil
306 306 @all_time_entry_custom_fields = nil
307 307 @to_param = nil
308 308 @allowed_parents = nil
309 309 @allowed_permissions = nil
310 310 @actions_allowed = nil
311 311 @start_date = nil
312 312 @due_date = nil
313 313 @override_members = nil
314 314 base_reload(*args)
315 315 end
316 316
317 317 def to_param
318 318 # id is used for projects with a numeric identifier (compatibility)
319 319 @to_param ||= (identifier.to_s =~ %r{^\d*$} ? id.to_s : identifier)
320 320 end
321 321
322 322 def active?
323 323 self.status == STATUS_ACTIVE
324 324 end
325 325
326 326 def archived?
327 327 self.status == STATUS_ARCHIVED
328 328 end
329 329
330 330 # Archives the project and its descendants
331 331 def archive
332 332 # Check that there is no issue of a non descendant project that is assigned
333 333 # to one of the project or descendant versions
334 334 v_ids = self_and_descendants.collect {|p| p.version_ids}.flatten
335 335 if v_ids.any? &&
336 336 Issue.
337 337 includes(:project).
338 338 where("#{Project.table_name}.lft < ? OR #{Project.table_name}.rgt > ?", lft, rgt).
339 339 where("#{Issue.table_name}.fixed_version_id IN (?)", v_ids).
340 340 exists?
341 341 return false
342 342 end
343 343 Project.transaction do
344 344 archive!
345 345 end
346 346 true
347 347 end
348 348
349 349 # Unarchives the project
350 350 # All its ancestors must be active
351 351 def unarchive
352 352 return false if ancestors.detect {|a| !a.active?}
353 353 update_attribute :status, STATUS_ACTIVE
354 354 end
355 355
356 356 def close
357 357 self_and_descendants.status(STATUS_ACTIVE).update_all :status => STATUS_CLOSED
358 358 end
359 359
360 360 def reopen
361 361 self_and_descendants.status(STATUS_CLOSED).update_all :status => STATUS_ACTIVE
362 362 end
363 363
364 364 # Returns an array of projects the project can be moved to
365 365 # by the current user
366 366 def allowed_parents
367 367 return @allowed_parents if @allowed_parents
368 368 @allowed_parents = Project.where(Project.allowed_to_condition(User.current, :add_subprojects)).to_a
369 369 @allowed_parents = @allowed_parents - self_and_descendants
370 370 if User.current.allowed_to?(:add_project, nil, :global => true) || (!new_record? && parent.nil?)
371 371 @allowed_parents << nil
372 372 end
373 373 unless parent.nil? || @allowed_parents.empty? || @allowed_parents.include?(parent)
374 374 @allowed_parents << parent
375 375 end
376 376 @allowed_parents
377 377 end
378 378
379 379 # Sets the parent of the project with authorization check
380 380 def set_allowed_parent!(p)
381 381 unless p.nil? || p.is_a?(Project)
382 382 if p.to_s.blank?
383 383 p = nil
384 384 else
385 385 p = Project.find_by_id(p)
386 386 return false unless p
387 387 end
388 388 end
389 389 if p.nil?
390 390 if !new_record? && allowed_parents.empty?
391 391 return false
392 392 end
393 393 elsif !allowed_parents.include?(p)
394 394 return false
395 395 end
396 396 set_parent!(p)
397 397 end
398 398
399 399 # Sets the parent of the project
400 400 # Argument can be either a Project, a String, a Fixnum or nil
401 401 def set_parent!(p)
402 402 unless p.nil? || p.is_a?(Project)
403 403 if p.to_s.blank?
404 404 p = nil
405 405 else
406 406 p = Project.find_by_id(p)
407 407 return false unless p
408 408 end
409 409 end
410 410 if p == parent && !p.nil?
411 411 # Nothing to do
412 412 true
413 413 elsif p.nil? || (p.active? && move_possible?(p))
414 414 set_or_update_position_under(p)
415 415 Issue.update_versions_from_hierarchy_change(self)
416 416 true
417 417 else
418 418 # Can not move to the given target
419 419 false
420 420 end
421 421 end
422 422
423 423 # Recalculates all lft and rgt values based on project names
424 424 # Unlike Project.rebuild!, these values are recalculated even if the tree "looks" valid
425 425 # Used in BuildProjectsTree migration
426 426 def self.rebuild_tree!
427 427 transaction do
428 428 update_all "lft = NULL, rgt = NULL"
429 429 rebuild!(false)
430 430 all.each { |p| p.set_or_update_position_under(p.parent) }
431 431 end
432 432 end
433 433
434 434 # Returns an array of the trackers used by the project and its active sub projects
435 435 def rolled_up_trackers
436 436 @rolled_up_trackers ||=
437 437 Tracker.
438 438 joins(:projects).
439 439 joins("JOIN #{EnabledModule.table_name} ON #{EnabledModule.table_name}.project_id = #{Project.table_name}.id AND #{EnabledModule.table_name}.name = 'issue_tracking'").
440 440 select("DISTINCT #{Tracker.table_name}.*").
441 441 where("#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ? AND #{Project.table_name}.status <> #{STATUS_ARCHIVED}", lft, rgt).
442 442 sorted.
443 443 to_a
444 444 end
445 445
446 446 # Closes open and locked project versions that are completed
447 447 def close_completed_versions
448 448 Version.transaction do
449 449 versions.where(:status => %w(open locked)).each do |version|
450 450 if version.completed?
451 451 version.update_attribute(:status, 'closed')
452 452 end
453 453 end
454 454 end
455 455 end
456 456
457 457 # Returns a scope of the Versions on subprojects
458 458 def rolled_up_versions
459 459 @rolled_up_versions ||=
460 460 Version.
461 461 joins(:project).
462 462 where("#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ? AND #{Project.table_name}.status <> ?", lft, rgt, STATUS_ARCHIVED)
463 463 end
464 464
465 465 # Returns a scope of the Versions used by the project
466 466 def shared_versions
467 467 if new_record?
468 468 Version.
469 469 joins(:project).
470 470 preload(:project).
471 471 where("#{Project.table_name}.status <> ? AND #{Version.table_name}.sharing = 'system'", STATUS_ARCHIVED)
472 472 else
473 473 @shared_versions ||= begin
474 474 r = root? ? self : root
475 475 Version.
476 476 joins(:project).
477 477 preload(:project).
478 478 where("#{Project.table_name}.id = #{id}" +
479 479 " OR (#{Project.table_name}.status <> #{Project::STATUS_ARCHIVED} AND (" +
480 480 " #{Version.table_name}.sharing = 'system'" +
481 481 " OR (#{Project.table_name}.lft >= #{r.lft} AND #{Project.table_name}.rgt <= #{r.rgt} AND #{Version.table_name}.sharing = 'tree')" +
482 482 " OR (#{Project.table_name}.lft < #{lft} AND #{Project.table_name}.rgt > #{rgt} AND #{Version.table_name}.sharing IN ('hierarchy', 'descendants'))" +
483 483 " OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt} AND #{Version.table_name}.sharing = 'hierarchy')" +
484 484 "))")
485 485 end
486 486 end
487 487 end
488 488
489 489 # Returns a hash of project users grouped by role
490 490 def users_by_role
491 491 members.includes(:user, :roles).inject({}) do |h, m|
492 492 m.roles.each do |r|
493 493 h[r] ||= []
494 494 h[r] << m.user
495 495 end
496 496 h
497 497 end
498 498 end
499 499
500 500 # Deletes all project's members
501 501 def delete_all_members
502 502 me, mr = Member.table_name, MemberRole.table_name
503 503 self.class.connection.delete("DELETE FROM #{mr} WHERE #{mr}.member_id IN (SELECT #{me}.id FROM #{me} WHERE #{me}.project_id = #{id})")
504 504 Member.delete_all(['project_id = ?', id])
505 505 end
506 506
507 507 # Users/groups issues can be assigned to
508 508 def assignable_users
509 509 types = ['User']
510 510 types << 'Group' if Setting.issue_group_assignment?
511 511
512 512 member_principals.
513 513 select {|m| types.include?(m.principal.type) && m.roles.detect(&:assignable?)}.
514 514 map(&:principal).
515 515 sort
516 516 end
517 517
518 518 # Returns the mail addresses of users that should be always notified on project events
519 519 def recipients
520 520 notified_users.collect {|user| user.mail}
521 521 end
522 522
523 523 # Returns the users that should be notified on project events
524 524 def notified_users
525 525 # TODO: User part should be extracted to User#notify_about?
526 526 members.select {|m| m.principal.present? && (m.mail_notification? || m.principal.mail_notification == 'all')}.collect {|m| m.principal}
527 527 end
528 528
529 529 # Returns a scope of all custom fields enabled for project issues
530 530 # (explicitly associated custom fields and custom fields enabled for all projects)
531 531 def all_issue_custom_fields
532 532 @all_issue_custom_fields ||= IssueCustomField.
533 533 sorted.
534 534 where("is_for_all = ? OR id IN (SELECT DISTINCT cfp.custom_field_id" +
535 535 " FROM #{table_name_prefix}custom_fields_projects#{table_name_suffix} cfp" +
536 536 " WHERE cfp.project_id = ?)", true, id)
537 537 end
538 538
539 539 # Returns an array of all custom fields enabled for project time entries
540 540 # (explictly associated custom fields and custom fields enabled for all projects)
541 541 def all_time_entry_custom_fields
542 542 @all_time_entry_custom_fields ||= (TimeEntryCustomField.for_all + time_entry_custom_fields).uniq.sort
543 543 end
544 544
545 545 def project
546 546 self
547 547 end
548 548
549 549 def <=>(project)
550 550 name.downcase <=> project.name.downcase
551 551 end
552 552
553 553 def to_s
554 554 name
555 555 end
556 556
557 557 # Returns a short description of the projects (first lines)
558 558 def short_description(length = 255)
559 559 description.gsub(/^(.{#{length}}[^\n\r]*).*$/m, '\1...').strip if description
560 560 end
561 561
562 562 def css_classes
563 563 s = 'project'
564 564 s << ' root' if root?
565 565 s << ' child' if child?
566 566 s << (leaf? ? ' leaf' : ' parent')
567 567 unless active?
568 568 if archived?
569 569 s << ' archived'
570 570 else
571 571 s << ' closed'
572 572 end
573 573 end
574 574 s
575 575 end
576 576
577 577 # The earliest start date of a project, based on it's issues and versions
578 578 def start_date
579 579 @start_date ||= [
580 580 issues.minimum('start_date'),
581 581 shared_versions.minimum('effective_date'),
582 582 Issue.fixed_version(shared_versions).minimum('start_date')
583 583 ].compact.min
584 584 end
585 585
586 586 # The latest due date of an issue or version
587 587 def due_date
588 588 @due_date ||= [
589 589 issues.maximum('due_date'),
590 590 shared_versions.maximum('effective_date'),
591 591 Issue.fixed_version(shared_versions).maximum('due_date')
592 592 ].compact.max
593 593 end
594 594
595 595 def overdue?
596 596 active? && !due_date.nil? && (due_date < Date.today)
597 597 end
598 598
599 599 # Returns the percent completed for this project, based on the
600 600 # progress on it's versions.
601 601 def completed_percent(options={:include_subprojects => false})
602 602 if options.delete(:include_subprojects)
603 603 total = self_and_descendants.collect(&:completed_percent).sum
604 604
605 605 total / self_and_descendants.count
606 606 else
607 607 if versions.count > 0
608 608 total = versions.collect(&:completed_percent).sum
609 609
610 610 total / versions.count
611 611 else
612 612 100
613 613 end
614 614 end
615 615 end
616 616
617 617 # Return true if this project allows to do the specified action.
618 618 # action can be:
619 619 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
620 620 # * a permission Symbol (eg. :edit_project)
621 621 def allows_to?(action)
622 622 if archived?
623 623 # No action allowed on archived projects
624 624 return false
625 625 end
626 626 unless active? || Redmine::AccessControl.read_action?(action)
627 627 # No write action allowed on closed projects
628 628 return false
629 629 end
630 630 # No action allowed on disabled modules
631 631 if action.is_a? Hash
632 632 allowed_actions.include? "#{action[:controller]}/#{action[:action]}"
633 633 else
634 634 allowed_permissions.include? action
635 635 end
636 636 end
637 637
638 638 # Return the enabled module with the given name
639 639 # or nil if the module is not enabled for the project
640 640 def enabled_module(name)
641 641 name = name.to_s
642 642 enabled_modules.detect {|m| m.name == name}
643 643 end
644 644
645 645 # Return true if the module with the given name is enabled
646 646 def module_enabled?(name)
647 647 enabled_module(name).present?
648 648 end
649 649
650 650 def enabled_module_names=(module_names)
651 651 if module_names && module_names.is_a?(Array)
652 652 module_names = module_names.collect(&:to_s).reject(&:blank?)
653 653 self.enabled_modules = module_names.collect {|name| enabled_modules.detect {|mod| mod.name == name} || EnabledModule.new(:name => name)}
654 654 else
655 655 enabled_modules.clear
656 656 end
657 657 end
658 658
659 659 # Returns an array of the enabled modules names
660 660 def enabled_module_names
661 661 enabled_modules.collect(&:name)
662 662 end
663 663
664 664 # Enable a specific module
665 665 #
666 666 # Examples:
667 667 # project.enable_module!(:issue_tracking)
668 668 # project.enable_module!("issue_tracking")
669 669 def enable_module!(name)
670 670 enabled_modules << EnabledModule.new(:name => name.to_s) unless module_enabled?(name)
671 671 end
672 672
673 673 # Disable a module if it exists
674 674 #
675 675 # Examples:
676 676 # project.disable_module!(:issue_tracking)
677 677 # project.disable_module!("issue_tracking")
678 678 # project.disable_module!(project.enabled_modules.first)
679 679 def disable_module!(target)
680 680 target = enabled_modules.detect{|mod| target.to_s == mod.name} unless enabled_modules.include?(target)
681 681 target.destroy unless target.blank?
682 682 end
683 683
684 684 safe_attributes 'name',
685 685 'description',
686 686 'homepage',
687 687 'is_public',
688 688 'identifier',
689 689 'custom_field_values',
690 690 'custom_fields',
691 691 'tracker_ids',
692 692 'issue_custom_field_ids'
693 693
694 694 safe_attributes 'enabled_module_names',
695 695 :if => lambda {|project, user| project.new_record? || user.allowed_to?(:select_project_modules, project) }
696 696
697 697 safe_attributes 'inherit_members',
698 698 :if => lambda {|project, user| project.parent.nil? || project.parent.visible?(user)}
699 699
700 700 # Returns an array of projects that are in this project's hierarchy
701 701 #
702 702 # Example: parents, children, siblings
703 703 def hierarchy
704 704 parents = project.self_and_ancestors || []
705 705 descendants = project.descendants || []
706 706 project_hierarchy = parents | descendants # Set union
707 707 end
708 708
709 709 # Returns an auto-generated project identifier based on the last identifier used
710 710 def self.next_identifier
711 711 p = Project.order('id DESC').first
712 712 p.nil? ? nil : p.identifier.to_s.succ
713 713 end
714 714
715 715 # Copies and saves the Project instance based on the +project+.
716 716 # Duplicates the source project's:
717 717 # * Wiki
718 718 # * Versions
719 719 # * Categories
720 720 # * Issues
721 721 # * Members
722 722 # * Queries
723 723 #
724 724 # Accepts an +options+ argument to specify what to copy
725 725 #
726 726 # Examples:
727 727 # project.copy(1) # => copies everything
728 728 # project.copy(1, :only => 'members') # => copies members only
729 729 # project.copy(1, :only => ['members', 'versions']) # => copies members and versions
730 730 def copy(project, options={})
731 731 project = project.is_a?(Project) ? project : Project.find(project)
732 732
733 733 to_be_copied = %w(wiki versions issue_categories issues members queries boards)
734 734 to_be_copied = to_be_copied & Array.wrap(options[:only]) unless options[:only].nil?
735 735
736 736 Project.transaction do
737 737 if save
738 738 reload
739 739 to_be_copied.each do |name|
740 740 send "copy_#{name}", project
741 741 end
742 742 Redmine::Hook.call_hook(:model_project_copy_before_save, :source_project => project, :destination_project => self)
743 743 save
744 744 end
745 745 end
746 746 true
747 747 end
748 748
749 749 # Returns a new unsaved Project instance with attributes copied from +project+
750 750 def self.copy_from(project)
751 751 project = project.is_a?(Project) ? project : Project.find(project)
752 752 # clear unique attributes
753 753 attributes = project.attributes.dup.except('id', 'name', 'identifier', 'status', 'parent_id', 'lft', 'rgt')
754 754 copy = Project.new(attributes)
755 755 copy.enabled_modules = project.enabled_modules
756 756 copy.trackers = project.trackers
757 757 copy.custom_values = project.custom_values.collect {|v| v.clone}
758 758 copy.issue_custom_fields = project.issue_custom_fields
759 759 copy
760 760 end
761 761
762 762 # Yields the given block for each project with its level in the tree
763 763 def self.project_tree(projects, &block)
764 764 ancestors = []
765 765 projects.sort_by(&:lft).each do |project|
766 766 while (ancestors.any? && !project.is_descendant_of?(ancestors.last))
767 767 ancestors.pop
768 768 end
769 769 yield project, ancestors.size
770 770 ancestors << project
771 771 end
772 772 end
773 773
774 774 private
775 775
776 776 def after_parent_changed(parent_was)
777 777 remove_inherited_member_roles
778 778 add_inherited_member_roles
779 779 end
780 780
781 781 def update_inherited_members
782 782 if parent
783 783 if inherit_members? && !inherit_members_was
784 784 remove_inherited_member_roles
785 785 add_inherited_member_roles
786 786 elsif !inherit_members? && inherit_members_was
787 787 remove_inherited_member_roles
788 788 end
789 789 end
790 790 end
791 791
792 792 def remove_inherited_member_roles
793 793 member_roles = memberships.map(&:member_roles).flatten
794 794 member_role_ids = member_roles.map(&:id)
795 795 member_roles.each do |member_role|
796 796 if member_role.inherited_from && !member_role_ids.include?(member_role.inherited_from)
797 797 member_role.destroy
798 798 end
799 799 end
800 800 end
801 801
802 802 def add_inherited_member_roles
803 803 if inherit_members? && parent
804 804 parent.memberships.each do |parent_member|
805 805 member = Member.find_or_new(self.id, parent_member.user_id)
806 806 parent_member.member_roles.each do |parent_member_role|
807 807 member.member_roles << MemberRole.new(:role => parent_member_role.role, :inherited_from => parent_member_role.id)
808 808 end
809 809 member.save!
810 810 end
811 811 end
812 812 end
813 813
814 814 # Copies wiki from +project+
815 815 def copy_wiki(project)
816 816 # Check that the source project has a wiki first
817 817 unless project.wiki.nil?
818 818 wiki = self.wiki || Wiki.new
819 819 wiki.attributes = project.wiki.attributes.dup.except("id", "project_id")
820 820 wiki_pages_map = {}
821 821 project.wiki.pages.each do |page|
822 822 # Skip pages without content
823 823 next if page.content.nil?
824 824 new_wiki_content = WikiContent.new(page.content.attributes.dup.except("id", "page_id", "updated_on"))
825 825 new_wiki_page = WikiPage.new(page.attributes.dup.except("id", "wiki_id", "created_on", "parent_id"))
826 826 new_wiki_page.content = new_wiki_content
827 827 wiki.pages << new_wiki_page
828 828 wiki_pages_map[page.id] = new_wiki_page
829 829 end
830 830
831 831 self.wiki = wiki
832 832 wiki.save
833 833 # Reproduce page hierarchy
834 834 project.wiki.pages.each do |page|
835 835 if page.parent_id && wiki_pages_map[page.id]
836 836 wiki_pages_map[page.id].parent = wiki_pages_map[page.parent_id]
837 837 wiki_pages_map[page.id].save
838 838 end
839 839 end
840 840 end
841 841 end
842 842
843 843 # Copies versions from +project+
844 844 def copy_versions(project)
845 845 project.versions.each do |version|
846 846 new_version = Version.new
847 847 new_version.attributes = version.attributes.dup.except("id", "project_id", "created_on", "updated_on")
848 848 self.versions << new_version
849 849 end
850 850 end
851 851
852 852 # Copies issue categories from +project+
853 853 def copy_issue_categories(project)
854 854 project.issue_categories.each do |issue_category|
855 855 new_issue_category = IssueCategory.new
856 856 new_issue_category.attributes = issue_category.attributes.dup.except("id", "project_id")
857 857 self.issue_categories << new_issue_category
858 858 end
859 859 end
860 860
861 861 # Copies issues from +project+
862 862 def copy_issues(project)
863 863 # Stores the source issue id as a key and the copied issues as the
864 864 # value. Used to map the two together for issue relations.
865 865 issues_map = {}
866 866
867 867 # Store status and reopen locked/closed versions
868 868 version_statuses = versions.reject(&:open?).map {|version| [version, version.status]}
869 869 version_statuses.each do |version, status|
870 870 version.update_attribute :status, 'open'
871 871 end
872 872
873 873 # Get issues sorted by root_id, lft so that parent issues
874 874 # get copied before their children
875 875 project.issues.reorder('root_id, lft').each do |issue|
876 876 new_issue = Issue.new
877 877 new_issue.copy_from(issue, :subtasks => false, :link => false)
878 878 new_issue.project = self
879 879 # Changing project resets the custom field values
880 880 # TODO: handle this in Issue#project=
881 881 new_issue.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
882 882 # Reassign fixed_versions by name, since names are unique per project
883 883 if issue.fixed_version && issue.fixed_version.project == project
884 884 new_issue.fixed_version = self.versions.detect {|v| v.name == issue.fixed_version.name}
885 885 end
886 886 # Reassign the category by name, since names are unique per project
887 887 if issue.category
888 888 new_issue.category = self.issue_categories.detect {|c| c.name == issue.category.name}
889 889 end
890 890 # Parent issue
891 891 if issue.parent_id
892 892 if copied_parent = issues_map[issue.parent_id]
893 893 new_issue.parent_issue_id = copied_parent.id
894 894 end
895 895 end
896 896
897 897 self.issues << new_issue
898 898 if new_issue.new_record?
899 899 logger.info "Project#copy_issues: issue ##{issue.id} could not be copied: #{new_issue.errors.full_messages}" if logger && logger.info
900 900 else
901 901 issues_map[issue.id] = new_issue unless new_issue.new_record?
902 902 end
903 903 end
904 904
905 905 # Restore locked/closed version statuses
906 906 version_statuses.each do |version, status|
907 907 version.update_attribute :status, status
908 908 end
909 909
910 910 # Relations after in case issues related each other
911 911 project.issues.each do |issue|
912 912 new_issue = issues_map[issue.id]
913 913 unless new_issue
914 914 # Issue was not copied
915 915 next
916 916 end
917 917
918 918 # Relations
919 919 issue.relations_from.each do |source_relation|
920 920 new_issue_relation = IssueRelation.new
921 921 new_issue_relation.attributes = source_relation.attributes.dup.except("id", "issue_from_id", "issue_to_id")
922 922 new_issue_relation.issue_to = issues_map[source_relation.issue_to_id]
923 923 if new_issue_relation.issue_to.nil? && Setting.cross_project_issue_relations?
924 924 new_issue_relation.issue_to = source_relation.issue_to
925 925 end
926 926 new_issue.relations_from << new_issue_relation
927 927 end
928 928
929 929 issue.relations_to.each do |source_relation|
930 930 new_issue_relation = IssueRelation.new
931 931 new_issue_relation.attributes = source_relation.attributes.dup.except("id", "issue_from_id", "issue_to_id")
932 932 new_issue_relation.issue_from = issues_map[source_relation.issue_from_id]
933 933 if new_issue_relation.issue_from.nil? && Setting.cross_project_issue_relations?
934 934 new_issue_relation.issue_from = source_relation.issue_from
935 935 end
936 936 new_issue.relations_to << new_issue_relation
937 937 end
938 938 end
939 939 end
940 940
941 941 # Copies members from +project+
942 942 def copy_members(project)
943 943 # Copy users first, then groups to handle members with inherited and given roles
944 944 members_to_copy = []
945 945 members_to_copy += project.memberships.select {|m| m.principal.is_a?(User)}
946 946 members_to_copy += project.memberships.select {|m| !m.principal.is_a?(User)}
947 947
948 948 members_to_copy.each do |member|
949 949 new_member = Member.new
950 950 new_member.attributes = member.attributes.dup.except("id", "project_id", "created_on")
951 951 # only copy non inherited roles
952 952 # inherited roles will be added when copying the group membership
953 953 role_ids = member.member_roles.reject(&:inherited?).collect(&:role_id)
954 954 next if role_ids.empty?
955 955 new_member.role_ids = role_ids
956 956 new_member.project = self
957 957 self.members << new_member
958 958 end
959 959 end
960 960
961 961 # Copies queries from +project+
962 962 def copy_queries(project)
963 963 project.queries.each do |query|
964 964 new_query = IssueQuery.new
965 new_query.attributes = query.attributes.dup.except("id", "project_id", "sort_criteria")
965 new_query.attributes = query.attributes.dup.except("id", "project_id", "sort_criteria", "user_id", "type")
966 966 new_query.sort_criteria = query.sort_criteria if query.sort_criteria
967 967 new_query.project = self
968 968 new_query.user_id = query.user_id
969 new_query.role_ids = query.role_ids if query.visibility == IssueQuery::VISIBILITY_ROLES
969 970 self.queries << new_query
970 971 end
971 972 end
972 973
973 974 # Copies boards from +project+
974 975 def copy_boards(project)
975 976 project.boards.each do |board|
976 977 new_board = Board.new
977 978 new_board.attributes = board.attributes.dup.except("id", "project_id", "topics_count", "messages_count", "last_message_id")
978 979 new_board.project = self
979 980 self.boards << new_board
980 981 end
981 982 end
982 983
983 984 def allowed_permissions
984 985 @allowed_permissions ||= begin
985 986 module_names = enabled_modules.loaded? ? enabled_modules.map(&:name) : enabled_modules.pluck(:name)
986 987 Redmine::AccessControl.modules_permissions(module_names).collect {|p| p.name}
987 988 end
988 989 end
989 990
990 991 def allowed_actions
991 992 @actions_allowed ||= allowed_permissions.inject([]) { |actions, permission| actions += Redmine::AccessControl.allowed_actions(permission) }.flatten
992 993 end
993 994
994 995 # Returns all the active Systemwide and project specific activities
995 996 def active_activities
996 997 overridden_activity_ids = self.time_entry_activities.collect(&:parent_id)
997 998
998 999 if overridden_activity_ids.empty?
999 1000 return TimeEntryActivity.shared.active
1000 1001 else
1001 1002 return system_activities_and_project_overrides
1002 1003 end
1003 1004 end
1004 1005
1005 1006 # Returns all the Systemwide and project specific activities
1006 1007 # (inactive and active)
1007 1008 def all_activities
1008 1009 overridden_activity_ids = self.time_entry_activities.collect(&:parent_id)
1009 1010
1010 1011 if overridden_activity_ids.empty?
1011 1012 return TimeEntryActivity.shared
1012 1013 else
1013 1014 return system_activities_and_project_overrides(true)
1014 1015 end
1015 1016 end
1016 1017
1017 1018 # Returns the systemwide active activities merged with the project specific overrides
1018 1019 def system_activities_and_project_overrides(include_inactive=false)
1019 1020 t = TimeEntryActivity.table_name
1020 1021 scope = TimeEntryActivity.where(
1021 1022 "(#{t}.project_id IS NULL AND #{t}.id NOT IN (?)) OR (#{t}.project_id = ?)",
1022 1023 time_entry_activities.map(&:parent_id), id
1023 1024 )
1024 1025 unless include_inactive
1025 1026 scope = scope.active
1026 1027 end
1027 1028 scope
1028 1029 end
1029 1030
1030 1031 # Archives subprojects recursively
1031 1032 def archive!
1032 1033 children.each do |subproject|
1033 1034 subproject.send :archive!
1034 1035 end
1035 1036 update_attribute :status, STATUS_ARCHIVED
1036 1037 end
1037 1038
1038 1039 def update_position_under_parent
1039 1040 set_or_update_position_under(parent)
1040 1041 end
1041 1042
1042 1043 public
1043 1044
1044 1045 # Inserts/moves the project so that target's children or root projects stay alphabetically sorted
1045 1046 def set_or_update_position_under(target_parent)
1046 1047 parent_was = parent
1047 1048 sibs = (target_parent.nil? ? self.class.roots : target_parent.children)
1048 1049 to_be_inserted_before = sibs.sort_by {|c| c.name.to_s.downcase}.detect {|c| c.name.to_s.downcase > name.to_s.downcase }
1049 1050
1050 1051 if to_be_inserted_before
1051 1052 move_to_left_of(to_be_inserted_before)
1052 1053 elsif target_parent.nil?
1053 1054 if sibs.empty?
1054 1055 # move_to_root adds the project in first (ie. left) position
1055 1056 move_to_root
1056 1057 else
1057 1058 move_to_right_of(sibs.last) unless self == sibs.last
1058 1059 end
1059 1060 else
1060 1061 # move_to_child_of adds the project in last (ie.right) position
1061 1062 move_to_child_of(target_parent)
1062 1063 end
1063 1064 if parent_was != target_parent
1064 1065 after_parent_changed(parent_was)
1065 1066 end
1066 1067 end
1067 1068 end
@@ -1,801 +1,801
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2014 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 require "digest/sha1"
19 19
20 20 class User < Principal
21 21 include Redmine::SafeAttributes
22 22
23 23 # Different ways of displaying/sorting users
24 24 USER_FORMATS = {
25 25 :firstname_lastname => {
26 26 :string => '#{firstname} #{lastname}',
27 27 :order => %w(firstname lastname id),
28 28 :setting_order => 1
29 29 },
30 30 :firstname_lastinitial => {
31 31 :string => '#{firstname} #{lastname.to_s.chars.first}.',
32 32 :order => %w(firstname lastname id),
33 33 :setting_order => 2
34 34 },
35 35 :firstinitial_lastname => {
36 36 :string => '#{firstname.to_s.gsub(/(([[:alpha:]])[[:alpha:]]*\.?)/, \'\2.\')} #{lastname}',
37 37 :order => %w(firstname lastname id),
38 38 :setting_order => 2
39 39 },
40 40 :firstname => {
41 41 :string => '#{firstname}',
42 42 :order => %w(firstname id),
43 43 :setting_order => 3
44 44 },
45 45 :lastname_firstname => {
46 46 :string => '#{lastname} #{firstname}',
47 47 :order => %w(lastname firstname id),
48 48 :setting_order => 4
49 49 },
50 50 :lastname_coma_firstname => {
51 51 :string => '#{lastname}, #{firstname}',
52 52 :order => %w(lastname firstname id),
53 53 :setting_order => 5
54 54 },
55 55 :lastname => {
56 56 :string => '#{lastname}',
57 57 :order => %w(lastname id),
58 58 :setting_order => 6
59 59 },
60 60 :username => {
61 61 :string => '#{login}',
62 62 :order => %w(login id),
63 63 :setting_order => 7
64 64 },
65 65 }
66 66
67 67 MAIL_NOTIFICATION_OPTIONS = [
68 68 ['all', :label_user_mail_option_all],
69 69 ['selected', :label_user_mail_option_selected],
70 70 ['only_my_events', :label_user_mail_option_only_my_events],
71 71 ['only_assigned', :label_user_mail_option_only_assigned],
72 72 ['only_owner', :label_user_mail_option_only_owner],
73 73 ['none', :label_user_mail_option_none]
74 74 ]
75 75
76 76 has_and_belongs_to_many :groups,
77 77 :join_table => "#{table_name_prefix}groups_users#{table_name_suffix}",
78 78 :after_add => Proc.new {|user, group| group.user_added(user)},
79 79 :after_remove => Proc.new {|user, group| group.user_removed(user)}
80 80 has_many :changesets, :dependent => :nullify
81 81 has_one :preference, :dependent => :destroy, :class_name => 'UserPreference'
82 82 has_one :rss_token, lambda {where "action='feeds'"}, :class_name => 'Token'
83 83 has_one :api_token, lambda {where "action='api'"}, :class_name => 'Token'
84 84 belongs_to :auth_source
85 85
86 86 scope :logged, lambda { where("#{User.table_name}.status <> #{STATUS_ANONYMOUS}") }
87 87 scope :status, lambda {|arg| where(arg.blank? ? nil : {:status => arg.to_i}) }
88 88
89 89 acts_as_customizable
90 90
91 91 attr_accessor :password, :password_confirmation, :generate_password
92 92 attr_accessor :last_before_login_on
93 93 # Prevents unauthorized assignments
94 94 attr_protected :login, :admin, :password, :password_confirmation, :hashed_password
95 95
96 96 LOGIN_LENGTH_LIMIT = 60
97 97 MAIL_LENGTH_LIMIT = 60
98 98
99 99 validates_presence_of :login, :firstname, :lastname, :mail, :if => Proc.new { |user| !user.is_a?(AnonymousUser) }
100 100 validates_uniqueness_of :login, :if => Proc.new { |user| user.login_changed? && user.login.present? }, :case_sensitive => false
101 101 validates_uniqueness_of :mail, :if => Proc.new { |user| user.mail_changed? && user.mail.present? }, :case_sensitive => false
102 102 # Login must contain letters, numbers, underscores only
103 103 validates_format_of :login, :with => /\A[a-z0-9_\-@\.]*\z/i
104 104 validates_length_of :login, :maximum => LOGIN_LENGTH_LIMIT
105 105 validates_length_of :firstname, :lastname, :maximum => 30
106 106 validates_format_of :mail, :with => /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\z/i, :allow_blank => true
107 107 validates_length_of :mail, :maximum => MAIL_LENGTH_LIMIT, :allow_nil => true
108 108 validates_inclusion_of :mail_notification, :in => MAIL_NOTIFICATION_OPTIONS.collect(&:first), :allow_blank => true
109 109 validate :validate_password_length
110 110 validate do
111 111 if password_confirmation && password != password_confirmation
112 112 errors.add(:password, :confirmation)
113 113 end
114 114 end
115 115
116 116 before_create :set_mail_notification
117 117 before_save :generate_password_if_needed, :update_hashed_password
118 118 before_destroy :remove_references_before_destroy
119 119 after_save :update_notified_project_ids, :destroy_tokens
120 120
121 121 scope :in_group, lambda {|group|
122 122 group_id = group.is_a?(Group) ? group.id : group.to_i
123 123 where("#{User.table_name}.id IN (SELECT gu.user_id FROM #{table_name_prefix}groups_users#{table_name_suffix} gu WHERE gu.group_id = ?)", group_id)
124 124 }
125 125 scope :not_in_group, lambda {|group|
126 126 group_id = group.is_a?(Group) ? group.id : group.to_i
127 127 where("#{User.table_name}.id NOT IN (SELECT gu.user_id FROM #{table_name_prefix}groups_users#{table_name_suffix} gu WHERE gu.group_id = ?)", group_id)
128 128 }
129 129 scope :sorted, lambda { order(*User.fields_for_order_statement)}
130 130
131 131 def set_mail_notification
132 132 self.mail_notification = Setting.default_notification_option if self.mail_notification.blank?
133 133 true
134 134 end
135 135
136 136 def update_hashed_password
137 137 # update hashed_password if password was set
138 138 if self.password && self.auth_source_id.blank?
139 139 salt_password(password)
140 140 end
141 141 end
142 142
143 143 alias :base_reload :reload
144 144 def reload(*args)
145 145 @name = nil
146 146 @projects_by_role = nil
147 147 @membership_by_project_id = nil
148 148 @notified_projects_ids = nil
149 149 @notified_projects_ids_changed = false
150 150 @builtin_role = nil
151 151 base_reload(*args)
152 152 end
153 153
154 154 def mail=(arg)
155 155 write_attribute(:mail, arg.to_s.strip)
156 156 end
157 157
158 158 def self.find_or_initialize_by_identity_url(url)
159 159 user = where(:identity_url => url).first
160 160 unless user
161 161 user = User.new
162 162 user.identity_url = url
163 163 end
164 164 user
165 165 end
166 166
167 167 def identity_url=(url)
168 168 if url.blank?
169 169 write_attribute(:identity_url, '')
170 170 else
171 171 begin
172 172 write_attribute(:identity_url, OpenIdAuthentication.normalize_identifier(url))
173 173 rescue OpenIdAuthentication::InvalidOpenId
174 174 # Invalid url, don't save
175 175 end
176 176 end
177 177 self.read_attribute(:identity_url)
178 178 end
179 179
180 180 # Returns the user that matches provided login and password, or nil
181 181 def self.try_to_login(login, password, active_only=true)
182 182 login = login.to_s
183 183 password = password.to_s
184 184
185 185 # Make sure no one can sign in with an empty login or password
186 186 return nil if login.empty? || password.empty?
187 187 user = find_by_login(login)
188 188 if user
189 189 # user is already in local database
190 190 return nil unless user.check_password?(password)
191 191 return nil if !user.active? && active_only
192 192 else
193 193 # user is not yet registered, try to authenticate with available sources
194 194 attrs = AuthSource.authenticate(login, password)
195 195 if attrs
196 196 user = new(attrs)
197 197 user.login = login
198 198 user.language = Setting.default_language
199 199 if user.save
200 200 user.reload
201 201 logger.info("User '#{user.login}' created from external auth source: #{user.auth_source.type} - #{user.auth_source.name}") if logger && user.auth_source
202 202 end
203 203 end
204 204 end
205 205 user.update_column(:last_login_on, Time.now) if user && !user.new_record? && user.active?
206 206 user
207 207 rescue => text
208 208 raise text
209 209 end
210 210
211 211 # Returns the user who matches the given autologin +key+ or nil
212 212 def self.try_to_autologin(key)
213 213 user = Token.find_active_user('autologin', key, Setting.autologin.to_i)
214 214 if user
215 215 user.update_column(:last_login_on, Time.now)
216 216 user
217 217 end
218 218 end
219 219
220 220 def self.name_formatter(formatter = nil)
221 221 USER_FORMATS[formatter || Setting.user_format] || USER_FORMATS[:firstname_lastname]
222 222 end
223 223
224 224 # Returns an array of fields names than can be used to make an order statement for users
225 225 # according to how user names are displayed
226 226 # Examples:
227 227 #
228 228 # User.fields_for_order_statement => ['users.login', 'users.id']
229 229 # User.fields_for_order_statement('authors') => ['authors.login', 'authors.id']
230 230 def self.fields_for_order_statement(table=nil)
231 231 table ||= table_name
232 232 name_formatter[:order].map {|field| "#{table}.#{field}"}
233 233 end
234 234
235 235 # Return user's full name for display
236 236 def name(formatter = nil)
237 237 f = self.class.name_formatter(formatter)
238 238 if formatter
239 239 eval('"' + f[:string] + '"')
240 240 else
241 241 @name ||= eval('"' + f[:string] + '"')
242 242 end
243 243 end
244 244
245 245 def active?
246 246 self.status == STATUS_ACTIVE
247 247 end
248 248
249 249 def registered?
250 250 self.status == STATUS_REGISTERED
251 251 end
252 252
253 253 def locked?
254 254 self.status == STATUS_LOCKED
255 255 end
256 256
257 257 def activate
258 258 self.status = STATUS_ACTIVE
259 259 end
260 260
261 261 def register
262 262 self.status = STATUS_REGISTERED
263 263 end
264 264
265 265 def lock
266 266 self.status = STATUS_LOCKED
267 267 end
268 268
269 269 def activate!
270 270 update_attribute(:status, STATUS_ACTIVE)
271 271 end
272 272
273 273 def register!
274 274 update_attribute(:status, STATUS_REGISTERED)
275 275 end
276 276
277 277 def lock!
278 278 update_attribute(:status, STATUS_LOCKED)
279 279 end
280 280
281 281 # Returns true if +clear_password+ is the correct user's password, otherwise false
282 282 def check_password?(clear_password)
283 283 if auth_source_id.present?
284 284 auth_source.authenticate(self.login, clear_password)
285 285 else
286 286 User.hash_password("#{salt}#{User.hash_password clear_password}") == hashed_password
287 287 end
288 288 end
289 289
290 290 # Generates a random salt and computes hashed_password for +clear_password+
291 291 # The hashed password is stored in the following form: SHA1(salt + SHA1(password))
292 292 def salt_password(clear_password)
293 293 self.salt = User.generate_salt
294 294 self.hashed_password = User.hash_password("#{salt}#{User.hash_password clear_password}")
295 295 self.passwd_changed_on = Time.now
296 296 end
297 297
298 298 # Does the backend storage allow this user to change their password?
299 299 def change_password_allowed?
300 300 return true if auth_source.nil?
301 301 return auth_source.allow_password_changes?
302 302 end
303 303
304 304 def must_change_password?
305 305 must_change_passwd? && change_password_allowed?
306 306 end
307 307
308 308 def generate_password?
309 309 generate_password == '1' || generate_password == true
310 310 end
311 311
312 312 # Generate and set a random password on given length
313 313 def random_password(length=40)
314 314 chars = ("a".."z").to_a + ("A".."Z").to_a + ("0".."9").to_a
315 315 chars -= %w(0 O 1 l)
316 316 password = ''
317 317 length.times {|i| password << chars[SecureRandom.random_number(chars.size)] }
318 318 self.password = password
319 319 self.password_confirmation = password
320 320 self
321 321 end
322 322
323 323 def pref
324 324 self.preference ||= UserPreference.new(:user => self)
325 325 end
326 326
327 327 def time_zone
328 328 @time_zone ||= (self.pref.time_zone.blank? ? nil : ActiveSupport::TimeZone[self.pref.time_zone])
329 329 end
330 330
331 331 def force_default_language?
332 332 Setting.force_default_language_for_loggedin?
333 333 end
334 334
335 335 def language
336 336 if force_default_language?
337 337 Setting.default_language
338 338 else
339 339 super
340 340 end
341 341 end
342 342
343 343 def wants_comments_in_reverse_order?
344 344 self.pref[:comments_sorting] == 'desc'
345 345 end
346 346
347 347 # Return user's RSS key (a 40 chars long string), used to access feeds
348 348 def rss_key
349 349 if rss_token.nil?
350 350 create_rss_token(:action => 'feeds')
351 351 end
352 352 rss_token.value
353 353 end
354 354
355 355 # Return user's API key (a 40 chars long string), used to access the API
356 356 def api_key
357 357 if api_token.nil?
358 358 create_api_token(:action => 'api')
359 359 end
360 360 api_token.value
361 361 end
362 362
363 363 # Return an array of project ids for which the user has explicitly turned mail notifications on
364 364 def notified_projects_ids
365 365 @notified_projects_ids ||= memberships.select {|m| m.mail_notification?}.collect(&:project_id)
366 366 end
367 367
368 368 def notified_project_ids=(ids)
369 369 @notified_projects_ids_changed = true
370 370 @notified_projects_ids = ids
371 371 end
372 372
373 373 # Updates per project notifications (after_save callback)
374 374 def update_notified_project_ids
375 375 if @notified_projects_ids_changed
376 376 ids = (mail_notification == 'selected' ? Array.wrap(notified_projects_ids).reject(&:blank?) : [])
377 377 members.update_all(:mail_notification => false)
378 378 members.where(:project_id => ids).update_all(:mail_notification => true) if ids.any?
379 379 end
380 380 end
381 381 private :update_notified_project_ids
382 382
383 383 def valid_notification_options
384 384 self.class.valid_notification_options(self)
385 385 end
386 386
387 387 # Only users that belong to more than 1 project can select projects for which they are notified
388 388 def self.valid_notification_options(user=nil)
389 389 # Note that @user.membership.size would fail since AR ignores
390 390 # :include association option when doing a count
391 391 if user.nil? || user.memberships.length < 1
392 392 MAIL_NOTIFICATION_OPTIONS.reject {|option| option.first == 'selected'}
393 393 else
394 394 MAIL_NOTIFICATION_OPTIONS
395 395 end
396 396 end
397 397
398 398 # Find a user account by matching the exact login and then a case-insensitive
399 399 # version. Exact matches will be given priority.
400 400 def self.find_by_login(login)
401 401 login = Redmine::CodesetUtil.replace_invalid_utf8(login.to_s)
402 402 if login.present?
403 403 # First look for an exact match
404 404 user = where(:login => login).detect {|u| u.login == login}
405 405 unless user
406 406 # Fail over to case-insensitive if none was found
407 407 user = where("LOWER(login) = ?", login.downcase).first
408 408 end
409 409 user
410 410 end
411 411 end
412 412
413 413 def self.find_by_rss_key(key)
414 414 Token.find_active_user('feeds', key)
415 415 end
416 416
417 417 def self.find_by_api_key(key)
418 418 Token.find_active_user('api', key)
419 419 end
420 420
421 421 # Makes find_by_mail case-insensitive
422 422 def self.find_by_mail(mail)
423 423 where("LOWER(mail) = ?", mail.to_s.downcase).first
424 424 end
425 425
426 426 # Returns true if the default admin account can no longer be used
427 427 def self.default_admin_account_changed?
428 428 !User.active.find_by_login("admin").try(:check_password?, "admin")
429 429 end
430 430
431 431 def to_s
432 432 name
433 433 end
434 434
435 435 CSS_CLASS_BY_STATUS = {
436 436 STATUS_ANONYMOUS => 'anon',
437 437 STATUS_ACTIVE => 'active',
438 438 STATUS_REGISTERED => 'registered',
439 439 STATUS_LOCKED => 'locked'
440 440 }
441 441
442 442 def css_classes
443 443 "user #{CSS_CLASS_BY_STATUS[status]}"
444 444 end
445 445
446 446 # Returns the current day according to user's time zone
447 447 def today
448 448 if time_zone.nil?
449 449 Date.today
450 450 else
451 451 Time.now.in_time_zone(time_zone).to_date
452 452 end
453 453 end
454 454
455 455 # Returns the day of +time+ according to user's time zone
456 456 def time_to_date(time)
457 457 if time_zone.nil?
458 458 time.to_date
459 459 else
460 460 time.in_time_zone(time_zone).to_date
461 461 end
462 462 end
463 463
464 464 def logged?
465 465 true
466 466 end
467 467
468 468 def anonymous?
469 469 !logged?
470 470 end
471 471
472 472 # Returns user's membership for the given project
473 473 # or nil if the user is not a member of project
474 474 def membership(project)
475 475 project_id = project.is_a?(Project) ? project.id : project
476 476
477 477 @membership_by_project_id ||= Hash.new {|h, project_id|
478 478 h[project_id] = memberships.where(:project_id => project_id).first
479 479 }
480 480 @membership_by_project_id[project_id]
481 481 end
482 482
483 483 # Returns the user's bult-in role
484 484 def builtin_role
485 485 @builtin_role ||= Role.non_member
486 486 end
487 487
488 488 # Return user's roles for project
489 489 def roles_for_project(project)
490 490 # No role on archived projects
491 491 return [] if project.nil? || project.archived?
492 492 if membership = membership(project)
493 493 membership.roles.dup
494 494 elsif project.is_public?
495 495 project.override_roles(builtin_role)
496 496 else
497 497 []
498 498 end
499 499 end
500 500
501 501 # Returns a hash of user's projects grouped by roles
502 502 def projects_by_role
503 503 return @projects_by_role if @projects_by_role
504 504
505 505 hash = Hash.new([])
506 506
507 507 group_class = anonymous? ? GroupAnonymous : GroupNonMember
508 508 members = Member.joins(:project, :principal).
509 509 where("#{Project.table_name}.status <> 9").
510 510 where("#{Member.table_name}.user_id = ? OR (#{Project.table_name}.is_public = ? AND #{Principal.table_name}.type = ?)", self.id, true, group_class.name).
511 511 preload(:project, :roles).
512 512 to_a
513 513
514 514 members.reject! {|member| member.user_id != id && project_ids.include?(member.project_id)}
515 515 members.each do |member|
516 516 if member.project
517 517 member.roles.each do |role|
518 518 hash[role] = [] unless hash.key?(role)
519 519 hash[role] << member.project
520 520 end
521 521 end
522 522 end
523 523
524 524 hash.each do |role, projects|
525 525 projects.uniq!
526 526 end
527 527
528 528 @projects_by_role = hash
529 529 end
530 530
531 531 # Returns true if user is arg or belongs to arg
532 532 def is_or_belongs_to?(arg)
533 533 if arg.is_a?(User)
534 534 self == arg
535 535 elsif arg.is_a?(Group)
536 536 arg.users.include?(self)
537 537 else
538 538 false
539 539 end
540 540 end
541 541
542 542 # Return true if the user is allowed to do the specified action on a specific context
543 543 # Action can be:
544 544 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
545 545 # * a permission Symbol (eg. :edit_project)
546 546 # Context can be:
547 547 # * a project : returns true if user is allowed to do the specified action on this project
548 548 # * an array of projects : returns true if user is allowed on every project
549 549 # * nil with options[:global] set : check if user has at least one role allowed for this action,
550 550 # or falls back to Non Member / Anonymous permissions depending if the user is logged
551 551 def allowed_to?(action, context, options={}, &block)
552 552 if context && context.is_a?(Project)
553 553 return false unless context.allows_to?(action)
554 554 # Admin users are authorized for anything else
555 555 return true if admin?
556 556
557 557 roles = roles_for_project(context)
558 558 return false unless roles
559 559 roles.any? {|role|
560 560 (context.is_public? || role.member?) &&
561 561 role.allowed_to?(action) &&
562 562 (block_given? ? yield(role, self) : true)
563 563 }
564 564 elsif context && context.is_a?(Array)
565 565 if context.empty?
566 566 false
567 567 else
568 568 # Authorize if user is authorized on every element of the array
569 569 context.map {|project| allowed_to?(action, project, options, &block)}.reduce(:&)
570 570 end
571 571 elsif context
572 572 raise ArgumentError.new("#allowed_to? context argument must be a Project, an Array of projects or nil")
573 573 elsif options[:global]
574 574 # Admin users are always authorized
575 575 return true if admin?
576 576
577 577 # authorize if user has at least one role that has this permission
578 578 roles = memberships.collect {|m| m.roles}.flatten.uniq
579 579 roles << (self.logged? ? Role.non_member : Role.anonymous)
580 580 roles.any? {|role|
581 581 role.allowed_to?(action) &&
582 582 (block_given? ? yield(role, self) : true)
583 583 }
584 584 else
585 585 false
586 586 end
587 587 end
588 588
589 589 # Is the user allowed to do the specified action on any project?
590 590 # See allowed_to? for the actions and valid options.
591 591 #
592 592 # NB: this method is not used anywhere in the core codebase as of
593 593 # 2.5.2, but it's used by many plugins so if we ever want to remove
594 594 # it it has to be carefully deprecated for a version or two.
595 595 def allowed_to_globally?(action, options={}, &block)
596 596 allowed_to?(action, nil, options.reverse_merge(:global => true), &block)
597 597 end
598 598
599 599 # Returns true if the user is allowed to delete the user's own account
600 600 def own_account_deletable?
601 601 Setting.unsubscribe? &&
602 602 (!admin? || User.active.where("admin = ? AND id <> ?", true, id).exists?)
603 603 end
604 604
605 605 safe_attributes 'login',
606 606 'firstname',
607 607 'lastname',
608 608 'mail',
609 609 'mail_notification',
610 610 'notified_project_ids',
611 611 'language',
612 612 'custom_field_values',
613 613 'custom_fields',
614 614 'identity_url'
615 615
616 616 safe_attributes 'status',
617 617 'auth_source_id',
618 618 'generate_password',
619 619 'must_change_passwd',
620 620 :if => lambda {|user, current_user| current_user.admin?}
621 621
622 622 safe_attributes 'group_ids',
623 623 :if => lambda {|user, current_user| current_user.admin? && !user.new_record?}
624 624
625 625 # Utility method to help check if a user should be notified about an
626 626 # event.
627 627 #
628 628 # TODO: only supports Issue events currently
629 629 def notify_about?(object)
630 630 if mail_notification == 'all'
631 631 true
632 632 elsif mail_notification.blank? || mail_notification == 'none'
633 633 false
634 634 else
635 635 case object
636 636 when Issue
637 637 case mail_notification
638 638 when 'selected', 'only_my_events'
639 639 # user receives notifications for created/assigned issues on unselected projects
640 640 object.author == self || is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.assigned_to_was)
641 641 when 'only_assigned'
642 642 is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.assigned_to_was)
643 643 when 'only_owner'
644 644 object.author == self
645 645 end
646 646 when News
647 647 # always send to project members except when mail_notification is set to 'none'
648 648 true
649 649 end
650 650 end
651 651 end
652 652
653 653 def self.current=(user)
654 654 RequestStore.store[:current_user] = user
655 655 end
656 656
657 657 def self.current
658 658 RequestStore.store[:current_user] ||= User.anonymous
659 659 end
660 660
661 661 # Returns the anonymous user. If the anonymous user does not exist, it is created. There can be only
662 662 # one anonymous user per database.
663 663 def self.anonymous
664 664 anonymous_user = AnonymousUser.first
665 665 if anonymous_user.nil?
666 666 anonymous_user = AnonymousUser.create(:lastname => 'Anonymous', :firstname => '', :mail => '', :login => '', :status => 0)
667 667 raise 'Unable to create the anonymous user.' if anonymous_user.new_record?
668 668 end
669 669 anonymous_user
670 670 end
671 671
672 672 # Salts all existing unsalted passwords
673 673 # It changes password storage scheme from SHA1(password) to SHA1(salt + SHA1(password))
674 674 # This method is used in the SaltPasswords migration and is to be kept as is
675 675 def self.salt_unsalted_passwords!
676 676 transaction do
677 677 User.where("salt IS NULL OR salt = ''").find_each do |user|
678 678 next if user.hashed_password.blank?
679 679 salt = User.generate_salt
680 680 hashed_password = User.hash_password("#{salt}#{user.hashed_password}")
681 681 User.where(:id => user.id).update_all(:salt => salt, :hashed_password => hashed_password)
682 682 end
683 683 end
684 684 end
685 685
686 686 protected
687 687
688 688 def validate_password_length
689 689 return if password.blank? && generate_password?
690 690 # Password length validation based on setting
691 691 if !password.nil? && password.size < Setting.password_min_length.to_i
692 692 errors.add(:password, :too_short, :count => Setting.password_min_length.to_i)
693 693 end
694 694 end
695 695
696 696 private
697 697
698 698 def generate_password_if_needed
699 699 if generate_password? && auth_source.nil?
700 700 length = [Setting.password_min_length.to_i + 2, 10].max
701 701 random_password(length)
702 702 end
703 703 end
704 704
705 705 # Delete all outstanding password reset tokens on password or email change.
706 706 # Delete the autologin tokens on password change to prohibit session leakage.
707 707 # This helps to keep the account secure in case the associated email account
708 708 # was compromised.
709 709 def destroy_tokens
710 710 tokens = []
711 711 tokens |= ['recovery', 'autologin'] if hashed_password_changed?
712 712 tokens |= ['recovery'] if mail_changed?
713 713
714 714 if tokens.any?
715 715 Token.where(:user_id => id, :action => tokens).delete_all
716 716 end
717 717 end
718 718
719 719 # Removes references that are not handled by associations
720 720 # Things that are not deleted are reassociated with the anonymous user
721 721 def remove_references_before_destroy
722 722 return if self.id.nil?
723 723
724 724 substitute = User.anonymous
725 Attachment.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
725 Attachment.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
726 726 Comment.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
727 727 Issue.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
728 728 Issue.where(['assigned_to_id = ?', id]).update_all('assigned_to_id = NULL')
729 Journal.where(['user_id = ?', id]).update_all(['user_id = ?', substitute.id])
729 Journal.where(['user_id = ?', id]).update_all(['user_id = ?', substitute.id])
730 730 JournalDetail.
731 731 where(["property = 'attr' AND prop_key = 'assigned_to_id' AND old_value = ?", id.to_s]).
732 732 update_all(['old_value = ?', substitute.id.to_s])
733 733 JournalDetail.
734 734 where(["property = 'attr' AND prop_key = 'assigned_to_id' AND value = ?", id.to_s]).
735 update_all(['value = ?', substitute.id.to_s])
735 update_all(['value = ?', substitute.id.to_s])
736 736 Message.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
737 737 News.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
738 738 # Remove private queries and keep public ones
739 739 ::Query.delete_all ['user_id = ? AND visibility = ?', id, ::Query::VISIBILITY_PRIVATE]
740 740 ::Query.where(['user_id = ?', id]).update_all(['user_id = ?', substitute.id])
741 741 TimeEntry.where(['user_id = ?', id]).update_all(['user_id = ?', substitute.id])
742 742 Token.delete_all ['user_id = ?', id]
743 743 Watcher.delete_all ['user_id = ?', id]
744 744 WikiContent.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
745 745 WikiContent::Version.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
746 746 end
747 747
748 748 # Return password digest
749 749 def self.hash_password(clear_password)
750 750 Digest::SHA1.hexdigest(clear_password || "")
751 751 end
752 752
753 753 # Returns a 128bits random salt as a hex string (32 chars long)
754 754 def self.generate_salt
755 755 Redmine::Utils.random_hex(16)
756 756 end
757 757
758 758 end
759 759
760 760 class AnonymousUser < User
761 761 validate :validate_anonymous_uniqueness, :on => :create
762 762
763 763 def validate_anonymous_uniqueness
764 764 # There should be only one AnonymousUser in the database
765 765 errors.add :base, 'An anonymous user already exists.' if AnonymousUser.exists?
766 766 end
767 767
768 768 def available_custom_fields
769 769 []
770 770 end
771 771
772 772 # Overrides a few properties
773 773 def logged?; false end
774 774 def admin; false end
775 775 def name(*args); I18n.t(:label_user_anonymous) end
776 776 def mail; nil end
777 777 def time_zone; nil end
778 778 def rss_key; nil end
779 779
780 780 def pref
781 781 UserPreference.new(:user => self)
782 782 end
783 783
784 784 # Returns the user's bult-in role
785 785 def builtin_role
786 786 @builtin_role ||= Role.anonymous
787 787 end
788 788
789 789 def membership(*args)
790 790 nil
791 791 end
792 792
793 793 def member_of?(*args)
794 794 false
795 795 end
796 796
797 797 # Anonymous user can not be destroyed
798 798 def destroy
799 799 false
800 800 end
801 801 end
@@ -1,284 +1,289
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2014 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class Version < ActiveRecord::Base
19 19 include Redmine::SafeAttributes
20 20 after_update :update_issues_from_sharing_change
21 21 belongs_to :project
22 22 has_many :fixed_issues, :class_name => 'Issue', :foreign_key => 'fixed_version_id', :dependent => :nullify
23 23 acts_as_customizable
24 24 acts_as_attachable :view_permission => :view_files,
25 25 :delete_permission => :manage_files
26 26
27 27 VERSION_STATUSES = %w(open locked closed)
28 28 VERSION_SHARINGS = %w(none descendants hierarchy tree system)
29 29
30 30 validates_presence_of :name
31 31 validates_uniqueness_of :name, :scope => [:project_id]
32 32 validates_length_of :name, :maximum => 60
33 33 validates :effective_date, :date => true
34 34 validates_inclusion_of :status, :in => VERSION_STATUSES
35 35 validates_inclusion_of :sharing, :in => VERSION_SHARINGS
36 36 attr_protected :id
37 37
38 38 scope :named, lambda {|arg| where("LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip)}
39 39 scope :open, lambda { where(:status => 'open') }
40 40 scope :visible, lambda {|*args|
41 41 joins(:project).
42 42 where(Project.allowed_to_condition(args.first || User.current, :view_issues))
43 43 }
44 44
45 45 safe_attributes 'name',
46 46 'description',
47 47 'effective_date',
48 48 'due_date',
49 49 'wiki_page_title',
50 50 'status',
51 51 'sharing',
52 52 'custom_field_values',
53 53 'custom_fields'
54 54
55 55 # Returns true if +user+ or current user is allowed to view the version
56 56 def visible?(user=User.current)
57 57 user.allowed_to?(:view_issues, self.project)
58 58 end
59 59
60 60 # Version files have same visibility as project files
61 61 def attachments_visible?(*args)
62 62 project.present? && project.attachments_visible?(*args)
63 63 end
64 64
65 65 def attachments_deletable?(usr=User.current)
66 66 project.present? && project.attachments_deletable?(usr)
67 67 end
68 68
69 69 def start_date
70 70 @start_date ||= fixed_issues.minimum('start_date')
71 71 end
72 72
73 73 def due_date
74 74 effective_date
75 75 end
76 76
77 77 def due_date=(arg)
78 78 self.effective_date=(arg)
79 79 end
80 80
81 81 # Returns the total estimated time for this version
82 82 # (sum of leaves estimated_hours)
83 83 def estimated_hours
84 84 @estimated_hours ||= fixed_issues.leaves.sum(:estimated_hours).to_f
85 85 end
86 86
87 87 # Returns the total reported time for this version
88 88 def spent_hours
89 89 @spent_hours ||= TimeEntry.joins(:issue).where("#{Issue.table_name}.fixed_version_id = ?", id).sum(:hours).to_f
90 90 end
91 91
92 92 def closed?
93 93 status == 'closed'
94 94 end
95 95
96 96 def open?
97 97 status == 'open'
98 98 end
99 99
100 100 # Returns true if the version is completed: due date reached and no open issues
101 101 def completed?
102 102 effective_date && (effective_date < Date.today) && (open_issues_count == 0)
103 103 end
104 104
105 105 def behind_schedule?
106 106 if completed_percent == 100
107 107 return false
108 108 elsif due_date && start_date
109 109 done_date = start_date + ((due_date - start_date+1)* completed_percent/100).floor
110 110 return done_date <= Date.today
111 111 else
112 112 false # No issues so it's not late
113 113 end
114 114 end
115 115
116 116 # Returns the completion percentage of this version based on the amount of open/closed issues
117 117 # and the time spent on the open issues.
118 118 def completed_percent
119 119 if issues_count == 0
120 120 0
121 121 elsif open_issues_count == 0
122 122 100
123 123 else
124 124 issues_progress(false) + issues_progress(true)
125 125 end
126 126 end
127 127
128 128 # Returns the percentage of issues that have been marked as 'closed'.
129 129 def closed_percent
130 130 if issues_count == 0
131 131 0
132 132 else
133 133 issues_progress(false)
134 134 end
135 135 end
136 136
137 137 # Returns true if the version is overdue: due date reached and some open issues
138 138 def overdue?
139 139 effective_date && (effective_date < Date.today) && (open_issues_count > 0)
140 140 end
141 141
142 142 # Returns assigned issues count
143 143 def issues_count
144 144 load_issue_counts
145 145 @issue_count
146 146 end
147 147
148 148 # Returns the total amount of open issues for this version.
149 149 def open_issues_count
150 150 load_issue_counts
151 151 @open_issues_count
152 152 end
153 153
154 154 # Returns the total amount of closed issues for this version.
155 155 def closed_issues_count
156 156 load_issue_counts
157 157 @closed_issues_count
158 158 end
159 159
160 160 def wiki_page
161 161 if project.wiki && !wiki_page_title.blank?
162 162 @wiki_page ||= project.wiki.find_page(wiki_page_title)
163 163 end
164 164 @wiki_page
165 165 end
166 166
167 167 def to_s; name end
168 168
169 169 def to_s_with_project
170 170 "#{project} - #{name}"
171 171 end
172 172
173 173 # Versions are sorted by effective_date and name
174 174 # Those with no effective_date are at the end, sorted by name
175 175 def <=>(version)
176 176 if self.effective_date
177 177 if version.effective_date
178 178 if self.effective_date == version.effective_date
179 179 name == version.name ? id <=> version.id : name <=> version.name
180 180 else
181 181 self.effective_date <=> version.effective_date
182 182 end
183 183 else
184 184 -1
185 185 end
186 186 else
187 187 if version.effective_date
188 188 1
189 189 else
190 190 name == version.name ? id <=> version.id : name <=> version.name
191 191 end
192 192 end
193 193 end
194 194
195 195 def self.fields_for_order_statement(table=nil)
196 196 table ||= table_name
197 197 ["(CASE WHEN #{table}.effective_date IS NULL THEN 1 ELSE 0 END)", "#{table}.effective_date", "#{table}.name", "#{table}.id"]
198 198 end
199 199
200 200 scope :sorted, lambda { order(fields_for_order_statement) }
201 201
202 202 # Returns the sharings that +user+ can set the version to
203 203 def allowed_sharings(user = User.current)
204 204 VERSION_SHARINGS.select do |s|
205 205 if sharing == s
206 206 true
207 207 else
208 208 case s
209 209 when 'system'
210 210 # Only admin users can set a systemwide sharing
211 211 user.admin?
212 212 when 'hierarchy', 'tree'
213 213 # Only users allowed to manage versions of the root project can
214 214 # set sharing to hierarchy or tree
215 215 project.nil? || user.allowed_to?(:manage_versions, project.root)
216 216 else
217 217 true
218 218 end
219 219 end
220 220 end
221 221 end
222 222
223 # Returns true if the version is shared, otherwise false
224 def shared?
225 sharing != 'none'
226 end
227
223 228 private
224 229
225 230 def load_issue_counts
226 231 unless @issue_count
227 232 @open_issues_count = 0
228 233 @closed_issues_count = 0
229 234 fixed_issues.group(:status).count.each do |status, count|
230 235 if status.is_closed?
231 236 @closed_issues_count += count
232 237 else
233 238 @open_issues_count += count
234 239 end
235 240 end
236 241 @issue_count = @open_issues_count + @closed_issues_count
237 242 end
238 243 end
239 244
240 245 # Update the issue's fixed versions. Used if a version's sharing changes.
241 246 def update_issues_from_sharing_change
242 247 if sharing_changed?
243 248 if VERSION_SHARINGS.index(sharing_was).nil? ||
244 249 VERSION_SHARINGS.index(sharing).nil? ||
245 250 VERSION_SHARINGS.index(sharing_was) > VERSION_SHARINGS.index(sharing)
246 251 Issue.update_versions_from_sharing_change self
247 252 end
248 253 end
249 254 end
250 255
251 256 # Returns the average estimated time of assigned issues
252 257 # or 1 if no issue has an estimated time
253 258 # Used to weight unestimated issues in progress calculation
254 259 def estimated_average
255 260 if @estimated_average.nil?
256 261 average = fixed_issues.average(:estimated_hours).to_f
257 262 if average == 0
258 263 average = 1
259 264 end
260 265 @estimated_average = average
261 266 end
262 267 @estimated_average
263 268 end
264 269
265 270 # Returns the total progress of open or closed issues. The returned percentage takes into account
266 271 # the amount of estimated time set for this version.
267 272 #
268 273 # Examples:
269 274 # issues_progress(true) => returns the progress percentage for open issues.
270 275 # issues_progress(false) => returns the progress percentage for closed issues.
271 276 def issues_progress(open)
272 277 @issues_progress ||= {}
273 278 @issues_progress[open] ||= begin
274 279 progress = 0
275 280 if issues_count > 0
276 281 ratio = open ? 'done_ratio' : 100
277 282
278 283 done = fixed_issues.open(open).sum("COALESCE(estimated_hours, #{estimated_average}) * #{ratio}").to_f
279 284 progress = done / (estimated_average * issues_count)
280 285 end
281 286 progress
282 287 end
283 288 end
284 289 end
@@ -1,26 +1,26
1 1 <div class="tabs">
2 2 <ul>
3 3 <% tabs.each do |tab| -%>
4 4 <li><%= link_to l(tab[:label]), { :tab => tab[:name] },
5 5 :id => "tab-#{tab[:name]}",
6 6 :class => (tab[:name] != selected_tab ? nil : 'selected'),
7 7 :onclick => "showTab('#{tab[:name]}', this.href); this.blur(); return false;" %></li>
8 8 <% end -%>
9 9 </ul>
10 10 <div class="tabs-buttons" style="display:none;">
11 <button class="tab-left" onclick="moveTabLeft(this); return false;"></button>
12 <button class="tab-right" onclick="moveTabRight(this); return false;"></button>
11 <button class="tab-left" type="button" onclick="moveTabLeft(this);"></button>
12 <button class="tab-right" type="button" onclick="moveTabRight(this);"></button>
13 13 </div>
14 14 </div>
15 15
16 16 <script>
17 17 $(document).ready(displayTabsButtons);
18 18 $(window).resize(displayTabsButtons);
19 19 </script>
20 20
21 21 <% tabs.each do |tab| -%>
22 22 <%= content_tag('div', render(:partial => tab[:partial], :locals => {:tab => tab} ),
23 23 :id => "tab-content-#{tab[:name]}",
24 24 :style => (tab[:name] != selected_tab ? 'display:none' : nil),
25 25 :class => 'tab-content') %>
26 26 <% end -%>
@@ -1,24 +1,30
1 1 I18n.default_locale = 'en'
2 2 I18n.backend = Redmine::I18n::Backend.new
3 3 # Forces I18n to load available locales from the backend
4 4 I18n.config.available_locales = nil
5 5
6 6 require 'redmine'
7 7
8 8 # Load the secret token from the Redmine configuration file
9 9 secret = Redmine::Configuration['secret_token']
10 10 if secret.present?
11 11 RedmineApp::Application.config.secret_token = secret
12 12 end
13 13
14 14 if Object.const_defined?(:OpenIdAuthentication)
15 15 openid_authentication_store = Redmine::Configuration['openid_authentication_store']
16 16 OpenIdAuthentication.store =
17 17 openid_authentication_store.present? ?
18 18 openid_authentication_store : :memory
19 19 end
20 20
21 21 Redmine::Plugin.load
22 22 unless Redmine::Configuration['mirror_plugins_assets_on_startup'] == false
23 23 Redmine::Plugin.mirror_assets
24 24 end
25
26 Rails.application.config.to_prepare do
27 Redmine::FieldFormat::RecordList.subclasses.each do |klass|
28 klass.instance.reset_target_class
29 end
30 end No newline at end of file
1 NO CONTENT: modified file
The requested commit or file is too big and content was truncated. Show full diff
@@ -1,706 +1,710
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2014 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 module Redmine
19 19 module FieldFormat
20 20 def self.add(name, klass)
21 21 all[name.to_s] = klass.instance
22 22 end
23 23
24 24 def self.delete(name)
25 25 all.delete(name.to_s)
26 26 end
27 27
28 28 def self.all
29 29 @formats ||= Hash.new(Base.instance)
30 30 end
31 31
32 32 def self.available_formats
33 33 all.keys
34 34 end
35 35
36 36 def self.find(name)
37 37 all[name.to_s]
38 38 end
39 39
40 40 # Return an array of custom field formats which can be used in select_tag
41 41 def self.as_select(class_name=nil)
42 42 formats = all.values.select do |format|
43 43 format.class.customized_class_names.nil? || format.class.customized_class_names.include?(class_name)
44 44 end
45 45 formats.map {|format| [::I18n.t(format.label), format.name] }.sort_by(&:first)
46 46 end
47 47
48 48 class Base
49 49 include Singleton
50 50 include Redmine::I18n
51 51 include ERB::Util
52 52
53 53 class_attribute :format_name
54 54 self.format_name = nil
55 55
56 56 # Set this to true if the format supports multiple values
57 57 class_attribute :multiple_supported
58 58 self.multiple_supported = false
59 59
60 60 # Set this to true if the format supports textual search on custom values
61 61 class_attribute :searchable_supported
62 62 self.searchable_supported = false
63 63
64 64 # Restricts the classes that the custom field can be added to
65 65 # Set to nil for no restrictions
66 66 class_attribute :customized_class_names
67 67 self.customized_class_names = nil
68 68
69 69 # Name of the partial for editing the custom field
70 70 class_attribute :form_partial
71 71 self.form_partial = nil
72 72
73 73 def self.add(name)
74 74 self.format_name = name
75 75 Redmine::FieldFormat.add(name, self)
76 76 end
77 77 private_class_method :add
78 78
79 79 def self.field_attributes(*args)
80 80 CustomField.store_accessor :format_store, *args
81 81 end
82 82
83 83 field_attributes :url_pattern
84 84
85 85 def name
86 86 self.class.format_name
87 87 end
88 88
89 89 def label
90 90 "label_#{name}"
91 91 end
92 92
93 93 def cast_custom_value(custom_value)
94 94 cast_value(custom_value.custom_field, custom_value.value, custom_value.customized)
95 95 end
96 96
97 97 def cast_value(custom_field, value, customized=nil)
98 98 if value.blank?
99 99 nil
100 100 elsif value.is_a?(Array)
101 101 casted = value.map do |v|
102 102 cast_single_value(custom_field, v, customized)
103 103 end
104 104 casted.compact.sort
105 105 else
106 106 cast_single_value(custom_field, value, customized)
107 107 end
108 108 end
109 109
110 110 def cast_single_value(custom_field, value, customized=nil)
111 111 value.to_s
112 112 end
113 113
114 114 def target_class
115 115 nil
116 116 end
117 117
118 118 def possible_custom_value_options(custom_value)
119 119 possible_values_options(custom_value.custom_field, custom_value.customized)
120 120 end
121 121
122 122 def possible_values_options(custom_field, object=nil)
123 123 []
124 124 end
125 125
126 126 # Returns the validation errors for custom_field
127 127 # Should return an empty array if custom_field is valid
128 128 def validate_custom_field(custom_field)
129 129 []
130 130 end
131 131
132 132 # Returns the validation error messages for custom_value
133 133 # Should return an empty array if custom_value is valid
134 134 def validate_custom_value(custom_value)
135 135 values = Array.wrap(custom_value.value).reject {|value| value.to_s == ''}
136 136 errors = values.map do |value|
137 137 validate_single_value(custom_value.custom_field, value, custom_value.customized)
138 138 end
139 139 errors.flatten.uniq
140 140 end
141 141
142 142 def validate_single_value(custom_field, value, customized=nil)
143 143 []
144 144 end
145 145
146 146 def formatted_custom_value(view, custom_value, html=false)
147 147 formatted_value(view, custom_value.custom_field, custom_value.value, custom_value.customized, html)
148 148 end
149 149
150 150 def formatted_value(view, custom_field, value, customized=nil, html=false)
151 151 casted = cast_value(custom_field, value, customized)
152 152 if html && custom_field.url_pattern.present?
153 153 texts_and_urls = Array.wrap(casted).map do |single_value|
154 154 text = view.format_object(single_value, false).to_s
155 155 url = url_from_pattern(custom_field, single_value, customized)
156 156 [text, url]
157 157 end
158 158 links = texts_and_urls.sort_by(&:first).map {|text, url| view.link_to text, url}
159 159 links.join(', ').html_safe
160 160 else
161 161 casted
162 162 end
163 163 end
164 164
165 165 # Returns an URL generated with the custom field URL pattern
166 166 # and variables substitution:
167 167 # %value% => the custom field value
168 168 # %id% => id of the customized object
169 169 # %project_id% => id of the project of the customized object if defined
170 170 # %project_identifier% => identifier of the project of the customized object if defined
171 171 # %m1%, %m2%... => capture groups matches of the custom field regexp if defined
172 172 def url_from_pattern(custom_field, value, customized)
173 173 url = custom_field.url_pattern.to_s.dup
174 174 url.gsub!('%value%') {value.to_s}
175 175 url.gsub!('%id%') {customized.id.to_s}
176 176 url.gsub!('%project_id%') {(customized.respond_to?(:project) ? customized.project.try(:id) : nil).to_s}
177 177 url.gsub!('%project_identifier%') {(customized.respond_to?(:project) ? customized.project.try(:identifier) : nil).to_s}
178 178 if custom_field.regexp.present?
179 179 url.gsub!(%r{%m(\d+)%}) do
180 180 m = $1.to_i
181 181 if matches ||= value.to_s.match(Regexp.new(custom_field.regexp))
182 182 matches[m].to_s
183 183 end
184 184 end
185 185 end
186 186 url
187 187 end
188 188 protected :url_from_pattern
189 189
190 190 def edit_tag(view, tag_id, tag_name, custom_value, options={})
191 191 view.text_field_tag(tag_name, custom_value.value, options.merge(:id => tag_id))
192 192 end
193 193
194 194 def bulk_edit_tag(view, tag_id, tag_name, custom_field, objects, value, options={})
195 195 view.text_field_tag(tag_name, value, options.merge(:id => tag_id)) +
196 196 bulk_clear_tag(view, tag_id, tag_name, custom_field, value)
197 197 end
198 198
199 199 def bulk_clear_tag(view, tag_id, tag_name, custom_field, value)
200 200 if custom_field.is_required?
201 201 ''.html_safe
202 202 else
203 203 view.content_tag('label',
204 204 view.check_box_tag(tag_name, '__none__', (value == '__none__'), :id => nil, :data => {:disables => "##{tag_id}"}) + l(:button_clear),
205 205 :class => 'inline'
206 206 )
207 207 end
208 208 end
209 209 protected :bulk_clear_tag
210 210
211 211 def query_filter_options(custom_field, query)
212 212 {:type => :string}
213 213 end
214 214
215 215 def before_custom_field_save(custom_field)
216 216 end
217 217
218 218 # Returns a ORDER BY clause that can used to sort customized
219 219 # objects by their value of the custom field.
220 220 # Returns nil if the custom field can not be used for sorting.
221 221 def order_statement(custom_field)
222 222 # COALESCE is here to make sure that blank and NULL values are sorted equally
223 223 "COALESCE(#{join_alias custom_field}.value, '')"
224 224 end
225 225
226 226 # Returns a GROUP BY clause that can used to group by custom value
227 227 # Returns nil if the custom field can not be used for grouping.
228 228 def group_statement(custom_field)
229 229 nil
230 230 end
231 231
232 232 # Returns a JOIN clause that is added to the query when sorting by custom values
233 233 def join_for_order_statement(custom_field)
234 234 alias_name = join_alias(custom_field)
235 235
236 236 "LEFT OUTER JOIN #{CustomValue.table_name} #{alias_name}" +
237 237 " ON #{alias_name}.customized_type = '#{custom_field.class.customized_class.base_class.name}'" +
238 238 " AND #{alias_name}.customized_id = #{custom_field.class.customized_class.table_name}.id" +
239 239 " AND #{alias_name}.custom_field_id = #{custom_field.id}" +
240 240 " AND (#{custom_field.visibility_by_project_condition})" +
241 241 " AND #{alias_name}.value <> ''" +
242 242 " AND #{alias_name}.id = (SELECT max(#{alias_name}_2.id) FROM #{CustomValue.table_name} #{alias_name}_2" +
243 243 " WHERE #{alias_name}_2.customized_type = #{alias_name}.customized_type" +
244 244 " AND #{alias_name}_2.customized_id = #{alias_name}.customized_id" +
245 245 " AND #{alias_name}_2.custom_field_id = #{alias_name}.custom_field_id)"
246 246 end
247 247
248 248 def join_alias(custom_field)
249 249 "cf_#{custom_field.id}"
250 250 end
251 251 protected :join_alias
252 252 end
253 253
254 254 class Unbounded < Base
255 255 def validate_single_value(custom_field, value, customized=nil)
256 256 errs = super
257 257 value = value.to_s
258 258 unless custom_field.regexp.blank? or value =~ Regexp.new(custom_field.regexp)
259 259 errs << ::I18n.t('activerecord.errors.messages.invalid')
260 260 end
261 261 if custom_field.min_length && value.length < custom_field.min_length
262 262 errs << ::I18n.t('activerecord.errors.messages.too_short', :count => custom_field.min_length)
263 263 end
264 264 if custom_field.max_length && custom_field.max_length > 0 && value.length > custom_field.max_length
265 265 errs << ::I18n.t('activerecord.errors.messages.too_long', :count => custom_field.max_length)
266 266 end
267 267 errs
268 268 end
269 269 end
270 270
271 271 class StringFormat < Unbounded
272 272 add 'string'
273 273 self.searchable_supported = true
274 274 self.form_partial = 'custom_fields/formats/string'
275 275 field_attributes :text_formatting
276 276
277 277 def formatted_value(view, custom_field, value, customized=nil, html=false)
278 278 if html
279 279 if custom_field.url_pattern.present?
280 280 super
281 281 elsif custom_field.text_formatting == 'full'
282 282 view.textilizable(value, :object => customized)
283 283 else
284 284 value.to_s
285 285 end
286 286 else
287 287 value.to_s
288 288 end
289 289 end
290 290 end
291 291
292 292 class TextFormat < Unbounded
293 293 add 'text'
294 294 self.searchable_supported = true
295 295 self.form_partial = 'custom_fields/formats/text'
296 296
297 297 def formatted_value(view, custom_field, value, customized=nil, html=false)
298 298 if html
299 299 if custom_field.text_formatting == 'full'
300 300 view.textilizable(value, :object => customized)
301 301 else
302 302 view.simple_format(html_escape(value))
303 303 end
304 304 else
305 305 value.to_s
306 306 end
307 307 end
308 308
309 309 def edit_tag(view, tag_id, tag_name, custom_value, options={})
310 310 view.text_area_tag(tag_name, custom_value.value, options.merge(:id => tag_id, :rows => 3))
311 311 end
312 312
313 313 def bulk_edit_tag(view, tag_id, tag_name, custom_field, objects, value, options={})
314 314 view.text_area_tag(tag_name, value, options.merge(:id => tag_id, :rows => 3)) +
315 315 '<br />'.html_safe +
316 316 bulk_clear_tag(view, tag_id, tag_name, custom_field, value)
317 317 end
318 318
319 319 def query_filter_options(custom_field, query)
320 320 {:type => :text}
321 321 end
322 322 end
323 323
324 324 class LinkFormat < StringFormat
325 325 add 'link'
326 326 self.searchable_supported = false
327 327 self.form_partial = 'custom_fields/formats/link'
328 328
329 329 def formatted_value(view, custom_field, value, customized=nil, html=false)
330 330 if html
331 331 if custom_field.url_pattern.present?
332 332 url = url_from_pattern(custom_field, value, customized)
333 333 else
334 334 url = value.to_s
335 335 unless url =~ %r{\A[a-z]+://}i
336 336 # no protocol found, use http by default
337 337 url = "http://" + url
338 338 end
339 339 end
340 340 view.link_to value.to_s, url
341 341 else
342 342 value.to_s
343 343 end
344 344 end
345 345 end
346 346
347 347 class Numeric < Unbounded
348 348 self.form_partial = 'custom_fields/formats/numeric'
349 349
350 350 def order_statement(custom_field)
351 351 # Make the database cast values into numeric
352 352 # Postgresql will raise an error if a value can not be casted!
353 353 # CustomValue validations should ensure that it doesn't occur
354 354 "CAST(CASE #{join_alias custom_field}.value WHEN '' THEN '0' ELSE #{join_alias custom_field}.value END AS decimal(30,3))"
355 355 end
356 356 end
357 357
358 358 class IntFormat < Numeric
359 359 add 'int'
360 360
361 361 def label
362 362 "label_integer"
363 363 end
364 364
365 365 def cast_single_value(custom_field, value, customized=nil)
366 366 value.to_i
367 367 end
368 368
369 369 def validate_single_value(custom_field, value, customized=nil)
370 370 errs = super
371 371 errs << ::I18n.t('activerecord.errors.messages.not_a_number') unless value =~ /^[+-]?\d+$/
372 372 errs
373 373 end
374 374
375 375 def query_filter_options(custom_field, query)
376 376 {:type => :integer}
377 377 end
378 378
379 379 def group_statement(custom_field)
380 380 order_statement(custom_field)
381 381 end
382 382 end
383 383
384 384 class FloatFormat < Numeric
385 385 add 'float'
386 386
387 387 def cast_single_value(custom_field, value, customized=nil)
388 388 value.to_f
389 389 end
390 390
391 391 def validate_single_value(custom_field, value, customized=nil)
392 392 errs = super
393 393 errs << ::I18n.t('activerecord.errors.messages.invalid') unless (Kernel.Float(value) rescue nil)
394 394 errs
395 395 end
396 396
397 397 def query_filter_options(custom_field, query)
398 398 {:type => :float}
399 399 end
400 400 end
401 401
402 402 class DateFormat < Unbounded
403 403 add 'date'
404 404 self.form_partial = 'custom_fields/formats/date'
405 405
406 406 def cast_single_value(custom_field, value, customized=nil)
407 407 value.to_date rescue nil
408 408 end
409 409
410 410 def validate_single_value(custom_field, value, customized=nil)
411 411 if value =~ /^\d{4}-\d{2}-\d{2}$/ && (value.to_date rescue false)
412 412 []
413 413 else
414 414 [::I18n.t('activerecord.errors.messages.not_a_date')]
415 415 end
416 416 end
417 417
418 418 def edit_tag(view, tag_id, tag_name, custom_value, options={})
419 419 view.text_field_tag(tag_name, custom_value.value, options.merge(:id => tag_id, :size => 10)) +
420 420 view.calendar_for(tag_id)
421 421 end
422 422
423 423 def bulk_edit_tag(view, tag_id, tag_name, custom_field, objects, value, options={})
424 424 view.text_field_tag(tag_name, value, options.merge(:id => tag_id, :size => 10)) +
425 425 view.calendar_for(tag_id) +
426 426 bulk_clear_tag(view, tag_id, tag_name, custom_field, value)
427 427 end
428 428
429 429 def query_filter_options(custom_field, query)
430 430 {:type => :date}
431 431 end
432 432
433 433 def group_statement(custom_field)
434 434 order_statement(custom_field)
435 435 end
436 436 end
437 437
438 438 class List < Base
439 439 self.multiple_supported = true
440 440 field_attributes :edit_tag_style
441 441
442 442 def edit_tag(view, tag_id, tag_name, custom_value, options={})
443 443 if custom_value.custom_field.edit_tag_style == 'check_box'
444 444 check_box_edit_tag(view, tag_id, tag_name, custom_value, options)
445 445 else
446 446 select_edit_tag(view, tag_id, tag_name, custom_value, options)
447 447 end
448 448 end
449 449
450 450 def bulk_edit_tag(view, tag_id, tag_name, custom_field, objects, value, options={})
451 451 opts = []
452 452 opts << [l(:label_no_change_option), ''] unless custom_field.multiple?
453 453 opts << [l(:label_none), '__none__'] unless custom_field.is_required?
454 454 opts += possible_values_options(custom_field, objects)
455 455 view.select_tag(tag_name, view.options_for_select(opts, value), options.merge(:multiple => custom_field.multiple?))
456 456 end
457 457
458 458 def query_filter_options(custom_field, query)
459 459 {:type => :list_optional, :values => possible_values_options(custom_field, query.project)}
460 460 end
461 461
462 462 protected
463 463
464 464 # Renders the edit tag as a select tag
465 465 def select_edit_tag(view, tag_id, tag_name, custom_value, options={})
466 466 blank_option = ''.html_safe
467 467 unless custom_value.custom_field.multiple?
468 468 if custom_value.custom_field.is_required?
469 469 unless custom_value.custom_field.default_value.present?
470 470 blank_option = view.content_tag('option', "--- #{l(:actionview_instancetag_blank_option)} ---", :value => '')
471 471 end
472 472 else
473 473 blank_option = view.content_tag('option', '&nbsp;'.html_safe, :value => '')
474 474 end
475 475 end
476 476 options_tags = blank_option + view.options_for_select(possible_custom_value_options(custom_value), custom_value.value)
477 477 s = view.select_tag(tag_name, options_tags, options.merge(:id => tag_id, :multiple => custom_value.custom_field.multiple?))
478 478 if custom_value.custom_field.multiple?
479 479 s << view.hidden_field_tag(tag_name, '')
480 480 end
481 481 s
482 482 end
483 483
484 484 # Renders the edit tag as check box or radio tags
485 485 def check_box_edit_tag(view, tag_id, tag_name, custom_value, options={})
486 486 opts = []
487 487 unless custom_value.custom_field.multiple? || custom_value.custom_field.is_required?
488 488 opts << ["(#{l(:label_none)})", '']
489 489 end
490 490 opts += possible_custom_value_options(custom_value)
491 491 s = ''.html_safe
492 492 tag_method = custom_value.custom_field.multiple? ? :check_box_tag : :radio_button_tag
493 493 opts.each do |label, value|
494 494 value ||= label
495 495 checked = (custom_value.value.is_a?(Array) && custom_value.value.include?(value)) || custom_value.value.to_s == value
496 496 tag = view.send(tag_method, tag_name, value, checked, :id => tag_id)
497 497 # set the id on the first tag only
498 498 tag_id = nil
499 499 s << view.content_tag('label', tag + ' ' + label)
500 500 end
501 501 if custom_value.custom_field.multiple?
502 502 s << view.hidden_field_tag(tag_name, '')
503 503 end
504 504 css = "#{options[:class]} check_box_group"
505 505 view.content_tag('span', s, options.merge(:class => css))
506 506 end
507 507 end
508 508
509 509 class ListFormat < List
510 510 add 'list'
511 511 self.searchable_supported = true
512 512 self.form_partial = 'custom_fields/formats/list'
513 513
514 514 def possible_custom_value_options(custom_value)
515 515 options = possible_values_options(custom_value.custom_field)
516 516 missing = [custom_value.value].flatten.reject(&:blank?) - options
517 517 if missing.any?
518 518 options += missing
519 519 end
520 520 options
521 521 end
522 522
523 523 def possible_values_options(custom_field, object=nil)
524 524 custom_field.possible_values
525 525 end
526 526
527 527 def validate_custom_field(custom_field)
528 528 errors = []
529 529 errors << [:possible_values, :blank] if custom_field.possible_values.blank?
530 530 errors << [:possible_values, :invalid] unless custom_field.possible_values.is_a? Array
531 531 errors
532 532 end
533 533
534 534 def validate_custom_value(custom_value)
535 535 values = Array.wrap(custom_value.value).reject {|value| value.to_s == ''}
536 536 invalid_values = values - Array.wrap(custom_value.value_was) - custom_value.custom_field.possible_values
537 537 if invalid_values.any?
538 538 [::I18n.t('activerecord.errors.messages.inclusion')]
539 539 else
540 540 []
541 541 end
542 542 end
543 543
544 544 def group_statement(custom_field)
545 545 order_statement(custom_field)
546 546 end
547 547 end
548 548
549 549 class BoolFormat < List
550 550 add 'bool'
551 551 self.multiple_supported = false
552 552 self.form_partial = 'custom_fields/formats/bool'
553 553
554 554 def label
555 555 "label_boolean"
556 556 end
557 557
558 558 def cast_single_value(custom_field, value, customized=nil)
559 559 value == '1' ? true : false
560 560 end
561 561
562 562 def possible_values_options(custom_field, object=nil)
563 563 [[::I18n.t(:general_text_Yes), '1'], [::I18n.t(:general_text_No), '0']]
564 564 end
565 565
566 566 def group_statement(custom_field)
567 567 order_statement(custom_field)
568 568 end
569 569
570 570 def edit_tag(view, tag_id, tag_name, custom_value, options={})
571 571 case custom_value.custom_field.edit_tag_style
572 572 when 'check_box'
573 573 single_check_box_edit_tag(view, tag_id, tag_name, custom_value, options)
574 574 when 'radio'
575 575 check_box_edit_tag(view, tag_id, tag_name, custom_value, options)
576 576 else
577 577 select_edit_tag(view, tag_id, tag_name, custom_value, options)
578 578 end
579 579 end
580 580
581 581 # Renders the edit tag as a simple check box
582 582 def single_check_box_edit_tag(view, tag_id, tag_name, custom_value, options={})
583 583 s = ''.html_safe
584 584 s << view.hidden_field_tag(tag_name, '0', :id => nil)
585 585 s << view.check_box_tag(tag_name, '1', custom_value.value.to_s == '1', :id => tag_id)
586 586 view.content_tag('span', s, options)
587 587 end
588 588 end
589 589
590 590 class RecordList < List
591 591 self.customized_class_names = %w(Issue TimeEntry Version Project)
592 592
593 593 def cast_single_value(custom_field, value, customized=nil)
594 594 target_class.find_by_id(value.to_i) if value.present?
595 595 end
596 596
597 597 def target_class
598 598 @target_class ||= self.class.name[/^(.*::)?(.+)Format$/, 2].constantize rescue nil
599 599 end
600
601 def reset_target_class
602 @target_class = nil
603 end
600 604
601 605 def possible_custom_value_options(custom_value)
602 606 options = possible_values_options(custom_value.custom_field, custom_value.customized)
603 607 missing = [custom_value.value_was].flatten.reject(&:blank?) - options.map(&:last)
604 608 if missing.any?
605 609 options += target_class.where(:id => missing.map(&:to_i)).map {|o| [o.to_s, o.id.to_s]}
606 610 options.sort_by!(&:first)
607 611 end
608 612 options
609 613 end
610 614
611 615 def order_statement(custom_field)
612 616 if target_class.respond_to?(:fields_for_order_statement)
613 617 target_class.fields_for_order_statement(value_join_alias(custom_field))
614 618 end
615 619 end
616 620
617 621 def group_statement(custom_field)
618 622 "COALESCE(#{join_alias custom_field}.value, '')"
619 623 end
620 624
621 625 def join_for_order_statement(custom_field)
622 626 alias_name = join_alias(custom_field)
623 627
624 628 "LEFT OUTER JOIN #{CustomValue.table_name} #{alias_name}" +
625 629 " ON #{alias_name}.customized_type = '#{custom_field.class.customized_class.base_class.name}'" +
626 630 " AND #{alias_name}.customized_id = #{custom_field.class.customized_class.table_name}.id" +
627 631 " AND #{alias_name}.custom_field_id = #{custom_field.id}" +
628 632 " AND (#{custom_field.visibility_by_project_condition})" +
629 633 " AND #{alias_name}.value <> ''" +
630 634 " AND #{alias_name}.id = (SELECT max(#{alias_name}_2.id) FROM #{CustomValue.table_name} #{alias_name}_2" +
631 635 " WHERE #{alias_name}_2.customized_type = #{alias_name}.customized_type" +
632 636 " AND #{alias_name}_2.customized_id = #{alias_name}.customized_id" +
633 637 " AND #{alias_name}_2.custom_field_id = #{alias_name}.custom_field_id)" +
634 638 " LEFT OUTER JOIN #{target_class.table_name} #{value_join_alias custom_field}" +
635 639 " ON CAST(CASE #{alias_name}.value WHEN '' THEN '0' ELSE #{alias_name}.value END AS decimal(30,0)) = #{value_join_alias custom_field}.id"
636 640 end
637 641
638 642 def value_join_alias(custom_field)
639 643 join_alias(custom_field) + "_" + custom_field.field_format
640 644 end
641 645 protected :value_join_alias
642 646 end
643 647
644 648 class UserFormat < RecordList
645 649 add 'user'
646 650 self.form_partial = 'custom_fields/formats/user'
647 651 field_attributes :user_role
648 652
649 653 def possible_values_options(custom_field, object=nil)
650 654 if object.is_a?(Array)
651 655 projects = object.map {|o| o.respond_to?(:project) ? o.project : nil}.compact.uniq
652 656 projects.map {|project| possible_values_options(custom_field, project)}.reduce(:&) || []
653 657 elsif object.respond_to?(:project) && object.project
654 658 scope = object.project.users
655 659 if custom_field.user_role.is_a?(Array)
656 660 role_ids = custom_field.user_role.map(&:to_s).reject(&:blank?).map(&:to_i)
657 661 if role_ids.any?
658 662 scope = scope.where("#{Member.table_name}.id IN (SELECT DISTINCT member_id FROM #{MemberRole.table_name} WHERE role_id IN (?))", role_ids)
659 663 end
660 664 end
661 665 scope.sorted.collect {|u| [u.to_s, u.id.to_s]}
662 666 else
663 667 []
664 668 end
665 669 end
666 670
667 671 def before_custom_field_save(custom_field)
668 672 super
669 673 if custom_field.user_role.is_a?(Array)
670 674 custom_field.user_role.map!(&:to_s).reject!(&:blank?)
671 675 end
672 676 end
673 677 end
674 678
675 679 class VersionFormat < RecordList
676 680 add 'version'
677 681 self.form_partial = 'custom_fields/formats/version'
678 682 field_attributes :version_status
679 683
680 684 def possible_values_options(custom_field, object=nil)
681 685 if object.is_a?(Array)
682 686 projects = object.map {|o| o.respond_to?(:project) ? o.project : nil}.compact.uniq
683 687 projects.map {|project| possible_values_options(custom_field, project)}.reduce(:&) || []
684 688 elsif object.respond_to?(:project) && object.project
685 689 scope = object.project.shared_versions
686 690 if custom_field.version_status.is_a?(Array)
687 691 statuses = custom_field.version_status.map(&:to_s).reject(&:blank?)
688 692 if statuses.any?
689 693 scope = scope.where(:status => statuses.map(&:to_s))
690 694 end
691 695 end
692 696 scope.sort.collect {|u| [u.to_s, u.id.to_s]}
693 697 else
694 698 []
695 699 end
696 700 end
697 701
698 702 def before_custom_field_save(custom_field)
699 703 super
700 704 if custom_field.version_status.is_a?(Array)
701 705 custom_field.version_status.map!(&:to_s).reject!(&:blank?)
702 706 end
703 707 end
704 708 end
705 709 end
706 710 end
@@ -1,194 +1,202
1 1 module ObjectHelpers
2 2 def User.generate!(attributes={})
3 3 @generated_user_login ||= 'user0'
4 4 @generated_user_login.succ!
5 5 user = User.new(attributes)
6 6 user.login = @generated_user_login.dup if user.login.blank?
7 7 user.mail = "#{@generated_user_login}@example.com" if user.mail.blank?
8 8 user.firstname = "Bob" if user.firstname.blank?
9 9 user.lastname = "Doe" if user.lastname.blank?
10 10 yield user if block_given?
11 11 user.save!
12 12 user
13 13 end
14 14
15 15 def User.add_to_project(user, project, roles=nil)
16 16 roles = Role.find(1) if roles.nil?
17 17 roles = [roles] if roles.is_a?(Role)
18 18 Member.create!(:principal => user, :project => project, :roles => roles)
19 19 end
20 20
21 21 def Group.generate!(attributes={})
22 22 @generated_group_name ||= 'Group 0'
23 23 @generated_group_name.succ!
24 24 group = Group.new(attributes)
25 25 group.name = @generated_group_name.dup if group.name.blank?
26 26 yield group if block_given?
27 27 group.save!
28 28 group
29 29 end
30 30
31 31 def Project.generate!(attributes={})
32 32 @generated_project_identifier ||= 'project-0000'
33 33 @generated_project_identifier.succ!
34 34 project = Project.new(attributes)
35 35 project.name = @generated_project_identifier.dup if project.name.blank?
36 36 project.identifier = @generated_project_identifier.dup if project.identifier.blank?
37 37 yield project if block_given?
38 38 project.save!
39 39 project
40 40 end
41 41
42 42 def Project.generate_with_parent!(parent, attributes={})
43 43 project = Project.generate!(attributes)
44 44 project.set_parent!(parent)
45 45 project
46 46 end
47 47
48 48 def Tracker.generate!(attributes={})
49 49 @generated_tracker_name ||= 'Tracker 0'
50 50 @generated_tracker_name.succ!
51 51 tracker = Tracker.new(attributes)
52 52 tracker.name = @generated_tracker_name.dup if tracker.name.blank?
53 53 yield tracker if block_given?
54 54 tracker.save!
55 55 tracker
56 56 end
57 57
58 58 def Role.generate!(attributes={})
59 59 @generated_role_name ||= 'Role 0'
60 60 @generated_role_name.succ!
61 61 role = Role.new(attributes)
62 62 role.name = @generated_role_name.dup if role.name.blank?
63 63 yield role if block_given?
64 64 role.save!
65 65 role
66 66 end
67 67
68 68 # Generates an unsaved Issue
69 69 def Issue.generate(attributes={})
70 70 issue = Issue.new(attributes)
71 71 issue.project ||= Project.find(1)
72 72 issue.tracker ||= issue.project.trackers.first
73 73 issue.subject = 'Generated' if issue.subject.blank?
74 74 issue.author ||= User.find(2)
75 75 yield issue if block_given?
76 76 issue
77 77 end
78 78
79 79 # Generates a saved Issue
80 80 def Issue.generate!(attributes={}, &block)
81 81 issue = Issue.generate(attributes, &block)
82 82 issue.save!
83 83 issue
84 84 end
85 85
86 86 # Generates an issue with 2 children and a grandchild
87 87 def Issue.generate_with_descendants!(attributes={})
88 88 issue = Issue.generate!(attributes)
89 89 child = Issue.generate!(:project => issue.project, :subject => 'Child1', :parent_issue_id => issue.id)
90 90 Issue.generate!(:project => issue.project, :subject => 'Child2', :parent_issue_id => issue.id)
91 91 Issue.generate!(:project => issue.project, :subject => 'Child11', :parent_issue_id => child.id)
92 92 issue.reload
93 93 end
94 94
95 95 def Journal.generate!(attributes={})
96 96 journal = Journal.new(attributes)
97 97 journal.user ||= User.first
98 98 journal.journalized ||= Issue.first
99 99 yield journal if block_given?
100 100 journal.save!
101 101 journal
102 102 end
103 103
104 104 def Version.generate!(attributes={})
105 105 @generated_version_name ||= 'Version 0'
106 106 @generated_version_name.succ!
107 107 version = Version.new(attributes)
108 108 version.name = @generated_version_name.dup if version.name.blank?
109 109 yield version if block_given?
110 110 version.save!
111 111 version
112 112 end
113 113
114 114 def TimeEntry.generate!(attributes={})
115 115 entry = TimeEntry.new(attributes)
116 116 entry.user ||= User.find(2)
117 117 entry.issue ||= Issue.find(1) unless entry.project
118 118 entry.project ||= entry.issue.project
119 119 entry.activity ||= TimeEntryActivity.first
120 120 entry.spent_on ||= Date.today
121 121 entry.hours ||= 1.0
122 122 entry.save!
123 123 entry
124 124 end
125 125
126 126 def AuthSource.generate!(attributes={})
127 127 @generated_auth_source_name ||= 'Auth 0'
128 128 @generated_auth_source_name.succ!
129 129 source = AuthSource.new(attributes)
130 130 source.name = @generated_auth_source_name.dup if source.name.blank?
131 131 yield source if block_given?
132 132 source.save!
133 133 source
134 134 end
135 135
136 136 def Board.generate!(attributes={})
137 137 @generated_board_name ||= 'Forum 0'
138 138 @generated_board_name.succ!
139 139 board = Board.new(attributes)
140 140 board.name = @generated_board_name.dup if board.name.blank?
141 141 board.description = @generated_board_name.dup if board.description.blank?
142 142 yield board if block_given?
143 143 board.save!
144 144 board
145 145 end
146 146
147 147 def Attachment.generate!(attributes={})
148 148 @generated_filename ||= 'testfile0'
149 149 @generated_filename.succ!
150 150 attributes = attributes.dup
151 151 attachment = Attachment.new(attributes)
152 152 attachment.container ||= Issue.find(1)
153 153 attachment.author ||= User.find(2)
154 154 attachment.filename = @generated_filename.dup if attachment.filename.blank?
155 155 attachment.save!
156 156 attachment
157 157 end
158 158
159 159 def CustomField.generate!(attributes={})
160 160 @generated_custom_field_name ||= 'Custom field 0'
161 161 @generated_custom_field_name.succ!
162 162 field = new(attributes)
163 163 field.name = @generated_custom_field_name.dup if field.name.blank?
164 164 field.field_format = 'string' if field.field_format.blank?
165 165 yield field if block_given?
166 166 field.save!
167 167 field
168 168 end
169 169
170 170 def Changeset.generate!(attributes={})
171 171 @generated_changeset_rev ||= '123456'
172 172 @generated_changeset_rev.succ!
173 173 changeset = new(attributes)
174 174 changeset.repository ||= Project.find(1).repository
175 175 changeset.revision ||= @generated_changeset_rev
176 176 changeset.committed_on ||= Time.now
177 177 yield changeset if block_given?
178 178 changeset.save!
179 179 changeset
180 180 end
181
182 def Query.generate!(attributes={})
183 query = new(attributes)
184 query.name = "Generated query" if query.name.blank?
185 query.user ||= User.find(1)
186 query.save!
187 query
188 end
181 189 end
182 190
183 191 module IssueObjectHelpers
184 192 def close!
185 193 self.status = IssueStatus.where(:is_closed => true).first
186 194 save!
187 195 end
188 196
189 197 def generate_child!(attributes={})
190 198 Issue.generate!(attributes.merge(:parent_issue_id => self.id))
191 199 end
192 200 end
193 201
194 202 Issue.send :include, IssueObjectHelpers
@@ -1,78 +1,94
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2014 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 require File.expand_path('../../../test_helper', __FILE__)
19 19
20 20 class ProjectsHelperTest < ActionView::TestCase
21 21 include ApplicationHelper
22 22 include ProjectsHelper
23 23 include Redmine::I18n
24 24 include ERB::Util
25 25 include Rails.application.routes.url_helpers
26 26
27 27 fixtures :projects, :trackers, :issue_statuses, :issues,
28 28 :enumerations, :users, :issue_categories,
29 29 :versions,
30 30 :projects_trackers,
31 31 :member_roles,
32 32 :members,
33 33 :groups_users,
34 34 :enabled_modules
35 35
36 36 def setup
37 37 super
38 38 set_language_if_valid('en')
39 39 User.current = nil
40 40 end
41 41
42 42 def test_link_to_version_within_project
43 43 @project = Project.find(2)
44 44 User.current = User.find(1)
45 assert_equal '<a href="/versions/5">Alpha</a>', link_to_version(Version.find(5))
45 assert_equal '<a href="/versions/5" title="07/01/2006">Alpha</a>', link_to_version(Version.find(5))
46 46 end
47 47
48 48 def test_link_to_version
49 49 User.current = User.find(1)
50 assert_equal '<a href="/versions/5">OnlineStore - Alpha</a>', link_to_version(Version.find(5))
50 assert_equal '<a href="/versions/5" title="07/01/2006">Alpha</a>', link_to_version(Version.find(5))
51 end
52
53 def test_link_to_version_without_effective_date
54 User.current = User.find(1)
55 version = Version.find(5)
56 version.effective_date = nil
57 assert_equal '<a href="/versions/5">Alpha</a>', link_to_version(version)
51 58 end
52 59
53 60 def test_link_to_private_version
54 assert_equal 'OnlineStore - Alpha', link_to_version(Version.find(5))
61 assert_equal 'Alpha', link_to_version(Version.find(5))
55 62 end
56 63
57 64 def test_link_to_version_invalid_version
58 65 assert_equal '', link_to_version(Object)
59 66 end
60 67
61 68 def test_format_version_name_within_project
62 69 @project = Project.find(1)
63 70 assert_equal "0.1", format_version_name(Version.find(1))
64 71 end
65 72
66 73 def test_format_version_name
67 assert_equal "eCookbook - 0.1", format_version_name(Version.find(1))
74 assert_equal "0.1", format_version_name(Version.find(1))
75 end
76
77 def test_format_version_name_for_shared_version_within_project_should_not_display_project_name
78 @project = Project.find(1)
79 version = Version.find(1)
80 version.sharing = 'system'
81 assert_equal "0.1", format_version_name(version)
68 82 end
69 83
70 def test_format_version_name_for_system_version
71 assert_equal "OnlineStore - Systemwide visible version", format_version_name(Version.find(7))
84 def test_format_version_name_for_shared_version_should_display_project_name
85 version = Version.find(1)
86 version.sharing = 'system'
87 assert_equal "eCookbook - 0.1", format_version_name(version)
72 88 end
73 89
74 90 def test_version_options_for_select_with_no_versions
75 91 assert_equal '', version_options_for_select([])
76 92 assert_equal '', version_options_for_select([], Version.find(1))
77 93 end
78 94 end
@@ -1,337 +1,348
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2014 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 require File.expand_path('../../test_helper', __FILE__)
19 19
20 20 class ProjectCopyTest < ActiveSupport::TestCase
21 21 fixtures :projects, :trackers, :issue_statuses, :issues,
22 22 :journals, :journal_details,
23 23 :enumerations, :users, :issue_categories,
24 24 :projects_trackers,
25 25 :custom_fields,
26 26 :custom_fields_projects,
27 27 :custom_fields_trackers,
28 28 :custom_values,
29 29 :roles,
30 30 :member_roles,
31 31 :members,
32 32 :enabled_modules,
33 33 :versions,
34 34 :wikis, :wiki_pages, :wiki_contents, :wiki_content_versions,
35 35 :groups_users,
36 36 :boards, :messages,
37 37 :repositories,
38 38 :news, :comments,
39 39 :documents
40 40
41 41 def setup
42 42 ProjectCustomField.destroy_all
43 43 @source_project = Project.find(2)
44 44 @project = Project.new(:name => 'Copy Test', :identifier => 'copy-test')
45 45 @project.trackers = @source_project.trackers
46 46 @project.enabled_module_names = @source_project.enabled_modules.collect(&:name)
47 47 end
48 48
49 49 test "#copy should copy issues" do
50 50 @source_project.issues << Issue.generate!(:status => IssueStatus.find_by_name('Closed'),
51 51 :subject => "copy issue status",
52 52 :tracker_id => 1,
53 53 :assigned_to_id => 2,
54 54 :project_id => @source_project.id)
55 55 assert @project.valid?
56 56 assert @project.issues.empty?
57 57 assert @project.copy(@source_project)
58 58
59 59 assert_equal @source_project.issues.size, @project.issues.size
60 60 @project.issues.each do |issue|
61 61 assert issue.valid?
62 62 assert ! issue.assigned_to.blank?
63 63 assert_equal @project, issue.project
64 64 end
65 65
66 66 copied_issue = @project.issues.where(:subject => "copy issue status").first
67 67 assert copied_issue
68 68 assert copied_issue.status
69 69 assert_equal "Closed", copied_issue.status.name
70 70 end
71 71
72 72 test "#copy should copy issues custom values" do
73 73 field = IssueCustomField.generate!(:is_for_all => true, :trackers => Tracker.all)
74 74 issue = Issue.generate!(:project => @source_project, :subject => 'Custom field copy')
75 75 issue.custom_field_values = {field.id => 'custom'}
76 76 issue.save!
77 77 assert_equal 'custom', issue.reload.custom_field_value(field)
78 78
79 79 assert @project.copy(@source_project)
80 80 copy = @project.issues.find_by_subject('Custom field copy')
81 81 assert copy
82 82 assert_equal 'custom', copy.reload.custom_field_value(field)
83 83 end
84 84
85 85 test "#copy should copy issues assigned to a locked version" do
86 86 User.current = User.find(1)
87 87 assigned_version = Version.generate!(:name => "Assigned Issues")
88 88 @source_project.versions << assigned_version
89 89 Issue.generate!(:project => @source_project,
90 90 :fixed_version_id => assigned_version.id,
91 91 :subject => "copy issues assigned to a locked version")
92 92 assigned_version.update_attribute :status, 'locked'
93 93
94 94 assert @project.copy(@source_project)
95 95 @project.reload
96 96 copied_issue = @project.issues.where(:subject => "copy issues assigned to a locked version").first
97 97
98 98 assert copied_issue
99 99 assert copied_issue.fixed_version
100 100 assert_equal "Assigned Issues", copied_issue.fixed_version.name # Same name
101 101 assert_equal 'locked', copied_issue.fixed_version.status
102 102 end
103 103
104 104 test "#copy should change the new issues to use the copied version" do
105 105 User.current = User.find(1)
106 106 assigned_version = Version.generate!(:name => "Assigned Issues", :status => 'open')
107 107 @source_project.versions << assigned_version
108 108 assert_equal 3, @source_project.versions.size
109 109 Issue.generate!(:project => @source_project,
110 110 :fixed_version_id => assigned_version.id,
111 111 :subject => "change the new issues to use the copied version")
112 112
113 113 assert @project.copy(@source_project)
114 114 @project.reload
115 115 copied_issue = @project.issues.where(:subject => "change the new issues to use the copied version").first
116 116
117 117 assert copied_issue
118 118 assert copied_issue.fixed_version
119 119 assert_equal "Assigned Issues", copied_issue.fixed_version.name # Same name
120 120 assert_not_equal assigned_version.id, copied_issue.fixed_version.id # Different record
121 121 end
122 122
123 123 test "#copy should keep target shared versions from other project" do
124 124 assigned_version = Version.generate!(:name => "Assigned Issues", :status => 'open', :project_id => 1, :sharing => 'system')
125 125 issue = Issue.generate!(:project => @source_project,
126 126 :fixed_version => assigned_version,
127 127 :subject => "keep target shared versions")
128 128
129 129 assert @project.copy(@source_project)
130 130 @project.reload
131 131 copied_issue = @project.issues.where(:subject => "keep target shared versions").first
132 132
133 133 assert copied_issue
134 134 assert_equal assigned_version, copied_issue.fixed_version
135 135 end
136 136
137 137 test "#copy should copy issue relations" do
138 138 Setting.cross_project_issue_relations = '1'
139 139
140 140 second_issue = Issue.generate!(:status_id => 5,
141 141 :subject => "copy issue relation",
142 142 :tracker_id => 1,
143 143 :assigned_to_id => 2,
144 144 :project_id => @source_project.id)
145 145 source_relation = IssueRelation.create!(:issue_from => Issue.find(4),
146 146 :issue_to => second_issue,
147 147 :relation_type => "relates")
148 148 source_relation_cross_project = IssueRelation.create!(:issue_from => Issue.find(1),
149 149 :issue_to => second_issue,
150 150 :relation_type => "duplicates")
151 151
152 152 assert @project.copy(@source_project)
153 153 assert_equal @source_project.issues.count, @project.issues.count
154 154 copied_issue = @project.issues.find_by_subject("Issue on project 2") # Was #4
155 155 copied_second_issue = @project.issues.find_by_subject("copy issue relation")
156 156
157 157 # First issue with a relation on project
158 158 assert_equal 1, copied_issue.relations.size, "Relation not copied"
159 159 copied_relation = copied_issue.relations.first
160 160 assert_equal "relates", copied_relation.relation_type
161 161 assert_equal copied_second_issue.id, copied_relation.issue_to_id
162 162 assert_not_equal source_relation.id, copied_relation.id
163 163
164 164 # Second issue with a cross project relation
165 165 assert_equal 2, copied_second_issue.relations.size, "Relation not copied"
166 166 copied_relation = copied_second_issue.relations.select {|r| r.relation_type == 'duplicates'}.first
167 167 assert_equal "duplicates", copied_relation.relation_type
168 168 assert_equal 1, copied_relation.issue_from_id, "Cross project relation not kept"
169 169 assert_not_equal source_relation_cross_project.id, copied_relation.id
170 170 end
171 171
172 172 test "#copy should copy issue attachments" do
173 173 issue = Issue.generate!(:subject => "copy with attachment", :tracker_id => 1, :project_id => @source_project.id)
174 174 Attachment.create!(:container => issue, :file => uploaded_test_file("testfile.txt", "text/plain"), :author_id => 1)
175 175 @source_project.issues << issue
176 176 assert @project.copy(@source_project)
177 177
178 178 copied_issue = @project.issues.where(:subject => "copy with attachment").first
179 179 assert_not_nil copied_issue
180 180 assert_equal 1, copied_issue.attachments.count, "Attachment not copied"
181 181 assert_equal "testfile.txt", copied_issue.attachments.first.filename
182 182 end
183 183
184 184 test "#copy should copy memberships" do
185 185 assert @project.valid?
186 186 assert @project.members.empty?
187 187 assert @project.copy(@source_project)
188 188
189 189 assert_equal @source_project.memberships.size, @project.memberships.size
190 190 @project.memberships.each do |membership|
191 191 assert membership
192 192 assert_equal @project, membership.project
193 193 end
194 194 end
195 195
196 196 test "#copy should copy memberships with groups and additional roles" do
197 197 group = Group.create!(:lastname => "Copy group")
198 198 user = User.find(7)
199 199 group.users << user
200 200 # group role
201 201 Member.create!(:project_id => @source_project.id, :principal => group, :role_ids => [2])
202 202 member = Member.find_by_user_id_and_project_id(user.id, @source_project.id)
203 203 # additional role
204 204 member.role_ids = [1]
205 205
206 206 assert @project.copy(@source_project)
207 207 member = Member.find_by_user_id_and_project_id(user.id, @project.id)
208 208 assert_not_nil member
209 209 assert_equal [1, 2], member.role_ids.sort
210 210 end
211 211
212 212 test "#copy should copy project specific queries" do
213 213 assert @project.valid?
214 214 assert @project.queries.empty?
215 215 assert @project.copy(@source_project)
216 216
217 217 assert_equal @source_project.queries.size, @project.queries.size
218 218 @project.queries.each do |query|
219 219 assert query
220 220 assert_equal @project, query.project
221 221 end
222 222 assert_equal @source_project.queries.map(&:user_id).sort, @project.queries.map(&:user_id).sort
223 223 end
224 224
225 def test_copy_should_copy_queries_roles_visibility
226 source = Project.generate!
227 target = Project.new(:name => 'Copy Test', :identifier => 'copy-test')
228 IssueQuery.generate!(:project => source, :visibility => Query::VISIBILITY_ROLES, :roles => Role.where(:id => [1, 3]).to_a)
229
230 assert target.copy(source)
231 assert_equal 1, target.queries.size
232 query = target.queries.first
233 assert_equal [1, 3], query.role_ids.sort
234 end
235
225 236 test "#copy should copy versions" do
226 237 @source_project.versions << Version.generate!
227 238 @source_project.versions << Version.generate!
228 239
229 240 assert @project.versions.empty?
230 241 assert @project.copy(@source_project)
231 242
232 243 assert_equal @source_project.versions.size, @project.versions.size
233 244 @project.versions.each do |version|
234 245 assert version
235 246 assert_equal @project, version.project
236 247 end
237 248 end
238 249
239 250 test "#copy should copy wiki" do
240 251 assert_difference 'Wiki.count' do
241 252 assert @project.copy(@source_project)
242 253 end
243 254
244 255 assert @project.wiki
245 256 assert_not_equal @source_project.wiki, @project.wiki
246 257 assert_equal "Start page", @project.wiki.start_page
247 258 end
248 259
249 260 test "#copy should copy wiki without wiki module" do
250 261 project = Project.new(:name => 'Copy Test', :identifier => 'copy-test', :enabled_module_names => [])
251 262 assert_difference 'Wiki.count' do
252 263 assert project.copy(@source_project)
253 264 end
254 265
255 266 assert project.wiki
256 267 end
257 268
258 269 test "#copy should copy wiki pages and content with hierarchy" do
259 270 assert_difference 'WikiPage.count', @source_project.wiki.pages.size do
260 271 assert @project.copy(@source_project)
261 272 end
262 273
263 274 assert @project.wiki
264 275 assert_equal @source_project.wiki.pages.size, @project.wiki.pages.size
265 276
266 277 @project.wiki.pages.each do |wiki_page|
267 278 assert wiki_page.content
268 279 assert !@source_project.wiki.pages.include?(wiki_page)
269 280 end
270 281
271 282 parent = @project.wiki.find_page('Parent_page')
272 283 child1 = @project.wiki.find_page('Child_page_1')
273 284 child2 = @project.wiki.find_page('Child_page_2')
274 285 assert_equal parent, child1.parent
275 286 assert_equal parent, child2.parent
276 287 end
277 288
278 289 test "#copy should copy issue categories" do
279 290 assert @project.copy(@source_project)
280 291
281 292 assert_equal 2, @project.issue_categories.size
282 293 @project.issue_categories.each do |issue_category|
283 294 assert !@source_project.issue_categories.include?(issue_category)
284 295 end
285 296 end
286 297
287 298 test "#copy should copy boards" do
288 299 assert @project.copy(@source_project)
289 300
290 301 assert_equal 1, @project.boards.size
291 302 @project.boards.each do |board|
292 303 assert !@source_project.boards.include?(board)
293 304 end
294 305 end
295 306
296 307 test "#copy should change the new issues to use the copied issue categories" do
297 308 issue = Issue.find(4)
298 309 issue.update_attribute(:category_id, 3)
299 310
300 311 assert @project.copy(@source_project)
301 312
302 313 @project.issues.each do |issue|
303 314 assert issue.category
304 315 assert_equal "Stock management", issue.category.name # Same name
305 316 assert_not_equal IssueCategory.find(3), issue.category # Different record
306 317 end
307 318 end
308 319
309 320 test "#copy should limit copy with :only option" do
310 321 assert @project.members.empty?
311 322 assert @project.issue_categories.empty?
312 323 assert @source_project.issues.any?
313 324
314 325 assert @project.copy(@source_project, :only => ['members', 'issue_categories'])
315 326
316 327 assert @project.members.any?
317 328 assert @project.issue_categories.any?
318 329 assert @project.issues.empty?
319 330 end
320 331
321 332 test "#copy should copy subtasks" do
322 333 source = Project.generate!(:tracker_ids => [1])
323 334 issue = Issue.generate_with_descendants!(:project => source)
324 335 project = Project.new(:name => 'Copy', :identifier => 'copy', :tracker_ids => [1])
325 336
326 337 assert_difference 'Project.count' do
327 338 assert_difference 'Issue.count', 1+issue.descendants.count do
328 339 assert project.copy(source.reload)
329 340 end
330 341 end
331 342 copy = Issue.where(:parent_id => nil).order("id DESC").first
332 343 assert_equal project, copy.project
333 344 assert_equal issue.descendants.count, copy.descendants.count
334 345 child_copy = copy.children.detect {|c| c.subject == 'Child1'}
335 346 assert child_copy.descendants.any?
336 347 end
337 348 end
General Comments 0
You need to be logged in to leave comments. Login now