##// END OF EJS Templates
Refactor: move method to model with compatibility wrapper...
Eric Davis -
r4168:0ca74df60403
parent child
Show More
@@ -1,845 +1,840
1 1 # redMine - project management software
2 2 # Copyright (C) 2006-2007 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 'forwardable'
19 19 require 'cgi'
20 20
21 21 module ApplicationHelper
22 22 include Redmine::WikiFormatting::Macros::Definitions
23 23 include Redmine::I18n
24 24 include GravatarHelper::PublicMethods
25 25
26 26 extend Forwardable
27 27 def_delegators :wiki_helper, :wikitoolbar_for, :heads_for_wiki_formatter
28 28
29 29 # Return true if user is authorized for controller/action, otherwise false
30 30 def authorize_for(controller, action)
31 31 User.current.allowed_to?({:controller => controller, :action => action}, @project)
32 32 end
33 33
34 34 # Display a link if user is authorized
35 35 #
36 36 # @param [String] name Anchor text (passed to link_to)
37 37 # @param [Hash] options Hash params. This will checked by authorize_for to see if the user is authorized
38 38 # @param [optional, Hash] html_options Options passed to link_to
39 39 # @param [optional, Hash] parameters_for_method_reference Extra parameters for link_to
40 40 def link_to_if_authorized(name, options = {}, html_options = nil, *parameters_for_method_reference)
41 41 link_to(name, options, html_options, *parameters_for_method_reference) if authorize_for(options[:controller] || params[:controller], options[:action])
42 42 end
43 43
44 44 # Display a link to remote if user is authorized
45 45 def link_to_remote_if_authorized(name, options = {}, html_options = nil)
46 46 url = options[:url] || {}
47 47 link_to_remote(name, options, html_options) if authorize_for(url[:controller] || params[:controller], url[:action])
48 48 end
49 49
50 50 # Displays a link to user's account page if active
51 51 def link_to_user(user, options={})
52 52 if user.is_a?(User)
53 53 name = h(user.name(options[:format]))
54 54 if user.active?
55 55 link_to name, :controller => 'users', :action => 'show', :id => user
56 56 else
57 57 name
58 58 end
59 59 else
60 60 h(user.to_s)
61 61 end
62 62 end
63 63
64 64 # Displays a link to +issue+ with its subject.
65 65 # Examples:
66 66 #
67 67 # link_to_issue(issue) # => Defect #6: This is the subject
68 68 # link_to_issue(issue, :truncate => 6) # => Defect #6: This i...
69 69 # link_to_issue(issue, :subject => false) # => Defect #6
70 70 # link_to_issue(issue, :project => true) # => Foo - Defect #6
71 71 #
72 72 def link_to_issue(issue, options={})
73 73 title = nil
74 74 subject = nil
75 75 if options[:subject] == false
76 76 title = truncate(issue.subject, :length => 60)
77 77 else
78 78 subject = issue.subject
79 79 if options[:truncate]
80 80 subject = truncate(subject, :length => options[:truncate])
81 81 end
82 82 end
83 83 s = link_to "#{issue.tracker} ##{issue.id}", {:controller => "issues", :action => "show", :id => issue},
84 84 :class => issue.css_classes,
85 85 :title => title
86 86 s << ": #{h subject}" if subject
87 87 s = "#{h issue.project} - " + s if options[:project]
88 88 s
89 89 end
90 90
91 91 # Generates a link to an attachment.
92 92 # Options:
93 93 # * :text - Link text (default to attachment filename)
94 94 # * :download - Force download (default: false)
95 95 def link_to_attachment(attachment, options={})
96 96 text = options.delete(:text) || attachment.filename
97 97 action = options.delete(:download) ? 'download' : 'show'
98 98
99 99 link_to(h(text), {:controller => 'attachments', :action => action, :id => attachment, :filename => attachment.filename }, 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, project, options={})
106 106 text = options.delete(:text) || format_revision(revision)
107 107
108 108 link_to(text, {:controller => 'repositories', :action => 'revision', :id => project, :rev => revision}, :title => l(:label_revision_id, revision))
109 109 end
110 110
111 111 # Generates a link to a project if active
112 112 # Examples:
113 113 #
114 114 # link_to_project(project) # => link to the specified project overview
115 115 # link_to_project(project, :action=>'settings') # => link to project settings
116 116 # link_to_project(project, {:only_path => false}, :class => "project") # => 3rd arg adds html options
117 117 # link_to_project(project, {}, :class => "project") # => html options with default url (project overview)
118 118 #
119 119 def link_to_project(project, options={}, html_options = nil)
120 120 if project.active?
121 121 url = {:controller => 'projects', :action => 'show', :id => project}.merge(options)
122 122 link_to(h(project), url, html_options)
123 123 else
124 124 h(project)
125 125 end
126 126 end
127 127
128 128 def toggle_link(name, id, options={})
129 129 onclick = "Element.toggle('#{id}'); "
130 130 onclick << (options[:focus] ? "Form.Element.focus('#{options[:focus]}'); " : "this.blur(); ")
131 131 onclick << "return false;"
132 132 link_to(name, "#", :onclick => onclick)
133 133 end
134 134
135 135 def image_to_function(name, function, html_options = {})
136 136 html_options.symbolize_keys!
137 137 tag(:input, html_options.merge({
138 138 :type => "image", :src => image_path(name),
139 139 :onclick => (html_options[:onclick] ? "#{html_options[:onclick]}; " : "") + "#{function};"
140 140 }))
141 141 end
142 142
143 143 def prompt_to_remote(name, text, param, url, html_options = {})
144 144 html_options[:onclick] = "promptToRemote('#{text}', '#{param}', '#{url_for(url)}'); return false;"
145 145 link_to name, {}, html_options
146 146 end
147 147
148 148 def format_activity_title(text)
149 149 h(truncate_single_line(text, :length => 100))
150 150 end
151 151
152 152 def format_activity_day(date)
153 153 date == Date.today ? l(:label_today).titleize : format_date(date)
154 154 end
155 155
156 156 def format_activity_description(text)
157 157 h(truncate(text.to_s, :length => 120).gsub(%r{[\r\n]*<(pre|code)>.*$}m, '...')).gsub(/[\r\n]+/, "<br />")
158 158 end
159 159
160 160 def format_version_name(version)
161 161 if version.project == @project
162 162 h(version)
163 163 else
164 164 h("#{version.project} - #{version}")
165 165 end
166 166 end
167 167
168 168 def due_date_distance_in_words(date)
169 169 if date
170 170 l((date < Date.today ? :label_roadmap_overdue : :label_roadmap_due_in), distance_of_date_in_words(Date.today, date))
171 171 end
172 172 end
173 173
174 174 def render_page_hierarchy(pages, node=nil)
175 175 content = ''
176 176 if pages[node]
177 177 content << "<ul class=\"pages-hierarchy\">\n"
178 178 pages[node].each do |page|
179 179 content << "<li>"
180 180 content << link_to(h(page.pretty_title), {:controller => 'wiki', :action => 'show', :project_id => page.project, :page => page.title},
181 181 :title => (page.respond_to?(:updated_on) ? l(:label_updated_time, distance_of_time_in_words(Time.now, page.updated_on)) : nil))
182 182 content << "\n" + render_page_hierarchy(pages, page.id) if pages[page.id]
183 183 content << "</li>\n"
184 184 end
185 185 content << "</ul>\n"
186 186 end
187 187 content
188 188 end
189 189
190 190 # Renders flash messages
191 191 def render_flash_messages
192 192 s = ''
193 193 flash.each do |k,v|
194 194 s << content_tag('div', v, :class => "flash #{k}")
195 195 end
196 196 s
197 197 end
198 198
199 199 # Renders tabs and their content
200 200 def render_tabs(tabs)
201 201 if tabs.any?
202 202 render :partial => 'common/tabs', :locals => {:tabs => tabs}
203 203 else
204 204 content_tag 'p', l(:label_no_data), :class => "nodata"
205 205 end
206 206 end
207 207
208 208 # Renders the project quick-jump box
209 209 def render_project_jump_box
210 210 # Retrieve them now to avoid a COUNT query
211 211 projects = User.current.projects.all
212 212 if projects.any?
213 213 s = '<select onchange="if (this.value != \'\') { window.location = this.value; }">' +
214 214 "<option value=''>#{ l(:label_jump_to_a_project) }</option>" +
215 215 '<option value="" disabled="disabled">---</option>'
216 216 s << project_tree_options_for_select(projects, :selected => @project) do |p|
217 217 { :value => url_for(:controller => 'projects', :action => 'show', :id => p, :jump => current_menu_item) }
218 218 end
219 219 s << '</select>'
220 220 s
221 221 end
222 222 end
223 223
224 224 def project_tree_options_for_select(projects, options = {})
225 225 s = ''
226 226 project_tree(projects) do |project, level|
227 227 name_prefix = (level > 0 ? ('&nbsp;' * 2 * level + '&#187; ') : '')
228 228 tag_options = {:value => project.id}
229 229 if project == options[:selected] || (options[:selected].respond_to?(:include?) && options[:selected].include?(project))
230 230 tag_options[:selected] = 'selected'
231 231 else
232 232 tag_options[:selected] = nil
233 233 end
234 234 tag_options.merge!(yield(project)) if block_given?
235 235 s << content_tag('option', name_prefix + h(project), tag_options)
236 236 end
237 237 s
238 238 end
239 239
240 240 # Yields the given block for each project with its level in the tree
241 #
242 # Wrapper for Project#project_tree
241 243 def project_tree(projects, &block)
242 ancestors = []
243 projects.sort_by(&:lft).each do |project|
244 while (ancestors.any? && !project.is_descendant_of?(ancestors.last))
245 ancestors.pop
246 end
247 yield project, ancestors.size
248 ancestors << project
249 end
244 Project.project_tree(projects, &block)
250 245 end
251 246
252 247 def project_nested_ul(projects, &block)
253 248 s = ''
254 249 if projects.any?
255 250 ancestors = []
256 251 projects.sort_by(&:lft).each do |project|
257 252 if (ancestors.empty? || project.is_descendant_of?(ancestors.last))
258 253 s << "<ul>\n"
259 254 else
260 255 ancestors.pop
261 256 s << "</li>"
262 257 while (ancestors.any? && !project.is_descendant_of?(ancestors.last))
263 258 ancestors.pop
264 259 s << "</ul></li>\n"
265 260 end
266 261 end
267 262 s << "<li>"
268 263 s << yield(project).to_s
269 264 ancestors << project
270 265 end
271 266 s << ("</li></ul>\n" * ancestors.size)
272 267 end
273 268 s
274 269 end
275 270
276 271 def principals_check_box_tags(name, principals)
277 272 s = ''
278 273 principals.sort.each do |principal|
279 274 s << "<label>#{ check_box_tag name, principal.id, false } #{h principal}</label>\n"
280 275 end
281 276 s
282 277 end
283 278
284 279 # Truncates and returns the string as a single line
285 280 def truncate_single_line(string, *args)
286 281 truncate(string.to_s, *args).gsub(%r{[\r\n]+}m, ' ')
287 282 end
288 283
289 284 # Truncates at line break after 250 characters or options[:length]
290 285 def truncate_lines(string, options={})
291 286 length = options[:length] || 250
292 287 if string.to_s =~ /\A(.{#{length}}.*?)$/m
293 288 "#{$1}..."
294 289 else
295 290 string
296 291 end
297 292 end
298 293
299 294 def html_hours(text)
300 295 text.gsub(%r{(\d+)\.(\d+)}, '<span class="hours hours-int">\1</span><span class="hours hours-dec">.\2</span>')
301 296 end
302 297
303 298 def authoring(created, author, options={})
304 299 l(options[:label] || :label_added_time_by, :author => link_to_user(author), :age => time_tag(created))
305 300 end
306 301
307 302 def time_tag(time)
308 303 text = distance_of_time_in_words(Time.now, time)
309 304 if @project
310 305 link_to(text, {:controller => 'activities', :action => 'index', :id => @project, :from => time.to_date}, :title => format_time(time))
311 306 else
312 307 content_tag('acronym', text, :title => format_time(time))
313 308 end
314 309 end
315 310
316 311 def syntax_highlight(name, content)
317 312 Redmine::SyntaxHighlighting.highlight_by_filename(content, name)
318 313 end
319 314
320 315 def to_path_param(path)
321 316 path.to_s.split(%r{[/\\]}).select {|p| !p.blank?}
322 317 end
323 318
324 319 def pagination_links_full(paginator, count=nil, options={})
325 320 page_param = options.delete(:page_param) || :page
326 321 per_page_links = options.delete(:per_page_links)
327 322 url_param = params.dup
328 323 # don't reuse query params if filters are present
329 324 url_param.merge!(:fields => nil, :values => nil, :operators => nil) if url_param.delete(:set_filter)
330 325
331 326 html = ''
332 327 if paginator.current.previous
333 328 html << link_to_remote_content_update('&#171; ' + l(:label_previous), url_param.merge(page_param => paginator.current.previous)) + ' '
334 329 end
335 330
336 331 html << (pagination_links_each(paginator, options) do |n|
337 332 link_to_remote_content_update(n.to_s, url_param.merge(page_param => n))
338 333 end || '')
339 334
340 335 if paginator.current.next
341 336 html << ' ' + link_to_remote_content_update((l(:label_next) + ' &#187;'), url_param.merge(page_param => paginator.current.next))
342 337 end
343 338
344 339 unless count.nil?
345 340 html << " (#{paginator.current.first_item}-#{paginator.current.last_item}/#{count})"
346 341 if per_page_links != false && links = per_page_links(paginator.items_per_page)
347 342 html << " | #{links}"
348 343 end
349 344 end
350 345
351 346 html
352 347 end
353 348
354 349 def per_page_links(selected=nil)
355 350 url_param = params.dup
356 351 url_param.clear if url_param.has_key?(:set_filter)
357 352
358 353 links = Setting.per_page_options_array.collect do |n|
359 354 n == selected ? n : link_to_remote(n, {:update => "content",
360 355 :url => params.dup.merge(:per_page => n),
361 356 :method => :get},
362 357 {:href => url_for(url_param.merge(:per_page => n))})
363 358 end
364 359 links.size > 1 ? l(:label_display_per_page, links.join(', ')) : nil
365 360 end
366 361
367 362 def reorder_links(name, url)
368 363 link_to(image_tag('2uparrow.png', :alt => l(:label_sort_highest)), url.merge({"#{name}[move_to]" => 'highest'}), :method => :post, :title => l(:label_sort_highest)) +
369 364 link_to(image_tag('1uparrow.png', :alt => l(:label_sort_higher)), url.merge({"#{name}[move_to]" => 'higher'}), :method => :post, :title => l(:label_sort_higher)) +
370 365 link_to(image_tag('1downarrow.png', :alt => l(:label_sort_lower)), url.merge({"#{name}[move_to]" => 'lower'}), :method => :post, :title => l(:label_sort_lower)) +
371 366 link_to(image_tag('2downarrow.png', :alt => l(:label_sort_lowest)), url.merge({"#{name}[move_to]" => 'lowest'}), :method => :post, :title => l(:label_sort_lowest))
372 367 end
373 368
374 369 def breadcrumb(*args)
375 370 elements = args.flatten
376 371 elements.any? ? content_tag('p', args.join(' &#187; ') + ' &#187; ', :class => 'breadcrumb') : nil
377 372 end
378 373
379 374 def other_formats_links(&block)
380 375 concat('<p class="other-formats">' + l(:label_export_to))
381 376 yield Redmine::Views::OtherFormatsBuilder.new(self)
382 377 concat('</p>')
383 378 end
384 379
385 380 def page_header_title
386 381 if @project.nil? || @project.new_record?
387 382 h(Setting.app_title)
388 383 else
389 384 b = []
390 385 ancestors = (@project.root? ? [] : @project.ancestors.visible)
391 386 if ancestors.any?
392 387 root = ancestors.shift
393 388 b << link_to_project(root, {:jump => current_menu_item}, :class => 'root')
394 389 if ancestors.size > 2
395 390 b << '&#8230;'
396 391 ancestors = ancestors[-2, 2]
397 392 end
398 393 b += ancestors.collect {|p| link_to_project(p, {:jump => current_menu_item}, :class => 'ancestor') }
399 394 end
400 395 b << h(@project)
401 396 b.join(' &#187; ')
402 397 end
403 398 end
404 399
405 400 def html_title(*args)
406 401 if args.empty?
407 402 title = []
408 403 title << @project.name if @project
409 404 title += @html_title if @html_title
410 405 title << Setting.app_title
411 406 title.select {|t| !t.blank? }.join(' - ')
412 407 else
413 408 @html_title ||= []
414 409 @html_title += args
415 410 end
416 411 end
417 412
418 413 # Returns the theme, controller name, and action as css classes for the
419 414 # HTML body.
420 415 def body_css_classes
421 416 css = []
422 417 if theme = Redmine::Themes.theme(Setting.ui_theme)
423 418 css << 'theme-' + theme.name
424 419 end
425 420
426 421 css << 'controller-' + params[:controller]
427 422 css << 'action-' + params[:action]
428 423 css.join(' ')
429 424 end
430 425
431 426 def accesskey(s)
432 427 Redmine::AccessKeys.key_for s
433 428 end
434 429
435 430 # Formats text according to system settings.
436 431 # 2 ways to call this method:
437 432 # * with a String: textilizable(text, options)
438 433 # * with an object and one of its attribute: textilizable(issue, :description, options)
439 434 def textilizable(*args)
440 435 options = args.last.is_a?(Hash) ? args.pop : {}
441 436 case args.size
442 437 when 1
443 438 obj = options[:object]
444 439 text = args.shift
445 440 when 2
446 441 obj = args.shift
447 442 attr = args.shift
448 443 text = obj.send(attr).to_s
449 444 else
450 445 raise ArgumentError, 'invalid arguments to textilizable'
451 446 end
452 447 return '' if text.blank?
453 448 project = options[:project] || @project || (obj && obj.respond_to?(:project) ? obj.project : nil)
454 449 only_path = options.delete(:only_path) == false ? false : true
455 450
456 451 text = Redmine::WikiFormatting.to_html(Setting.text_formatting, text, :object => obj, :attribute => attr) { |macro, args| exec_macro(macro, obj, args) }
457 452
458 453 parse_non_pre_blocks(text) do |text|
459 454 [:parse_inline_attachments, :parse_wiki_links, :parse_redmine_links].each do |method_name|
460 455 send method_name, text, project, obj, attr, only_path, options
461 456 end
462 457 end
463 458 end
464 459
465 460 def parse_non_pre_blocks(text)
466 461 s = StringScanner.new(text)
467 462 tags = []
468 463 parsed = ''
469 464 while !s.eos?
470 465 s.scan(/(.*?)(<(\/)?(pre|code)(.*?)>|\z)/im)
471 466 text, full_tag, closing, tag = s[1], s[2], s[3], s[4]
472 467 if tags.empty?
473 468 yield text
474 469 end
475 470 parsed << text
476 471 if tag
477 472 if closing
478 473 if tags.last == tag.downcase
479 474 tags.pop
480 475 end
481 476 else
482 477 tags << tag.downcase
483 478 end
484 479 parsed << full_tag
485 480 end
486 481 end
487 482 # Close any non closing tags
488 483 while tag = tags.pop
489 484 parsed << "</#{tag}>"
490 485 end
491 486 parsed
492 487 end
493 488
494 489 def parse_inline_attachments(text, project, obj, attr, only_path, options)
495 490 # when using an image link, try to use an attachment, if possible
496 491 if options[:attachments] || (obj && obj.respond_to?(:attachments))
497 492 attachments = nil
498 493 text.gsub!(/src="([^\/"]+\.(bmp|gif|jpg|jpeg|png))"(\s+alt="([^"]*)")?/i) do |m|
499 494 filename, ext, alt, alttext = $1.downcase, $2, $3, $4
500 495 attachments ||= (options[:attachments] || obj.attachments).sort_by(&:created_on).reverse
501 496 # search for the picture in attachments
502 497 if found = attachments.detect { |att| att.filename.downcase == filename }
503 498 image_url = url_for :only_path => only_path, :controller => 'attachments', :action => 'download', :id => found
504 499 desc = found.description.to_s.gsub('"', '')
505 500 if !desc.blank? && alttext.blank?
506 501 alt = " title=\"#{desc}\" alt=\"#{desc}\""
507 502 end
508 503 "src=\"#{image_url}\"#{alt}"
509 504 else
510 505 m
511 506 end
512 507 end
513 508 end
514 509 end
515 510
516 511 # Wiki links
517 512 #
518 513 # Examples:
519 514 # [[mypage]]
520 515 # [[mypage|mytext]]
521 516 # wiki links can refer other project wikis, using project name or identifier:
522 517 # [[project:]] -> wiki starting page
523 518 # [[project:|mytext]]
524 519 # [[project:mypage]]
525 520 # [[project:mypage|mytext]]
526 521 def parse_wiki_links(text, project, obj, attr, only_path, options)
527 522 text.gsub!(/(!)?(\[\[([^\]\n\|]+)(\|([^\]\n\|]+))?\]\])/) do |m|
528 523 link_project = project
529 524 esc, all, page, title = $1, $2, $3, $5
530 525 if esc.nil?
531 526 if page =~ /^([^\:]+)\:(.*)$/
532 527 link_project = Project.find_by_name($1) || Project.find_by_identifier($1)
533 528 page = $2
534 529 title ||= $1 if page.blank?
535 530 end
536 531
537 532 if link_project && link_project.wiki
538 533 # extract anchor
539 534 anchor = nil
540 535 if page =~ /^(.+?)\#(.+)$/
541 536 page, anchor = $1, $2
542 537 end
543 538 # check if page exists
544 539 wiki_page = link_project.wiki.find_page(page)
545 540 url = case options[:wiki_links]
546 541 when :local; "#{title}.html"
547 542 when :anchor; "##{title}" # used for single-file wiki export
548 543 else
549 544 url_for(:only_path => only_path, :controller => 'wiki', :action => 'show', :project_id => link_project, :page => Wiki.titleize(page), :anchor => anchor)
550 545 end
551 546 link_to((title || page), url, :class => ('wiki-page' + (wiki_page ? '' : ' new')))
552 547 else
553 548 # project or wiki doesn't exist
554 549 all
555 550 end
556 551 else
557 552 all
558 553 end
559 554 end
560 555 end
561 556
562 557 # Redmine links
563 558 #
564 559 # Examples:
565 560 # Issues:
566 561 # #52 -> Link to issue #52
567 562 # Changesets:
568 563 # r52 -> Link to revision 52
569 564 # commit:a85130f -> Link to scmid starting with a85130f
570 565 # Documents:
571 566 # document#17 -> Link to document with id 17
572 567 # document:Greetings -> Link to the document with title "Greetings"
573 568 # document:"Some document" -> Link to the document with title "Some document"
574 569 # Versions:
575 570 # version#3 -> Link to version with id 3
576 571 # version:1.0.0 -> Link to version named "1.0.0"
577 572 # version:"1.0 beta 2" -> Link to version named "1.0 beta 2"
578 573 # Attachments:
579 574 # attachment:file.zip -> Link to the attachment of the current object named file.zip
580 575 # Source files:
581 576 # source:some/file -> Link to the file located at /some/file in the project's repository
582 577 # source:some/file@52 -> Link to the file's revision 52
583 578 # source:some/file#L120 -> Link to line 120 of the file
584 579 # source:some/file@52#L120 -> Link to line 120 of the file's revision 52
585 580 # export:some/file -> Force the download of the file
586 581 # Forum messages:
587 582 # message#1218 -> Link to message with id 1218
588 583 def parse_redmine_links(text, project, obj, attr, only_path, options)
589 584 text.gsub!(%r{([\s\(,\-\[\>]|^)(!)?(attachment|document|version|commit|source|export|message|project)?((#|r)(\d+)|(:)([^"\s<>][^\s<>]*?|"[^"]+?"))(?=(?=[[:punct:]]\W)|,|\s|\]|<|$)}) do |m|
590 585 leading, esc, prefix, sep, identifier = $1, $2, $3, $5 || $7, $6 || $8
591 586 link = nil
592 587 if esc.nil?
593 588 if prefix.nil? && sep == 'r'
594 589 if project && (changeset = project.changesets.find_by_revision(identifier))
595 590 link = link_to("r#{identifier}", {:only_path => only_path, :controller => 'repositories', :action => 'revision', :id => project, :rev => changeset.revision},
596 591 :class => 'changeset',
597 592 :title => truncate_single_line(changeset.comments, :length => 100))
598 593 end
599 594 elsif sep == '#'
600 595 oid = identifier.to_i
601 596 case prefix
602 597 when nil
603 598 if issue = Issue.visible.find_by_id(oid, :include => :status)
604 599 link = link_to("##{oid}", {:only_path => only_path, :controller => 'issues', :action => 'show', :id => oid},
605 600 :class => issue.css_classes,
606 601 :title => "#{truncate(issue.subject, :length => 100)} (#{issue.status.name})")
607 602 end
608 603 when 'document'
609 604 if document = Document.find_by_id(oid, :include => [:project], :conditions => Project.visible_by(User.current))
610 605 link = link_to h(document.title), {:only_path => only_path, :controller => 'documents', :action => 'show', :id => document},
611 606 :class => 'document'
612 607 end
613 608 when 'version'
614 609 if version = Version.find_by_id(oid, :include => [:project], :conditions => Project.visible_by(User.current))
615 610 link = link_to h(version.name), {:only_path => only_path, :controller => 'versions', :action => 'show', :id => version},
616 611 :class => 'version'
617 612 end
618 613 when 'message'
619 614 if message = Message.find_by_id(oid, :include => [:parent, {:board => :project}], :conditions => Project.visible_by(User.current))
620 615 link = link_to h(truncate(message.subject, :length => 60)), {:only_path => only_path,
621 616 :controller => 'messages',
622 617 :action => 'show',
623 618 :board_id => message.board,
624 619 :id => message.root,
625 620 :anchor => (message.parent ? "message-#{message.id}" : nil)},
626 621 :class => 'message'
627 622 end
628 623 when 'project'
629 624 if p = Project.visible.find_by_id(oid)
630 625 link = link_to_project(p, {:only_path => only_path}, :class => 'project')
631 626 end
632 627 end
633 628 elsif sep == ':'
634 629 # removes the double quotes if any
635 630 name = identifier.gsub(%r{^"(.*)"$}, "\\1")
636 631 case prefix
637 632 when 'document'
638 633 if project && document = project.documents.find_by_title(name)
639 634 link = link_to h(document.title), {:only_path => only_path, :controller => 'documents', :action => 'show', :id => document},
640 635 :class => 'document'
641 636 end
642 637 when 'version'
643 638 if project && version = project.versions.find_by_name(name)
644 639 link = link_to h(version.name), {:only_path => only_path, :controller => 'versions', :action => 'show', :id => version},
645 640 :class => 'version'
646 641 end
647 642 when 'commit'
648 643 if project && (changeset = project.changesets.find(:first, :conditions => ["scmid LIKE ?", "#{name}%"]))
649 644 link = link_to h("#{name}"), {:only_path => only_path, :controller => 'repositories', :action => 'revision', :id => project, :rev => changeset.revision},
650 645 :class => 'changeset',
651 646 :title => truncate_single_line(changeset.comments, :length => 100)
652 647 end
653 648 when 'source', 'export'
654 649 if project && project.repository
655 650 name =~ %r{^[/\\]*(.*?)(@([0-9a-f]+))?(#(L\d+))?$}
656 651 path, rev, anchor = $1, $3, $5
657 652 link = link_to h("#{prefix}:#{name}"), {:controller => 'repositories', :action => 'entry', :id => project,
658 653 :path => to_path_param(path),
659 654 :rev => rev,
660 655 :anchor => anchor,
661 656 :format => (prefix == 'export' ? 'raw' : nil)},
662 657 :class => (prefix == 'export' ? 'source download' : 'source')
663 658 end
664 659 when 'attachment'
665 660 attachments = options[:attachments] || (obj && obj.respond_to?(:attachments) ? obj.attachments : nil)
666 661 if attachments && attachment = attachments.detect {|a| a.filename == name }
667 662 link = link_to h(attachment.filename), {:only_path => only_path, :controller => 'attachments', :action => 'download', :id => attachment},
668 663 :class => 'attachment'
669 664 end
670 665 when 'project'
671 666 if p = Project.visible.find(:first, :conditions => ["identifier = :s OR LOWER(name) = :s", {:s => name.downcase}])
672 667 link = link_to_project(p, {:only_path => only_path}, :class => 'project')
673 668 end
674 669 end
675 670 end
676 671 end
677 672 leading + (link || "#{prefix}#{sep}#{identifier}")
678 673 end
679 674 end
680 675
681 676 # Same as Rails' simple_format helper without using paragraphs
682 677 def simple_format_without_paragraph(text)
683 678 text.to_s.
684 679 gsub(/\r\n?/, "\n"). # \r\n and \r -> \n
685 680 gsub(/\n\n+/, "<br /><br />"). # 2+ newline -> 2 br
686 681 gsub(/([^\n]\n)(?=[^\n])/, '\1<br />') # 1 newline -> br
687 682 end
688 683
689 684 def lang_options_for_select(blank=true)
690 685 (blank ? [["(auto)", ""]] : []) +
691 686 valid_languages.collect{|lang| [ ll(lang.to_s, :general_lang_name), lang.to_s]}.sort{|x,y| x.last <=> y.last }
692 687 end
693 688
694 689 def label_tag_for(name, option_tags = nil, options = {})
695 690 label_text = l(("field_"+field.to_s.gsub(/\_id$/, "")).to_sym) + (options.delete(:required) ? @template.content_tag("span", " *", :class => "required"): "")
696 691 content_tag("label", label_text)
697 692 end
698 693
699 694 def labelled_tabular_form_for(name, object, options, &proc)
700 695 options[:html] ||= {}
701 696 options[:html][:class] = 'tabular' unless options[:html].has_key?(:class)
702 697 form_for(name, object, options.merge({ :builder => TabularFormBuilder, :lang => current_language}), &proc)
703 698 end
704 699
705 700 def back_url_hidden_field_tag
706 701 back_url = params[:back_url] || request.env['HTTP_REFERER']
707 702 back_url = CGI.unescape(back_url.to_s)
708 703 hidden_field_tag('back_url', CGI.escape(back_url)) unless back_url.blank?
709 704 end
710 705
711 706 def check_all_links(form_name)
712 707 link_to_function(l(:button_check_all), "checkAll('#{form_name}', true)") +
713 708 " | " +
714 709 link_to_function(l(:button_uncheck_all), "checkAll('#{form_name}', false)")
715 710 end
716 711
717 712 def progress_bar(pcts, options={})
718 713 pcts = [pcts, pcts] unless pcts.is_a?(Array)
719 714 pcts = pcts.collect(&:round)
720 715 pcts[1] = pcts[1] - pcts[0]
721 716 pcts << (100 - pcts[1] - pcts[0])
722 717 width = options[:width] || '100px;'
723 718 legend = options[:legend] || ''
724 719 content_tag('table',
725 720 content_tag('tr',
726 721 (pcts[0] > 0 ? content_tag('td', '', :style => "width: #{pcts[0]}%;", :class => 'closed') : '') +
727 722 (pcts[1] > 0 ? content_tag('td', '', :style => "width: #{pcts[1]}%;", :class => 'done') : '') +
728 723 (pcts[2] > 0 ? content_tag('td', '', :style => "width: #{pcts[2]}%;", :class => 'todo') : '')
729 724 ), :class => 'progress', :style => "width: #{width};") +
730 725 content_tag('p', legend, :class => 'pourcent')
731 726 end
732 727
733 728 def checked_image(checked=true)
734 729 if checked
735 730 image_tag 'toggle_check.png'
736 731 end
737 732 end
738 733
739 734 def context_menu(url)
740 735 unless @context_menu_included
741 736 content_for :header_tags do
742 737 javascript_include_tag('context_menu') +
743 738 stylesheet_link_tag('context_menu')
744 739 end
745 740 if l(:direction) == 'rtl'
746 741 content_for :header_tags do
747 742 stylesheet_link_tag('context_menu_rtl')
748 743 end
749 744 end
750 745 @context_menu_included = true
751 746 end
752 747 javascript_tag "new ContextMenu('#{ url_for(url) }')"
753 748 end
754 749
755 750 def context_menu_link(name, url, options={})
756 751 options[:class] ||= ''
757 752 if options.delete(:selected)
758 753 options[:class] << ' icon-checked disabled'
759 754 options[:disabled] = true
760 755 end
761 756 if options.delete(:disabled)
762 757 options.delete(:method)
763 758 options.delete(:confirm)
764 759 options.delete(:onclick)
765 760 options[:class] << ' disabled'
766 761 url = '#'
767 762 end
768 763 link_to name, url, options
769 764 end
770 765
771 766 def calendar_for(field_id)
772 767 include_calendar_headers_tags
773 768 image_tag("calendar.png", {:id => "#{field_id}_trigger",:class => "calendar-trigger"}) +
774 769 javascript_tag("Calendar.setup({inputField : '#{field_id}', ifFormat : '%Y-%m-%d', button : '#{field_id}_trigger' });")
775 770 end
776 771
777 772 def include_calendar_headers_tags
778 773 unless @calendar_headers_tags_included
779 774 @calendar_headers_tags_included = true
780 775 content_for :header_tags do
781 776 start_of_week = case Setting.start_of_week.to_i
782 777 when 1
783 778 'Calendar._FD = 1;' # Monday
784 779 when 7
785 780 'Calendar._FD = 0;' # Sunday
786 781 else
787 782 '' # use language
788 783 end
789 784
790 785 javascript_include_tag('calendar/calendar') +
791 786 javascript_include_tag("calendar/lang/calendar-#{current_language.to_s.downcase}.js") +
792 787 javascript_tag(start_of_week) +
793 788 javascript_include_tag('calendar/calendar-setup') +
794 789 stylesheet_link_tag('calendar')
795 790 end
796 791 end
797 792 end
798 793
799 794 def content_for(name, content = nil, &block)
800 795 @has_content ||= {}
801 796 @has_content[name] = true
802 797 super(name, content, &block)
803 798 end
804 799
805 800 def has_content?(name)
806 801 (@has_content && @has_content[name]) || false
807 802 end
808 803
809 804 # Returns the avatar image tag for the given +user+ if avatars are enabled
810 805 # +user+ can be a User or a string that will be scanned for an email address (eg. 'joe <joe@foo.bar>')
811 806 def avatar(user, options = { })
812 807 if Setting.gravatar_enabled?
813 808 options.merge!({:ssl => (defined?(request) && request.ssl?), :default => Setting.gravatar_default})
814 809 email = nil
815 810 if user.respond_to?(:mail)
816 811 email = user.mail
817 812 elsif user.to_s =~ %r{<(.+?)>}
818 813 email = $1
819 814 end
820 815 return gravatar(email.to_s.downcase, options) unless email.blank? rescue nil
821 816 else
822 817 ''
823 818 end
824 819 end
825 820
826 821 def favicon
827 822 "<link rel='shortcut icon' href='#{image_path('/favicon.ico')}' />"
828 823 end
829 824
830 825 private
831 826
832 827 def wiki_helper
833 828 helper = Redmine::WikiFormatting.helper_for(Setting.text_formatting)
834 829 extend helper
835 830 return self
836 831 end
837 832
838 833 def link_to_remote_content_update(text, url_params)
839 834 link_to_remote(text,
840 835 {:url => url_params, :method => :get, :update => 'content', :complete => 'window.scrollTo(0,0)'},
841 836 {:href => url_for(:params => url_params)}
842 837 )
843 838 end
844 839
845 840 end
@@ -1,775 +1,787
1 1 # redMine - project management software
2 2 # Copyright (C) 2006 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 # Project statuses
20 20 STATUS_ACTIVE = 1
21 21 STATUS_ARCHIVED = 9
22 22
23 23 # Specific overidden Activities
24 24 has_many :time_entry_activities
25 25 has_many :members, :include => [:user, :roles], :conditions => "#{User.table_name}.type='User' AND #{User.table_name}.status=#{User::STATUS_ACTIVE}"
26 26 has_many :memberships, :class_name => 'Member'
27 27 has_many :member_principals, :class_name => 'Member',
28 28 :include => :principal,
29 29 :conditions => "#{Principal.table_name}.type='Group' OR (#{Principal.table_name}.type='User' AND #{Principal.table_name}.status=#{User::STATUS_ACTIVE})"
30 30 has_many :users, :through => :members
31 31 has_many :principals, :through => :member_principals, :source => :principal
32 32
33 33 has_many :enabled_modules, :dependent => :delete_all
34 34 has_and_belongs_to_many :trackers, :order => "#{Tracker.table_name}.position"
35 35 has_many :issues, :dependent => :destroy, :order => "#{Issue.table_name}.created_on DESC", :include => [:status, :tracker]
36 36 has_many :issue_changes, :through => :issues, :source => :journals
37 37 has_many :versions, :dependent => :destroy, :order => "#{Version.table_name}.effective_date DESC, #{Version.table_name}.name DESC"
38 38 has_many :time_entries, :dependent => :delete_all
39 39 has_many :queries, :dependent => :delete_all
40 40 has_many :documents, :dependent => :destroy
41 41 has_many :news, :dependent => :delete_all, :include => :author
42 42 has_many :issue_categories, :dependent => :delete_all, :order => "#{IssueCategory.table_name}.name"
43 43 has_many :boards, :dependent => :destroy, :order => "position ASC"
44 44 has_one :repository, :dependent => :destroy
45 45 has_many :changesets, :through => :repository
46 46 has_one :wiki, :dependent => :destroy
47 47 # Custom field for the project issues
48 48 has_and_belongs_to_many :issue_custom_fields,
49 49 :class_name => 'IssueCustomField',
50 50 :order => "#{CustomField.table_name}.position",
51 51 :join_table => "#{table_name_prefix}custom_fields_projects#{table_name_suffix}",
52 52 :association_foreign_key => 'custom_field_id'
53 53
54 54 acts_as_nested_set :order => 'name'
55 55 acts_as_attachable :view_permission => :view_files,
56 56 :delete_permission => :manage_files
57 57
58 58 acts_as_customizable
59 59 acts_as_searchable :columns => ['name', 'identifier', 'description'], :project_key => 'id', :permission => nil
60 60 acts_as_event :title => Proc.new {|o| "#{l(:label_project)}: #{o.name}"},
61 61 :url => Proc.new {|o| {:controller => 'projects', :action => 'show', :id => o}},
62 62 :author => nil
63 63
64 64 attr_protected :status, :enabled_module_names
65 65
66 66 validates_presence_of :name, :identifier
67 67 validates_uniqueness_of :name, :identifier
68 68 validates_associated :repository, :wiki
69 69 validates_length_of :name, :maximum => 30
70 70 validates_length_of :homepage, :maximum => 255
71 71 validates_length_of :identifier, :in => 1..20
72 72 # donwcase letters, digits, dashes but not digits only
73 73 validates_format_of :identifier, :with => /^(?!\d+$)[a-z0-9\-]*$/, :if => Proc.new { |p| p.identifier_changed? }
74 74 # reserved words
75 75 validates_exclusion_of :identifier, :in => %w( new )
76 76
77 77 before_destroy :delete_all_members, :destroy_children
78 78
79 79 named_scope :has_module, lambda { |mod| { :conditions => ["#{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name=?)", mod.to_s] } }
80 80 named_scope :active, { :conditions => "#{Project.table_name}.status = #{STATUS_ACTIVE}"}
81 81 named_scope :all_public, { :conditions => { :is_public => true } }
82 82 named_scope :visible, lambda { { :conditions => Project.visible_by(User.current) } }
83 83
84 84 def identifier=(identifier)
85 85 super unless identifier_frozen?
86 86 end
87 87
88 88 def identifier_frozen?
89 89 errors[:identifier].nil? && !(new_record? || identifier.blank?)
90 90 end
91 91
92 92 # returns latest created projects
93 93 # non public projects will be returned only if user is a member of those
94 94 def self.latest(user=nil, count=5)
95 95 find(:all, :limit => count, :conditions => visible_by(user), :order => "created_on DESC")
96 96 end
97 97
98 98 # Returns a SQL :conditions string used to find all active projects for the specified user.
99 99 #
100 100 # Examples:
101 101 # Projects.visible_by(admin) => "projects.status = 1"
102 102 # Projects.visible_by(normal_user) => "projects.status = 1 AND projects.is_public = 1"
103 103 def self.visible_by(user=nil)
104 104 user ||= User.current
105 105 if user && user.admin?
106 106 return "#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"
107 107 elsif user && user.memberships.any?
108 108 return "#{Project.table_name}.status=#{Project::STATUS_ACTIVE} AND (#{Project.table_name}.is_public = #{connection.quoted_true} or #{Project.table_name}.id IN (#{user.memberships.collect{|m| m.project_id}.join(',')}))"
109 109 else
110 110 return "#{Project.table_name}.status=#{Project::STATUS_ACTIVE} AND #{Project.table_name}.is_public = #{connection.quoted_true}"
111 111 end
112 112 end
113 113
114 114 def self.allowed_to_condition(user, permission, options={})
115 115 statements = []
116 116 base_statement = "#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"
117 117 if perm = Redmine::AccessControl.permission(permission)
118 118 unless perm.project_module.nil?
119 119 # If the permission belongs to a project module, make sure the module is enabled
120 120 base_statement << " AND #{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name='#{perm.project_module}')"
121 121 end
122 122 end
123 123 if options[:project]
124 124 project_statement = "#{Project.table_name}.id = #{options[:project].id}"
125 125 project_statement << " OR (#{Project.table_name}.lft > #{options[:project].lft} AND #{Project.table_name}.rgt < #{options[:project].rgt})" if options[:with_subprojects]
126 126 base_statement = "(#{project_statement}) AND (#{base_statement})"
127 127 end
128 128 if user.admin?
129 129 # no restriction
130 130 else
131 131 statements << "1=0"
132 132 if user.logged?
133 133 if Role.non_member.allowed_to?(permission) && !options[:member]
134 134 statements << "#{Project.table_name}.is_public = #{connection.quoted_true}"
135 135 end
136 136 allowed_project_ids = user.memberships.select {|m| m.roles.detect {|role| role.allowed_to?(permission)}}.collect {|m| m.project_id}
137 137 statements << "#{Project.table_name}.id IN (#{allowed_project_ids.join(',')})" if allowed_project_ids.any?
138 138 else
139 139 if Role.anonymous.allowed_to?(permission) && !options[:member]
140 140 # anonymous user allowed on public project
141 141 statements << "#{Project.table_name}.is_public = #{connection.quoted_true}"
142 142 end
143 143 end
144 144 end
145 145 statements.empty? ? base_statement : "((#{base_statement}) AND (#{statements.join(' OR ')}))"
146 146 end
147 147
148 148 # Returns the Systemwide and project specific activities
149 149 def activities(include_inactive=false)
150 150 if include_inactive
151 151 return all_activities
152 152 else
153 153 return active_activities
154 154 end
155 155 end
156 156
157 157 # Will create a new Project specific Activity or update an existing one
158 158 #
159 159 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
160 160 # does not successfully save.
161 161 def update_or_create_time_entry_activity(id, activity_hash)
162 162 if activity_hash.respond_to?(:has_key?) && activity_hash.has_key?('parent_id')
163 163 self.create_time_entry_activity_if_needed(activity_hash)
164 164 else
165 165 activity = project.time_entry_activities.find_by_id(id.to_i)
166 166 activity.update_attributes(activity_hash) if activity
167 167 end
168 168 end
169 169
170 170 # Create a new TimeEntryActivity if it overrides a system TimeEntryActivity
171 171 #
172 172 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
173 173 # does not successfully save.
174 174 def create_time_entry_activity_if_needed(activity)
175 175 if activity['parent_id']
176 176
177 177 parent_activity = TimeEntryActivity.find(activity['parent_id'])
178 178 activity['name'] = parent_activity.name
179 179 activity['position'] = parent_activity.position
180 180
181 181 if Enumeration.overridding_change?(activity, parent_activity)
182 182 project_activity = self.time_entry_activities.create(activity)
183 183
184 184 if project_activity.new_record?
185 185 raise ActiveRecord::Rollback, "Overridding TimeEntryActivity was not successfully saved"
186 186 else
187 187 self.time_entries.update_all("activity_id = #{project_activity.id}", ["activity_id = ?", parent_activity.id])
188 188 end
189 189 end
190 190 end
191 191 end
192 192
193 193 # Returns a :conditions SQL string that can be used to find the issues associated with this project.
194 194 #
195 195 # Examples:
196 196 # project.project_condition(true) => "(projects.id = 1 OR (projects.lft > 1 AND projects.rgt < 10))"
197 197 # project.project_condition(false) => "projects.id = 1"
198 198 def project_condition(with_subprojects)
199 199 cond = "#{Project.table_name}.id = #{id}"
200 200 cond = "(#{cond} OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt}))" if with_subprojects
201 201 cond
202 202 end
203 203
204 204 def self.find(*args)
205 205 if args.first && args.first.is_a?(String) && !args.first.match(/^\d*$/)
206 206 project = find_by_identifier(*args)
207 207 raise ActiveRecord::RecordNotFound, "Couldn't find Project with identifier=#{args.first}" if project.nil?
208 208 project
209 209 else
210 210 super
211 211 end
212 212 end
213 213
214 214 def to_param
215 215 # id is used for projects with a numeric identifier (compatibility)
216 216 @to_param ||= (identifier.to_s =~ %r{^\d*$} ? id : identifier)
217 217 end
218 218
219 219 def active?
220 220 self.status == STATUS_ACTIVE
221 221 end
222 222
223 223 # Archives the project and its descendants
224 224 def archive
225 225 # Check that there is no issue of a non descendant project that is assigned
226 226 # to one of the project or descendant versions
227 227 v_ids = self_and_descendants.collect {|p| p.version_ids}.flatten
228 228 if v_ids.any? && Issue.find(:first, :include => :project,
229 229 :conditions => ["(#{Project.table_name}.lft < ? OR #{Project.table_name}.rgt > ?)" +
230 230 " AND #{Issue.table_name}.fixed_version_id IN (?)", lft, rgt, v_ids])
231 231 return false
232 232 end
233 233 Project.transaction do
234 234 archive!
235 235 end
236 236 true
237 237 end
238 238
239 239 # Unarchives the project
240 240 # All its ancestors must be active
241 241 def unarchive
242 242 return false if ancestors.detect {|a| !a.active?}
243 243 update_attribute :status, STATUS_ACTIVE
244 244 end
245 245
246 246 # Returns an array of projects the project can be moved to
247 247 # by the current user
248 248 def allowed_parents
249 249 return @allowed_parents if @allowed_parents
250 250 @allowed_parents = Project.find(:all, :conditions => Project.allowed_to_condition(User.current, :add_subprojects))
251 251 @allowed_parents = @allowed_parents - self_and_descendants
252 252 if User.current.allowed_to?(:add_project, nil, :global => true) || (!new_record? && parent.nil?)
253 253 @allowed_parents << nil
254 254 end
255 255 unless parent.nil? || @allowed_parents.empty? || @allowed_parents.include?(parent)
256 256 @allowed_parents << parent
257 257 end
258 258 @allowed_parents
259 259 end
260 260
261 261 # Sets the parent of the project with authorization check
262 262 def set_allowed_parent!(p)
263 263 unless p.nil? || p.is_a?(Project)
264 264 if p.to_s.blank?
265 265 p = nil
266 266 else
267 267 p = Project.find_by_id(p)
268 268 return false unless p
269 269 end
270 270 end
271 271 if p.nil?
272 272 if !new_record? && allowed_parents.empty?
273 273 return false
274 274 end
275 275 elsif !allowed_parents.include?(p)
276 276 return false
277 277 end
278 278 set_parent!(p)
279 279 end
280 280
281 281 # Sets the parent of the project
282 282 # Argument can be either a Project, a String, a Fixnum or nil
283 283 def set_parent!(p)
284 284 unless p.nil? || p.is_a?(Project)
285 285 if p.to_s.blank?
286 286 p = nil
287 287 else
288 288 p = Project.find_by_id(p)
289 289 return false unless p
290 290 end
291 291 end
292 292 if p == parent && !p.nil?
293 293 # Nothing to do
294 294 true
295 295 elsif p.nil? || (p.active? && move_possible?(p))
296 296 # Insert the project so that target's children or root projects stay alphabetically sorted
297 297 sibs = (p.nil? ? self.class.roots : p.children)
298 298 to_be_inserted_before = sibs.detect {|c| c.name.to_s.downcase > name.to_s.downcase }
299 299 if to_be_inserted_before
300 300 move_to_left_of(to_be_inserted_before)
301 301 elsif p.nil?
302 302 if sibs.empty?
303 303 # move_to_root adds the project in first (ie. left) position
304 304 move_to_root
305 305 else
306 306 move_to_right_of(sibs.last) unless self == sibs.last
307 307 end
308 308 else
309 309 # move_to_child_of adds the project in last (ie.right) position
310 310 move_to_child_of(p)
311 311 end
312 312 Issue.update_versions_from_hierarchy_change(self)
313 313 true
314 314 else
315 315 # Can not move to the given target
316 316 false
317 317 end
318 318 end
319 319
320 320 # Returns an array of the trackers used by the project and its active sub projects
321 321 def rolled_up_trackers
322 322 @rolled_up_trackers ||=
323 323 Tracker.find(:all, :include => :projects,
324 324 :select => "DISTINCT #{Tracker.table_name}.*",
325 325 :conditions => ["#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ? AND #{Project.table_name}.status = #{STATUS_ACTIVE}", lft, rgt],
326 326 :order => "#{Tracker.table_name}.position")
327 327 end
328 328
329 329 # Closes open and locked project versions that are completed
330 330 def close_completed_versions
331 331 Version.transaction do
332 332 versions.find(:all, :conditions => {:status => %w(open locked)}).each do |version|
333 333 if version.completed?
334 334 version.update_attribute(:status, 'closed')
335 335 end
336 336 end
337 337 end
338 338 end
339 339
340 340 # Returns a scope of the Versions on subprojects
341 341 def rolled_up_versions
342 342 @rolled_up_versions ||=
343 343 Version.scoped(:include => :project,
344 344 :conditions => ["#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ? AND #{Project.table_name}.status = #{STATUS_ACTIVE}", lft, rgt])
345 345 end
346 346
347 347 # Returns a scope of the Versions used by the project
348 348 def shared_versions
349 349 @shared_versions ||=
350 350 Version.scoped(:include => :project,
351 351 :conditions => "#{Project.table_name}.id = #{id}" +
352 352 " OR (#{Project.table_name}.status = #{Project::STATUS_ACTIVE} AND (" +
353 353 " #{Version.table_name}.sharing = 'system'" +
354 354 " OR (#{Project.table_name}.lft >= #{root.lft} AND #{Project.table_name}.rgt <= #{root.rgt} AND #{Version.table_name}.sharing = 'tree')" +
355 355 " OR (#{Project.table_name}.lft < #{lft} AND #{Project.table_name}.rgt > #{rgt} AND #{Version.table_name}.sharing IN ('hierarchy', 'descendants'))" +
356 356 " OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt} AND #{Version.table_name}.sharing = 'hierarchy')" +
357 357 "))")
358 358 end
359 359
360 360 # Returns a hash of project users grouped by role
361 361 def users_by_role
362 362 members.find(:all, :include => [:user, :roles]).inject({}) do |h, m|
363 363 m.roles.each do |r|
364 364 h[r] ||= []
365 365 h[r] << m.user
366 366 end
367 367 h
368 368 end
369 369 end
370 370
371 371 # Deletes all project's members
372 372 def delete_all_members
373 373 me, mr = Member.table_name, MemberRole.table_name
374 374 connection.delete("DELETE FROM #{mr} WHERE #{mr}.member_id IN (SELECT #{me}.id FROM #{me} WHERE #{me}.project_id = #{id})")
375 375 Member.delete_all(['project_id = ?', id])
376 376 end
377 377
378 378 # Users issues can be assigned to
379 379 def assignable_users
380 380 members.select {|m| m.roles.detect {|role| role.assignable?}}.collect {|m| m.user}.sort
381 381 end
382 382
383 383 # Returns the mail adresses of users that should be always notified on project events
384 384 def recipients
385 385 notified_users.collect {|user| user.mail}
386 386 end
387 387
388 388 # Returns the users that should be notified on project events
389 389 def notified_users
390 390 # TODO: User part should be extracted to User#notify_about?
391 391 members.select {|m| m.mail_notification? || m.user.mail_notification == 'all'}.collect {|m| m.user}
392 392 end
393 393
394 394 # Returns an array of all custom fields enabled for project issues
395 395 # (explictly associated custom fields and custom fields enabled for all projects)
396 396 def all_issue_custom_fields
397 397 @all_issue_custom_fields ||= (IssueCustomField.for_all + issue_custom_fields).uniq.sort
398 398 end
399 399
400 400 def project
401 401 self
402 402 end
403 403
404 404 def <=>(project)
405 405 name.downcase <=> project.name.downcase
406 406 end
407 407
408 408 def to_s
409 409 name
410 410 end
411 411
412 412 # Returns a short description of the projects (first lines)
413 413 def short_description(length = 255)
414 414 description.gsub(/^(.{#{length}}[^\n\r]*).*$/m, '\1...').strip if description
415 415 end
416 416
417 417 def css_classes
418 418 s = 'project'
419 419 s << ' root' if root?
420 420 s << ' child' if child?
421 421 s << (leaf? ? ' leaf' : ' parent')
422 422 s
423 423 end
424 424
425 425 # The earliest start date of a project, based on it's issues and versions
426 426 def start_date
427 427 if module_enabled?(:issue_tracking)
428 428 [
429 429 issues.minimum('start_date'),
430 430 shared_versions.collect(&:effective_date),
431 431 shared_versions.collect {|v| v.fixed_issues.minimum('start_date')}
432 432 ].flatten.compact.min
433 433 end
434 434 end
435 435
436 436 # The latest due date of an issue or version
437 437 def due_date
438 438 if module_enabled?(:issue_tracking)
439 439 [
440 440 issues.maximum('due_date'),
441 441 shared_versions.collect(&:effective_date),
442 442 shared_versions.collect {|v| v.fixed_issues.maximum('due_date')}
443 443 ].flatten.compact.max
444 444 end
445 445 end
446 446
447 447 def overdue?
448 448 active? && !due_date.nil? && (due_date < Date.today)
449 449 end
450 450
451 451 # Returns the percent completed for this project, based on the
452 452 # progress on it's versions.
453 453 def completed_percent(options={:include_subprojects => false})
454 454 if options.delete(:include_subprojects)
455 455 total = self_and_descendants.collect(&:completed_percent).sum
456 456
457 457 total / self_and_descendants.count
458 458 else
459 459 if versions.count > 0
460 460 total = versions.collect(&:completed_pourcent).sum
461 461
462 462 total / versions.count
463 463 else
464 464 100
465 465 end
466 466 end
467 467 end
468 468
469 469 # Return true if this project is allowed to do the specified action.
470 470 # action can be:
471 471 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
472 472 # * a permission Symbol (eg. :edit_project)
473 473 def allows_to?(action)
474 474 if action.is_a? Hash
475 475 allowed_actions.include? "#{action[:controller]}/#{action[:action]}"
476 476 else
477 477 allowed_permissions.include? action
478 478 end
479 479 end
480 480
481 481 def module_enabled?(module_name)
482 482 module_name = module_name.to_s
483 483 enabled_modules.detect {|m| m.name == module_name}
484 484 end
485 485
486 486 def enabled_module_names=(module_names)
487 487 if module_names && module_names.is_a?(Array)
488 488 module_names = module_names.collect(&:to_s)
489 489 # remove disabled modules
490 490 enabled_modules.each {|mod| mod.destroy unless module_names.include?(mod.name)}
491 491 # add new modules
492 492 module_names.reject {|name| module_enabled?(name)}.each {|name| enabled_modules << EnabledModule.new(:name => name)}
493 493 else
494 494 enabled_modules.clear
495 495 end
496 496 end
497 497
498 498 # Returns an array of projects that are in this project's hierarchy
499 499 #
500 500 # Example: parents, children, siblings
501 501 def hierarchy
502 502 parents = project.self_and_ancestors || []
503 503 descendants = project.descendants || []
504 504 project_hierarchy = parents | descendants # Set union
505 505 end
506 506
507 507 # Returns an auto-generated project identifier based on the last identifier used
508 508 def self.next_identifier
509 509 p = Project.find(:first, :order => 'created_on DESC')
510 510 p.nil? ? nil : p.identifier.to_s.succ
511 511 end
512 512
513 513 # Copies and saves the Project instance based on the +project+.
514 514 # Duplicates the source project's:
515 515 # * Wiki
516 516 # * Versions
517 517 # * Categories
518 518 # * Issues
519 519 # * Members
520 520 # * Queries
521 521 #
522 522 # Accepts an +options+ argument to specify what to copy
523 523 #
524 524 # Examples:
525 525 # project.copy(1) # => copies everything
526 526 # project.copy(1, :only => 'members') # => copies members only
527 527 # project.copy(1, :only => ['members', 'versions']) # => copies members and versions
528 528 def copy(project, options={})
529 529 project = project.is_a?(Project) ? project : Project.find(project)
530 530
531 531 to_be_copied = %w(wiki versions issue_categories issues members queries boards)
532 532 to_be_copied = to_be_copied & options[:only].to_a unless options[:only].nil?
533 533
534 534 Project.transaction do
535 535 if save
536 536 reload
537 537 to_be_copied.each do |name|
538 538 send "copy_#{name}", project
539 539 end
540 540 Redmine::Hook.call_hook(:model_project_copy_before_save, :source_project => project, :destination_project => self)
541 541 save
542 542 end
543 543 end
544 544 end
545 545
546 546
547 547 # Copies +project+ and returns the new instance. This will not save
548 548 # the copy
549 549 def self.copy_from(project)
550 550 begin
551 551 project = project.is_a?(Project) ? project : Project.find(project)
552 552 if project
553 553 # clear unique attributes
554 554 attributes = project.attributes.dup.except('id', 'name', 'identifier', 'status', 'parent_id', 'lft', 'rgt')
555 555 copy = Project.new(attributes)
556 556 copy.enabled_modules = project.enabled_modules
557 557 copy.trackers = project.trackers
558 558 copy.custom_values = project.custom_values.collect {|v| v.clone}
559 559 copy.issue_custom_fields = project.issue_custom_fields
560 560 return copy
561 561 else
562 562 return nil
563 563 end
564 564 rescue ActiveRecord::RecordNotFound
565 565 return nil
566 566 end
567 567 end
568 568
569 # Yields the given block for each project with its level in the tree
570 def self.project_tree(projects, &block)
571 ancestors = []
572 projects.sort_by(&:lft).each do |project|
573 while (ancestors.any? && !project.is_descendant_of?(ancestors.last))
574 ancestors.pop
575 end
576 yield project, ancestors.size
577 ancestors << project
578 end
579 end
580
569 581 private
570 582
571 583 # Destroys children before destroying self
572 584 def destroy_children
573 585 children.each do |child|
574 586 child.destroy
575 587 end
576 588 end
577 589
578 590 # Copies wiki from +project+
579 591 def copy_wiki(project)
580 592 # Check that the source project has a wiki first
581 593 unless project.wiki.nil?
582 594 self.wiki ||= Wiki.new
583 595 wiki.attributes = project.wiki.attributes.dup.except("id", "project_id")
584 596 wiki_pages_map = {}
585 597 project.wiki.pages.each do |page|
586 598 # Skip pages without content
587 599 next if page.content.nil?
588 600 new_wiki_content = WikiContent.new(page.content.attributes.dup.except("id", "page_id", "updated_on"))
589 601 new_wiki_page = WikiPage.new(page.attributes.dup.except("id", "wiki_id", "created_on", "parent_id"))
590 602 new_wiki_page.content = new_wiki_content
591 603 wiki.pages << new_wiki_page
592 604 wiki_pages_map[page.id] = new_wiki_page
593 605 end
594 606 wiki.save
595 607 # Reproduce page hierarchy
596 608 project.wiki.pages.each do |page|
597 609 if page.parent_id && wiki_pages_map[page.id]
598 610 wiki_pages_map[page.id].parent = wiki_pages_map[page.parent_id]
599 611 wiki_pages_map[page.id].save
600 612 end
601 613 end
602 614 end
603 615 end
604 616
605 617 # Copies versions from +project+
606 618 def copy_versions(project)
607 619 project.versions.each do |version|
608 620 new_version = Version.new
609 621 new_version.attributes = version.attributes.dup.except("id", "project_id", "created_on", "updated_on")
610 622 self.versions << new_version
611 623 end
612 624 end
613 625
614 626 # Copies issue categories from +project+
615 627 def copy_issue_categories(project)
616 628 project.issue_categories.each do |issue_category|
617 629 new_issue_category = IssueCategory.new
618 630 new_issue_category.attributes = issue_category.attributes.dup.except("id", "project_id")
619 631 self.issue_categories << new_issue_category
620 632 end
621 633 end
622 634
623 635 # Copies issues from +project+
624 636 def copy_issues(project)
625 637 # Stores the source issue id as a key and the copied issues as the
626 638 # value. Used to map the two togeather for issue relations.
627 639 issues_map = {}
628 640
629 641 # Get issues sorted by root_id, lft so that parent issues
630 642 # get copied before their children
631 643 project.issues.find(:all, :order => 'root_id, lft').each do |issue|
632 644 new_issue = Issue.new
633 645 new_issue.copy_from(issue)
634 646 new_issue.project = self
635 647 # Reassign fixed_versions by name, since names are unique per
636 648 # project and the versions for self are not yet saved
637 649 if issue.fixed_version
638 650 new_issue.fixed_version = self.versions.select {|v| v.name == issue.fixed_version.name}.first
639 651 end
640 652 # Reassign the category by name, since names are unique per
641 653 # project and the categories for self are not yet saved
642 654 if issue.category
643 655 new_issue.category = self.issue_categories.select {|c| c.name == issue.category.name}.first
644 656 end
645 657 # Parent issue
646 658 if issue.parent_id
647 659 if copied_parent = issues_map[issue.parent_id]
648 660 new_issue.parent_issue_id = copied_parent.id
649 661 end
650 662 end
651 663
652 664 self.issues << new_issue
653 665 issues_map[issue.id] = new_issue
654 666 end
655 667
656 668 # Relations after in case issues related each other
657 669 project.issues.each do |issue|
658 670 new_issue = issues_map[issue.id]
659 671
660 672 # Relations
661 673 issue.relations_from.each do |source_relation|
662 674 new_issue_relation = IssueRelation.new
663 675 new_issue_relation.attributes = source_relation.attributes.dup.except("id", "issue_from_id", "issue_to_id")
664 676 new_issue_relation.issue_to = issues_map[source_relation.issue_to_id]
665 677 if new_issue_relation.issue_to.nil? && Setting.cross_project_issue_relations?
666 678 new_issue_relation.issue_to = source_relation.issue_to
667 679 end
668 680 new_issue.relations_from << new_issue_relation
669 681 end
670 682
671 683 issue.relations_to.each do |source_relation|
672 684 new_issue_relation = IssueRelation.new
673 685 new_issue_relation.attributes = source_relation.attributes.dup.except("id", "issue_from_id", "issue_to_id")
674 686 new_issue_relation.issue_from = issues_map[source_relation.issue_from_id]
675 687 if new_issue_relation.issue_from.nil? && Setting.cross_project_issue_relations?
676 688 new_issue_relation.issue_from = source_relation.issue_from
677 689 end
678 690 new_issue.relations_to << new_issue_relation
679 691 end
680 692 end
681 693 end
682 694
683 695 # Copies members from +project+
684 696 def copy_members(project)
685 697 project.memberships.each do |member|
686 698 new_member = Member.new
687 699 new_member.attributes = member.attributes.dup.except("id", "project_id", "created_on")
688 700 # only copy non inherited roles
689 701 # inherited roles will be added when copying the group membership
690 702 role_ids = member.member_roles.reject(&:inherited?).collect(&:role_id)
691 703 next if role_ids.empty?
692 704 new_member.role_ids = role_ids
693 705 new_member.project = self
694 706 self.members << new_member
695 707 end
696 708 end
697 709
698 710 # Copies queries from +project+
699 711 def copy_queries(project)
700 712 project.queries.each do |query|
701 713 new_query = Query.new
702 714 new_query.attributes = query.attributes.dup.except("id", "project_id", "sort_criteria")
703 715 new_query.sort_criteria = query.sort_criteria if query.sort_criteria
704 716 new_query.project = self
705 717 self.queries << new_query
706 718 end
707 719 end
708 720
709 721 # Copies boards from +project+
710 722 def copy_boards(project)
711 723 project.boards.each do |board|
712 724 new_board = Board.new
713 725 new_board.attributes = board.attributes.dup.except("id", "project_id", "topics_count", "messages_count", "last_message_id")
714 726 new_board.project = self
715 727 self.boards << new_board
716 728 end
717 729 end
718 730
719 731 def allowed_permissions
720 732 @allowed_permissions ||= begin
721 733 module_names = enabled_modules.all(:select => :name).collect {|m| m.name}
722 734 Redmine::AccessControl.modules_permissions(module_names).collect {|p| p.name}
723 735 end
724 736 end
725 737
726 738 def allowed_actions
727 739 @actions_allowed ||= allowed_permissions.inject([]) { |actions, permission| actions += Redmine::AccessControl.allowed_actions(permission) }.flatten
728 740 end
729 741
730 742 # Returns all the active Systemwide and project specific activities
731 743 def active_activities
732 744 overridden_activity_ids = self.time_entry_activities.collect(&:parent_id)
733 745
734 746 if overridden_activity_ids.empty?
735 747 return TimeEntryActivity.shared.active
736 748 else
737 749 return system_activities_and_project_overrides
738 750 end
739 751 end
740 752
741 753 # Returns all the Systemwide and project specific activities
742 754 # (inactive and active)
743 755 def all_activities
744 756 overridden_activity_ids = self.time_entry_activities.collect(&:parent_id)
745 757
746 758 if overridden_activity_ids.empty?
747 759 return TimeEntryActivity.shared
748 760 else
749 761 return system_activities_and_project_overrides(true)
750 762 end
751 763 end
752 764
753 765 # Returns the systemwide active activities merged with the project specific overrides
754 766 def system_activities_and_project_overrides(include_inactive=false)
755 767 if include_inactive
756 768 return TimeEntryActivity.shared.
757 769 find(:all,
758 770 :conditions => ["id NOT IN (?)", self.time_entry_activities.collect(&:parent_id)]) +
759 771 self.time_entry_activities
760 772 else
761 773 return TimeEntryActivity.shared.active.
762 774 find(:all,
763 775 :conditions => ["id NOT IN (?)", self.time_entry_activities.collect(&:parent_id)]) +
764 776 self.time_entry_activities.active
765 777 end
766 778 end
767 779
768 780 # Archives subprojects recursively
769 781 def archive!
770 782 children.each do |subproject|
771 783 subproject.send :archive!
772 784 end
773 785 update_attribute :status, STATUS_ARCHIVED
774 786 end
775 787 end
General Comments 0
You need to be logged in to leave comments. Login now