##// END OF EJS Templates
Deprecates Version#*_pourcent in favour of #*_percent (#12724)....
Jean-Philippe Lang -
r10883:9613a13b10aa
parent child
Show More
@@ -1,1234 +1,1234
1 1 # encoding: utf-8
2 2 #
3 3 # Redmine - project management software
4 4 # Copyright (C) 2006-2012 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 = truncate(issue.subject, :length => 60)
76 76 else
77 77 subject = issue.subject
78 78 if options[:truncate]
79 79 subject = truncate(subject, :length => options[:truncate])
80 80 end
81 81 end
82 82 s = link_to text, issue_path(issue), :class => issue.css_classes, :title => title
83 83 s << h(": #{subject}") if subject
84 84 s = h("#{issue.project} - ") + s if options[:project]
85 85 s
86 86 end
87 87
88 88 # Generates a link to an attachment.
89 89 # Options:
90 90 # * :text - Link text (default to attachment filename)
91 91 # * :download - Force download (default: false)
92 92 def link_to_attachment(attachment, options={})
93 93 text = options.delete(:text) || attachment.filename
94 94 action = options.delete(:download) ? 'download' : 'show'
95 95 opt_only_path = {}
96 96 opt_only_path[:only_path] = (options[:only_path] == false ? false : true)
97 97 options.delete(:only_path)
98 98 link_to(h(text),
99 99 {:controller => 'attachments', :action => action,
100 100 :id => attachment, :filename => attachment.filename}.merge(opt_only_path),
101 101 options)
102 102 end
103 103
104 104 # Generates a link to a SCM revision
105 105 # Options:
106 106 # * :text - Link text (default to the formatted revision)
107 107 def link_to_revision(revision, repository, options={})
108 108 if repository.is_a?(Project)
109 109 repository = repository.repository
110 110 end
111 111 text = options.delete(:text) || format_revision(revision)
112 112 rev = revision.respond_to?(:identifier) ? revision.identifier : revision
113 113 link_to(
114 114 h(text),
115 115 {:controller => 'repositories', :action => 'revision', :id => repository.project, :repository_id => repository.identifier_param, :rev => rev},
116 116 :title => l(:label_revision_id, format_revision(revision))
117 117 )
118 118 end
119 119
120 120 # Generates a link to a message
121 121 def link_to_message(message, options={}, html_options = nil)
122 122 link_to(
123 123 h(truncate(message.subject, :length => 60)),
124 124 { :controller => 'messages', :action => 'show',
125 125 :board_id => message.board_id,
126 126 :id => (message.parent_id || message.id),
127 127 :r => (message.parent_id && message.id),
128 128 :anchor => (message.parent_id ? "message-#{message.id}" : nil)
129 129 }.merge(options),
130 130 html_options
131 131 )
132 132 end
133 133
134 134 # Generates a link to a project if active
135 135 # Examples:
136 136 #
137 137 # link_to_project(project) # => link to the specified project overview
138 138 # link_to_project(project, :action=>'settings') # => link to project settings
139 139 # link_to_project(project, {:only_path => false}, :class => "project") # => 3rd arg adds html options
140 140 # link_to_project(project, {}, :class => "project") # => html options with default url (project overview)
141 141 #
142 142 def link_to_project(project, options={}, html_options = nil)
143 143 if project.archived?
144 144 h(project)
145 145 else
146 146 url = {:controller => 'projects', :action => 'show', :id => project}.merge(options)
147 147 link_to(h(project), url, html_options)
148 148 end
149 149 end
150 150
151 151 def wiki_page_path(page, options={})
152 152 url_for({:controller => 'wiki', :action => 'show', :project_id => page.project, :id => page.title}.merge(options))
153 153 end
154 154
155 155 def thumbnail_tag(attachment)
156 156 link_to image_tag(url_for(:controller => 'attachments', :action => 'thumbnail', :id => attachment)),
157 157 {:controller => 'attachments', :action => 'show', :id => attachment, :filename => attachment.filename},
158 158 :title => attachment.filename
159 159 end
160 160
161 161 def toggle_link(name, id, options={})
162 162 onclick = "$('##{id}').toggle(); "
163 163 onclick << (options[:focus] ? "$('##{options[:focus]}').focus(); " : "this.blur(); ")
164 164 onclick << "return false;"
165 165 link_to(name, "#", :onclick => onclick)
166 166 end
167 167
168 168 def image_to_function(name, function, html_options = {})
169 169 html_options.symbolize_keys!
170 170 tag(:input, html_options.merge({
171 171 :type => "image", :src => image_path(name),
172 172 :onclick => (html_options[:onclick] ? "#{html_options[:onclick]}; " : "") + "#{function};"
173 173 }))
174 174 end
175 175
176 176 def format_activity_title(text)
177 177 h(truncate_single_line(text, :length => 100))
178 178 end
179 179
180 180 def format_activity_day(date)
181 181 date == User.current.today ? l(:label_today).titleize : format_date(date)
182 182 end
183 183
184 184 def format_activity_description(text)
185 185 h(truncate(text.to_s, :length => 120).gsub(%r{[\r\n]*<(pre|code)>.*$}m, '...')
186 186 ).gsub(/[\r\n]+/, "<br />").html_safe
187 187 end
188 188
189 189 def format_version_name(version)
190 190 if version.project == @project
191 191 h(version)
192 192 else
193 193 h("#{version.project} - #{version}")
194 194 end
195 195 end
196 196
197 197 def due_date_distance_in_words(date)
198 198 if date
199 199 l((date < Date.today ? :label_roadmap_overdue : :label_roadmap_due_in), distance_of_date_in_words(Date.today, date))
200 200 end
201 201 end
202 202
203 203 # Renders a tree of projects as a nested set of unordered lists
204 204 # The given collection may be a subset of the whole project tree
205 205 # (eg. some intermediate nodes are private and can not be seen)
206 206 def render_project_nested_lists(projects)
207 207 s = ''
208 208 if projects.any?
209 209 ancestors = []
210 210 original_project = @project
211 211 projects.sort_by(&:lft).each do |project|
212 212 # set the project environment to please macros.
213 213 @project = project
214 214 if (ancestors.empty? || project.is_descendant_of?(ancestors.last))
215 215 s << "<ul class='projects #{ ancestors.empty? ? 'root' : nil}'>\n"
216 216 else
217 217 ancestors.pop
218 218 s << "</li>"
219 219 while (ancestors.any? && !project.is_descendant_of?(ancestors.last))
220 220 ancestors.pop
221 221 s << "</ul></li>\n"
222 222 end
223 223 end
224 224 classes = (ancestors.empty? ? 'root' : 'child')
225 225 s << "<li class='#{classes}'><div class='#{classes}'>"
226 226 s << h(block_given? ? yield(project) : project.name)
227 227 s << "</div>\n"
228 228 ancestors << project
229 229 end
230 230 s << ("</li></ul>\n" * ancestors.size)
231 231 @project = original_project
232 232 end
233 233 s.html_safe
234 234 end
235 235
236 236 def render_page_hierarchy(pages, node=nil, options={})
237 237 content = ''
238 238 if pages[node]
239 239 content << "<ul class=\"pages-hierarchy\">\n"
240 240 pages[node].each do |page|
241 241 content << "<li>"
242 242 content << link_to(h(page.pretty_title), {:controller => 'wiki', :action => 'show', :project_id => page.project, :id => page.title, :version => nil},
243 243 :title => (options[:timestamp] && page.updated_on ? l(:label_updated_time, distance_of_time_in_words(Time.now, page.updated_on)) : nil))
244 244 content << "\n" + render_page_hierarchy(pages, page.id, options) if pages[page.id]
245 245 content << "</li>\n"
246 246 end
247 247 content << "</ul>\n"
248 248 end
249 249 content.html_safe
250 250 end
251 251
252 252 # Renders flash messages
253 253 def render_flash_messages
254 254 s = ''
255 255 flash.each do |k,v|
256 256 s << content_tag('div', v.html_safe, :class => "flash #{k}", :id => "flash_#{k}")
257 257 end
258 258 s.html_safe
259 259 end
260 260
261 261 # Renders tabs and their content
262 262 def render_tabs(tabs)
263 263 if tabs.any?
264 264 render :partial => 'common/tabs', :locals => {:tabs => tabs}
265 265 else
266 266 content_tag 'p', l(:label_no_data), :class => "nodata"
267 267 end
268 268 end
269 269
270 270 # Renders the project quick-jump box
271 271 def render_project_jump_box
272 272 return unless User.current.logged?
273 273 projects = User.current.memberships.collect(&:project).compact.select(&:active?).uniq
274 274 if projects.any?
275 275 options =
276 276 ("<option value=''>#{ l(:label_jump_to_a_project) }</option>" +
277 277 '<option value="" disabled="disabled">---</option>').html_safe
278 278
279 279 options << project_tree_options_for_select(projects, :selected => @project) do |p|
280 280 { :value => project_path(:id => p, :jump => current_menu_item) }
281 281 end
282 282
283 283 select_tag('project_quick_jump_box', options, :onchange => 'if (this.value != \'\') { window.location = this.value; }')
284 284 end
285 285 end
286 286
287 287 def project_tree_options_for_select(projects, options = {})
288 288 s = ''
289 289 project_tree(projects) do |project, level|
290 290 name_prefix = (level > 0 ? '&nbsp;' * 2 * level + '&#187; ' : '').html_safe
291 291 tag_options = {:value => project.id}
292 292 if project == options[:selected] || (options[:selected].respond_to?(:include?) && options[:selected].include?(project))
293 293 tag_options[:selected] = 'selected'
294 294 else
295 295 tag_options[:selected] = nil
296 296 end
297 297 tag_options.merge!(yield(project)) if block_given?
298 298 s << content_tag('option', name_prefix + h(project), tag_options)
299 299 end
300 300 s.html_safe
301 301 end
302 302
303 303 # Yields the given block for each project with its level in the tree
304 304 #
305 305 # Wrapper for Project#project_tree
306 306 def project_tree(projects, &block)
307 307 Project.project_tree(projects, &block)
308 308 end
309 309
310 310 def principals_check_box_tags(name, principals)
311 311 s = ''
312 312 principals.sort.each do |principal|
313 313 s << "<label>#{ check_box_tag name, principal.id, false } #{h principal}</label>\n"
314 314 end
315 315 s.html_safe
316 316 end
317 317
318 318 # Returns a string for users/groups option tags
319 319 def principals_options_for_select(collection, selected=nil)
320 320 s = ''
321 321 if collection.include?(User.current)
322 322 s << content_tag('option', "<< #{l(:label_me)} >>", :value => User.current.id)
323 323 end
324 324 groups = ''
325 325 collection.sort.each do |element|
326 326 selected_attribute = ' selected="selected"' if option_value_selected?(element, selected)
327 327 (element.is_a?(Group) ? groups : s) << %(<option value="#{element.id}"#{selected_attribute}>#{h element.name}</option>)
328 328 end
329 329 unless groups.empty?
330 330 s << %(<optgroup label="#{h(l(:label_group_plural))}">#{groups}</optgroup>)
331 331 end
332 332 s.html_safe
333 333 end
334 334
335 335 # Options for the new membership projects combo-box
336 336 def options_for_membership_project_select(principal, projects)
337 337 options = content_tag('option', "--- #{l(:actionview_instancetag_blank_option)} ---")
338 338 options << project_tree_options_for_select(projects) do |p|
339 339 {:disabled => principal.projects.include?(p)}
340 340 end
341 341 options
342 342 end
343 343
344 344 # Truncates and returns the string as a single line
345 345 def truncate_single_line(string, *args)
346 346 truncate(string.to_s, *args).gsub(%r{[\r\n]+}m, ' ')
347 347 end
348 348
349 349 # Truncates at line break after 250 characters or options[:length]
350 350 def truncate_lines(string, options={})
351 351 length = options[:length] || 250
352 352 if string.to_s =~ /\A(.{#{length}}.*?)$/m
353 353 "#{$1}..."
354 354 else
355 355 string
356 356 end
357 357 end
358 358
359 359 def anchor(text)
360 360 text.to_s.gsub(' ', '_')
361 361 end
362 362
363 363 def html_hours(text)
364 364 text.gsub(%r{(\d+)\.(\d+)}, '<span class="hours hours-int">\1</span><span class="hours hours-dec">.\2</span>').html_safe
365 365 end
366 366
367 367 def authoring(created, author, options={})
368 368 l(options[:label] || :label_added_time_by, :author => link_to_user(author), :age => time_tag(created)).html_safe
369 369 end
370 370
371 371 def time_tag(time)
372 372 text = distance_of_time_in_words(Time.now, time)
373 373 if @project
374 374 link_to(text, {:controller => 'activities', :action => 'index', :id => @project, :from => User.current.time_to_date(time)}, :title => format_time(time))
375 375 else
376 376 content_tag('acronym', text, :title => format_time(time))
377 377 end
378 378 end
379 379
380 380 def syntax_highlight_lines(name, content)
381 381 lines = []
382 382 syntax_highlight(name, content).each_line { |line| lines << line }
383 383 lines
384 384 end
385 385
386 386 def syntax_highlight(name, content)
387 387 Redmine::SyntaxHighlighting.highlight_by_filename(content, name)
388 388 end
389 389
390 390 def to_path_param(path)
391 391 str = path.to_s.split(%r{[/\\]}).select{|p| !p.blank?}.join("/")
392 392 str.blank? ? nil : str
393 393 end
394 394
395 395 def reorder_links(name, url, method = :post)
396 396 link_to(image_tag('2uparrow.png', :alt => l(:label_sort_highest)),
397 397 url.merge({"#{name}[move_to]" => 'highest'}),
398 398 :method => method, :title => l(:label_sort_highest)) +
399 399 link_to(image_tag('1uparrow.png', :alt => l(:label_sort_higher)),
400 400 url.merge({"#{name}[move_to]" => 'higher'}),
401 401 :method => method, :title => l(:label_sort_higher)) +
402 402 link_to(image_tag('1downarrow.png', :alt => l(:label_sort_lower)),
403 403 url.merge({"#{name}[move_to]" => 'lower'}),
404 404 :method => method, :title => l(:label_sort_lower)) +
405 405 link_to(image_tag('2downarrow.png', :alt => l(:label_sort_lowest)),
406 406 url.merge({"#{name}[move_to]" => 'lowest'}),
407 407 :method => method, :title => l(:label_sort_lowest))
408 408 end
409 409
410 410 def breadcrumb(*args)
411 411 elements = args.flatten
412 412 elements.any? ? content_tag('p', (args.join(" \xc2\xbb ") + " \xc2\xbb ").html_safe, :class => 'breadcrumb') : nil
413 413 end
414 414
415 415 def other_formats_links(&block)
416 416 concat('<p class="other-formats">'.html_safe + l(:label_export_to))
417 417 yield Redmine::Views::OtherFormatsBuilder.new(self)
418 418 concat('</p>'.html_safe)
419 419 end
420 420
421 421 def page_header_title
422 422 if @project.nil? || @project.new_record?
423 423 h(Setting.app_title)
424 424 else
425 425 b = []
426 426 ancestors = (@project.root? ? [] : @project.ancestors.visible.all)
427 427 if ancestors.any?
428 428 root = ancestors.shift
429 429 b << link_to_project(root, {:jump => current_menu_item}, :class => 'root')
430 430 if ancestors.size > 2
431 431 b << "\xe2\x80\xa6"
432 432 ancestors = ancestors[-2, 2]
433 433 end
434 434 b += ancestors.collect {|p| link_to_project(p, {:jump => current_menu_item}, :class => 'ancestor') }
435 435 end
436 436 b << h(@project)
437 437 b.join(" \xc2\xbb ").html_safe
438 438 end
439 439 end
440 440
441 441 def html_title(*args)
442 442 if args.empty?
443 443 title = @html_title || []
444 444 title << @project.name if @project
445 445 title << Setting.app_title unless Setting.app_title == title.last
446 446 title.select {|t| !t.blank? }.join(' - ')
447 447 else
448 448 @html_title ||= []
449 449 @html_title += args
450 450 end
451 451 end
452 452
453 453 # Returns the theme, controller name, and action as css classes for the
454 454 # HTML body.
455 455 def body_css_classes
456 456 css = []
457 457 if theme = Redmine::Themes.theme(Setting.ui_theme)
458 458 css << 'theme-' + theme.name
459 459 end
460 460
461 461 css << 'controller-' + controller_name
462 462 css << 'action-' + action_name
463 463 css.join(' ')
464 464 end
465 465
466 466 def accesskey(s)
467 467 Redmine::AccessKeys.key_for s
468 468 end
469 469
470 470 # Formats text according to system settings.
471 471 # 2 ways to call this method:
472 472 # * with a String: textilizable(text, options)
473 473 # * with an object and one of its attribute: textilizable(issue, :description, options)
474 474 def textilizable(*args)
475 475 options = args.last.is_a?(Hash) ? args.pop : {}
476 476 case args.size
477 477 when 1
478 478 obj = options[:object]
479 479 text = args.shift
480 480 when 2
481 481 obj = args.shift
482 482 attr = args.shift
483 483 text = obj.send(attr).to_s
484 484 else
485 485 raise ArgumentError, 'invalid arguments to textilizable'
486 486 end
487 487 return '' if text.blank?
488 488 project = options[:project] || @project || (obj && obj.respond_to?(:project) ? obj.project : nil)
489 489 only_path = options.delete(:only_path) == false ? false : true
490 490
491 491 text = text.dup
492 492 macros = catch_macros(text)
493 493 text = Redmine::WikiFormatting.to_html(Setting.text_formatting, text, :object => obj, :attribute => attr)
494 494
495 495 @parsed_headings = []
496 496 @heading_anchors = {}
497 497 @current_section = 0 if options[:edit_section_links]
498 498
499 499 parse_sections(text, project, obj, attr, only_path, options)
500 500 text = parse_non_pre_blocks(text, obj, macros) do |text|
501 501 [:parse_inline_attachments, :parse_wiki_links, :parse_redmine_links].each do |method_name|
502 502 send method_name, text, project, obj, attr, only_path, options
503 503 end
504 504 end
505 505 parse_headings(text, project, obj, attr, only_path, options)
506 506
507 507 if @parsed_headings.any?
508 508 replace_toc(text, @parsed_headings)
509 509 end
510 510
511 511 text.html_safe
512 512 end
513 513
514 514 def parse_non_pre_blocks(text, obj, macros)
515 515 s = StringScanner.new(text)
516 516 tags = []
517 517 parsed = ''
518 518 while !s.eos?
519 519 s.scan(/(.*?)(<(\/)?(pre|code)(.*?)>|\z)/im)
520 520 text, full_tag, closing, tag = s[1], s[2], s[3], s[4]
521 521 if tags.empty?
522 522 yield text
523 523 inject_macros(text, obj, macros) if macros.any?
524 524 else
525 525 inject_macros(text, obj, macros, false) if macros.any?
526 526 end
527 527 parsed << text
528 528 if tag
529 529 if closing
530 530 if tags.last == tag.downcase
531 531 tags.pop
532 532 end
533 533 else
534 534 tags << tag.downcase
535 535 end
536 536 parsed << full_tag
537 537 end
538 538 end
539 539 # Close any non closing tags
540 540 while tag = tags.pop
541 541 parsed << "</#{tag}>"
542 542 end
543 543 parsed
544 544 end
545 545
546 546 def parse_inline_attachments(text, project, obj, attr, only_path, options)
547 547 # when using an image link, try to use an attachment, if possible
548 548 if options[:attachments].present? || (obj && obj.respond_to?(:attachments))
549 549 attachments = options[:attachments] || []
550 550 attachments += obj.attachments if obj
551 551 text.gsub!(/src="([^\/"]+\.(bmp|gif|jpg|jpe|jpeg|png))"(\s+alt="([^"]*)")?/i) do |m|
552 552 filename, ext, alt, alttext = $1.downcase, $2, $3, $4
553 553 # search for the picture in attachments
554 554 if found = Attachment.latest_attach(attachments, filename)
555 555 image_url = url_for :only_path => only_path, :controller => 'attachments',
556 556 :action => 'download', :id => found
557 557 desc = found.description.to_s.gsub('"', '')
558 558 if !desc.blank? && alttext.blank?
559 559 alt = " title=\"#{desc}\" alt=\"#{desc}\""
560 560 end
561 561 "src=\"#{image_url}\"#{alt}"
562 562 else
563 563 m
564 564 end
565 565 end
566 566 end
567 567 end
568 568
569 569 # Wiki links
570 570 #
571 571 # Examples:
572 572 # [[mypage]]
573 573 # [[mypage|mytext]]
574 574 # wiki links can refer other project wikis, using project name or identifier:
575 575 # [[project:]] -> wiki starting page
576 576 # [[project:|mytext]]
577 577 # [[project:mypage]]
578 578 # [[project:mypage|mytext]]
579 579 def parse_wiki_links(text, project, obj, attr, only_path, options)
580 580 text.gsub!(/(!)?(\[\[([^\]\n\|]+)(\|([^\]\n\|]+))?\]\])/) do |m|
581 581 link_project = project
582 582 esc, all, page, title = $1, $2, $3, $5
583 583 if esc.nil?
584 584 if page =~ /^([^\:]+)\:(.*)$/
585 585 link_project = Project.find_by_identifier($1) || Project.find_by_name($1)
586 586 page = $2
587 587 title ||= $1 if page.blank?
588 588 end
589 589
590 590 if link_project && link_project.wiki
591 591 # extract anchor
592 592 anchor = nil
593 593 if page =~ /^(.+?)\#(.+)$/
594 594 page, anchor = $1, $2
595 595 end
596 596 anchor = sanitize_anchor_name(anchor) if anchor.present?
597 597 # check if page exists
598 598 wiki_page = link_project.wiki.find_page(page)
599 599 url = if anchor.present? && wiki_page.present? && (obj.is_a?(WikiContent) || obj.is_a?(WikiContent::Version)) && obj.page == wiki_page
600 600 "##{anchor}"
601 601 else
602 602 case options[:wiki_links]
603 603 when :local; "#{page.present? ? Wiki.titleize(page) : ''}.html" + (anchor.present? ? "##{anchor}" : '')
604 604 when :anchor; "##{page.present? ? Wiki.titleize(page) : title}" + (anchor.present? ? "_#{anchor}" : '') # used for single-file wiki export
605 605 else
606 606 wiki_page_id = page.present? ? Wiki.titleize(page) : nil
607 607 parent = wiki_page.nil? && obj.is_a?(WikiContent) && obj.page && project == link_project ? obj.page.title : nil
608 608 url_for(:only_path => only_path, :controller => 'wiki', :action => 'show', :project_id => link_project,
609 609 :id => wiki_page_id, :version => nil, :anchor => anchor, :parent => parent)
610 610 end
611 611 end
612 612 link_to(title.present? ? title.html_safe : h(page), url, :class => ('wiki-page' + (wiki_page ? '' : ' new')))
613 613 else
614 614 # project or wiki doesn't exist
615 615 all
616 616 end
617 617 else
618 618 all
619 619 end
620 620 end
621 621 end
622 622
623 623 # Redmine links
624 624 #
625 625 # Examples:
626 626 # Issues:
627 627 # #52 -> Link to issue #52
628 628 # Changesets:
629 629 # r52 -> Link to revision 52
630 630 # commit:a85130f -> Link to scmid starting with a85130f
631 631 # Documents:
632 632 # document#17 -> Link to document with id 17
633 633 # document:Greetings -> Link to the document with title "Greetings"
634 634 # document:"Some document" -> Link to the document with title "Some document"
635 635 # Versions:
636 636 # version#3 -> Link to version with id 3
637 637 # version:1.0.0 -> Link to version named "1.0.0"
638 638 # version:"1.0 beta 2" -> Link to version named "1.0 beta 2"
639 639 # Attachments:
640 640 # attachment:file.zip -> Link to the attachment of the current object named file.zip
641 641 # Source files:
642 642 # source:some/file -> Link to the file located at /some/file in the project's repository
643 643 # source:some/file@52 -> Link to the file's revision 52
644 644 # source:some/file#L120 -> Link to line 120 of the file
645 645 # source:some/file@52#L120 -> Link to line 120 of the file's revision 52
646 646 # export:some/file -> Force the download of the file
647 647 # Forum messages:
648 648 # message#1218 -> Link to message with id 1218
649 649 #
650 650 # Links can refer other objects from other projects, using project identifier:
651 651 # identifier:r52
652 652 # identifier:document:"Some document"
653 653 # identifier:version:1.0.0
654 654 # identifier:source:some/file
655 655 def parse_redmine_links(text, project, obj, attr, only_path, options)
656 656 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|
657 657 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
658 658 link = nil
659 659 if project_identifier
660 660 project = Project.visible.find_by_identifier(project_identifier)
661 661 end
662 662 if esc.nil?
663 663 if prefix.nil? && sep == 'r'
664 664 if project
665 665 repository = nil
666 666 if repo_identifier
667 667 repository = project.repositories.detect {|repo| repo.identifier == repo_identifier}
668 668 else
669 669 repository = project.repository
670 670 end
671 671 # project.changesets.visible raises an SQL error because of a double join on repositories
672 672 if repository && (changeset = Changeset.visible.find_by_repository_id_and_revision(repository.id, identifier))
673 673 link = link_to(h("#{project_prefix}#{repo_prefix}r#{identifier}"), {:only_path => only_path, :controller => 'repositories', :action => 'revision', :id => project, :repository_id => repository.identifier_param, :rev => changeset.revision},
674 674 :class => 'changeset',
675 675 :title => truncate_single_line(changeset.comments, :length => 100))
676 676 end
677 677 end
678 678 elsif sep == '#'
679 679 oid = identifier.to_i
680 680 case prefix
681 681 when nil
682 682 if oid.to_s == identifier && issue = Issue.visible.find_by_id(oid, :include => :status)
683 683 anchor = comment_id ? "note-#{comment_id}" : nil
684 684 link = link_to("##{oid}", {:only_path => only_path, :controller => 'issues', :action => 'show', :id => oid, :anchor => anchor},
685 685 :class => issue.css_classes,
686 686 :title => "#{truncate(issue.subject, :length => 100)} (#{issue.status.name})")
687 687 end
688 688 when 'document'
689 689 if document = Document.visible.find_by_id(oid)
690 690 link = link_to h(document.title), {:only_path => only_path, :controller => 'documents', :action => 'show', :id => document},
691 691 :class => 'document'
692 692 end
693 693 when 'version'
694 694 if version = Version.visible.find_by_id(oid)
695 695 link = link_to h(version.name), {:only_path => only_path, :controller => 'versions', :action => 'show', :id => version},
696 696 :class => 'version'
697 697 end
698 698 when 'message'
699 699 if message = Message.visible.find_by_id(oid, :include => :parent)
700 700 link = link_to_message(message, {:only_path => only_path}, :class => 'message')
701 701 end
702 702 when 'forum'
703 703 if board = Board.visible.find_by_id(oid)
704 704 link = link_to h(board.name), {:only_path => only_path, :controller => 'boards', :action => 'show', :id => board, :project_id => board.project},
705 705 :class => 'board'
706 706 end
707 707 when 'news'
708 708 if news = News.visible.find_by_id(oid)
709 709 link = link_to h(news.title), {:only_path => only_path, :controller => 'news', :action => 'show', :id => news},
710 710 :class => 'news'
711 711 end
712 712 when 'project'
713 713 if p = Project.visible.find_by_id(oid)
714 714 link = link_to_project(p, {:only_path => only_path}, :class => 'project')
715 715 end
716 716 end
717 717 elsif sep == ':'
718 718 # removes the double quotes if any
719 719 name = identifier.gsub(%r{^"(.*)"$}, "\\1")
720 720 case prefix
721 721 when 'document'
722 722 if project && document = project.documents.visible.find_by_title(name)
723 723 link = link_to h(document.title), {:only_path => only_path, :controller => 'documents', :action => 'show', :id => document},
724 724 :class => 'document'
725 725 end
726 726 when 'version'
727 727 if project && version = project.versions.visible.find_by_name(name)
728 728 link = link_to h(version.name), {:only_path => only_path, :controller => 'versions', :action => 'show', :id => version},
729 729 :class => 'version'
730 730 end
731 731 when 'forum'
732 732 if project && board = project.boards.visible.find_by_name(name)
733 733 link = link_to h(board.name), {:only_path => only_path, :controller => 'boards', :action => 'show', :id => board, :project_id => board.project},
734 734 :class => 'board'
735 735 end
736 736 when 'news'
737 737 if project && news = project.news.visible.find_by_title(name)
738 738 link = link_to h(news.title), {:only_path => only_path, :controller => 'news', :action => 'show', :id => news},
739 739 :class => 'news'
740 740 end
741 741 when 'commit', 'source', 'export'
742 742 if project
743 743 repository = nil
744 744 if name =~ %r{^(([a-z0-9\-]+)\|)(.+)$}
745 745 repo_prefix, repo_identifier, name = $1, $2, $3
746 746 repository = project.repositories.detect {|repo| repo.identifier == repo_identifier}
747 747 else
748 748 repository = project.repository
749 749 end
750 750 if prefix == 'commit'
751 751 if repository && (changeset = Changeset.visible.where("repository_id = ? AND scmid LIKE ?", repository.id, "#{name}%").first)
752 752 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},
753 753 :class => 'changeset',
754 754 :title => truncate_single_line(h(changeset.comments), :length => 100)
755 755 end
756 756 else
757 757 if repository && User.current.allowed_to?(:browse_repository, project)
758 758 name =~ %r{^[/\\]*(.*?)(@([^/\\@]+?))?(#(L\d+))?$}
759 759 path, rev, anchor = $1, $3, $5
760 760 link = link_to h("#{project_prefix}#{prefix}:#{repo_prefix}#{name}"), {:controller => 'repositories', :action => (prefix == 'export' ? 'raw' : 'entry'), :id => project, :repository_id => repository.identifier_param,
761 761 :path => to_path_param(path),
762 762 :rev => rev,
763 763 :anchor => anchor},
764 764 :class => (prefix == 'export' ? 'source download' : 'source')
765 765 end
766 766 end
767 767 repo_prefix = nil
768 768 end
769 769 when 'attachment'
770 770 attachments = options[:attachments] || (obj && obj.respond_to?(:attachments) ? obj.attachments : nil)
771 771 if attachments && attachment = attachments.detect {|a| a.filename == name }
772 772 link = link_to h(attachment.filename), {:only_path => only_path, :controller => 'attachments', :action => 'download', :id => attachment},
773 773 :class => 'attachment'
774 774 end
775 775 when 'project'
776 776 if p = Project.visible.where("identifier = :s OR LOWER(name) = :s", :s => name.downcase).first
777 777 link = link_to_project(p, {:only_path => only_path}, :class => 'project')
778 778 end
779 779 end
780 780 end
781 781 end
782 782 (leading + (link || "#{project_prefix}#{prefix}#{repo_prefix}#{sep}#{identifier}#{comment_suffix}"))
783 783 end
784 784 end
785 785
786 786 HEADING_RE = /(<h(\d)( [^>]+)?>(.+?)<\/h(\d)>)/i unless const_defined?(:HEADING_RE)
787 787
788 788 def parse_sections(text, project, obj, attr, only_path, options)
789 789 return unless options[:edit_section_links]
790 790 text.gsub!(HEADING_RE) do
791 791 heading = $1
792 792 @current_section += 1
793 793 if @current_section > 1
794 794 content_tag('div',
795 795 link_to(image_tag('edit.png'), options[:edit_section_links].merge(:section => @current_section)),
796 796 :class => 'contextual',
797 797 :title => l(:button_edit_section)) + heading.html_safe
798 798 else
799 799 heading
800 800 end
801 801 end
802 802 end
803 803
804 804 # Headings and TOC
805 805 # Adds ids and links to headings unless options[:headings] is set to false
806 806 def parse_headings(text, project, obj, attr, only_path, options)
807 807 return if options[:headings] == false
808 808
809 809 text.gsub!(HEADING_RE) do
810 810 level, attrs, content = $2.to_i, $3, $4
811 811 item = strip_tags(content).strip
812 812 anchor = sanitize_anchor_name(item)
813 813 # used for single-file wiki export
814 814 anchor = "#{obj.page.title}_#{anchor}" if options[:wiki_links] == :anchor && (obj.is_a?(WikiContent) || obj.is_a?(WikiContent::Version))
815 815 @heading_anchors[anchor] ||= 0
816 816 idx = (@heading_anchors[anchor] += 1)
817 817 if idx > 1
818 818 anchor = "#{anchor}-#{idx}"
819 819 end
820 820 @parsed_headings << [level, anchor, item]
821 821 "<a name=\"#{anchor}\"></a>\n<h#{level} #{attrs}>#{content}<a href=\"##{anchor}\" class=\"wiki-anchor\">&para;</a></h#{level}>"
822 822 end
823 823 end
824 824
825 825 MACROS_RE = /(
826 826 (!)? # escaping
827 827 (
828 828 \{\{ # opening tag
829 829 ([\w]+) # macro name
830 830 (\(([^\n\r]*?)\))? # optional arguments
831 831 ([\n\r].*?[\n\r])? # optional block of text
832 832 \}\} # closing tag
833 833 )
834 834 )/mx unless const_defined?(:MACROS_RE)
835 835
836 836 MACRO_SUB_RE = /(
837 837 \{\{
838 838 macro\((\d+)\)
839 839 \}\}
840 840 )/x unless const_defined?(:MACRO_SUB_RE)
841 841
842 842 # Extracts macros from text
843 843 def catch_macros(text)
844 844 macros = {}
845 845 text.gsub!(MACROS_RE) do
846 846 all, macro = $1, $4.downcase
847 847 if macro_exists?(macro) || all =~ MACRO_SUB_RE
848 848 index = macros.size
849 849 macros[index] = all
850 850 "{{macro(#{index})}}"
851 851 else
852 852 all
853 853 end
854 854 end
855 855 macros
856 856 end
857 857
858 858 # Executes and replaces macros in text
859 859 def inject_macros(text, obj, macros, execute=true)
860 860 text.gsub!(MACRO_SUB_RE) do
861 861 all, index = $1, $2.to_i
862 862 orig = macros.delete(index)
863 863 if execute && orig && orig =~ MACROS_RE
864 864 esc, all, macro, args, block = $2, $3, $4.downcase, $6.to_s, $7.try(:strip)
865 865 if esc.nil?
866 866 h(exec_macro(macro, obj, args, block) || all)
867 867 else
868 868 h(all)
869 869 end
870 870 elsif orig
871 871 h(orig)
872 872 else
873 873 h(all)
874 874 end
875 875 end
876 876 end
877 877
878 878 TOC_RE = /<p>\{\{([<>]?)toc\}\}<\/p>/i unless const_defined?(:TOC_RE)
879 879
880 880 # Renders the TOC with given headings
881 881 def replace_toc(text, headings)
882 882 text.gsub!(TOC_RE) do
883 883 # Keep only the 4 first levels
884 884 headings = headings.select{|level, anchor, item| level <= 4}
885 885 if headings.empty?
886 886 ''
887 887 else
888 888 div_class = 'toc'
889 889 div_class << ' right' if $1 == '>'
890 890 div_class << ' left' if $1 == '<'
891 891 out = "<ul class=\"#{div_class}\"><li>"
892 892 root = headings.map(&:first).min
893 893 current = root
894 894 started = false
895 895 headings.each do |level, anchor, item|
896 896 if level > current
897 897 out << '<ul><li>' * (level - current)
898 898 elsif level < current
899 899 out << "</li></ul>\n" * (current - level) + "</li><li>"
900 900 elsif started
901 901 out << '</li><li>'
902 902 end
903 903 out << "<a href=\"##{anchor}\">#{item}</a>"
904 904 current = level
905 905 started = true
906 906 end
907 907 out << '</li></ul>' * (current - root)
908 908 out << '</li></ul>'
909 909 end
910 910 end
911 911 end
912 912
913 913 # Same as Rails' simple_format helper without using paragraphs
914 914 def simple_format_without_paragraph(text)
915 915 text.to_s.
916 916 gsub(/\r\n?/, "\n"). # \r\n and \r -> \n
917 917 gsub(/\n\n+/, "<br /><br />"). # 2+ newline -> 2 br
918 918 gsub(/([^\n]\n)(?=[^\n])/, '\1<br />'). # 1 newline -> br
919 919 html_safe
920 920 end
921 921
922 922 def lang_options_for_select(blank=true)
923 923 (blank ? [["(auto)", ""]] : []) + languages_options
924 924 end
925 925
926 926 def label_tag_for(name, option_tags = nil, options = {})
927 927 label_text = l(("field_"+field.to_s.gsub(/\_id$/, "")).to_sym) + (options.delete(:required) ? @template.content_tag("span", " *", :class => "required"): "")
928 928 content_tag("label", label_text)
929 929 end
930 930
931 931 def labelled_form_for(*args, &proc)
932 932 args << {} unless args.last.is_a?(Hash)
933 933 options = args.last
934 934 if args.first.is_a?(Symbol)
935 935 options.merge!(:as => args.shift)
936 936 end
937 937 options.merge!({:builder => Redmine::Views::LabelledFormBuilder})
938 938 form_for(*args, &proc)
939 939 end
940 940
941 941 def labelled_fields_for(*args, &proc)
942 942 args << {} unless args.last.is_a?(Hash)
943 943 options = args.last
944 944 options.merge!({:builder => Redmine::Views::LabelledFormBuilder})
945 945 fields_for(*args, &proc)
946 946 end
947 947
948 948 def labelled_remote_form_for(*args, &proc)
949 949 ActiveSupport::Deprecation.warn "ApplicationHelper#labelled_remote_form_for is deprecated and will be removed in Redmine 2.2."
950 950 args << {} unless args.last.is_a?(Hash)
951 951 options = args.last
952 952 options.merge!({:builder => Redmine::Views::LabelledFormBuilder, :remote => true})
953 953 form_for(*args, &proc)
954 954 end
955 955
956 956 def error_messages_for(*objects)
957 957 html = ""
958 958 objects = objects.map {|o| o.is_a?(String) ? instance_variable_get("@#{o}") : o}.compact
959 959 errors = objects.map {|o| o.errors.full_messages}.flatten
960 960 if errors.any?
961 961 html << "<div id='errorExplanation'><ul>\n"
962 962 errors.each do |error|
963 963 html << "<li>#{h error}</li>\n"
964 964 end
965 965 html << "</ul></div>\n"
966 966 end
967 967 html.html_safe
968 968 end
969 969
970 970 def delete_link(url, options={})
971 971 options = {
972 972 :method => :delete,
973 973 :data => {:confirm => l(:text_are_you_sure)},
974 974 :class => 'icon icon-del'
975 975 }.merge(options)
976 976
977 977 link_to l(:button_delete), url, options
978 978 end
979 979
980 980 def preview_link(url, form, target='preview', options={})
981 981 content_tag 'a', l(:label_preview), {
982 982 :href => "#",
983 983 :onclick => %|submitPreview("#{escape_javascript url_for(url)}", "#{escape_javascript form}", "#{escape_javascript target}"); return false;|,
984 984 :accesskey => accesskey(:preview)
985 985 }.merge(options)
986 986 end
987 987
988 988 def link_to_function(name, function, html_options={})
989 989 content_tag(:a, name, {:href => '#', :onclick => "#{function}; return false;"}.merge(html_options))
990 990 end
991 991
992 992 # Helper to render JSON in views
993 993 def raw_json(arg)
994 994 arg.to_json.to_s.gsub('/', '\/').html_safe
995 995 end
996 996
997 997 def back_url
998 998 url = params[:back_url]
999 999 if url.nil? && referer = request.env['HTTP_REFERER']
1000 1000 url = CGI.unescape(referer.to_s)
1001 1001 end
1002 1002 url
1003 1003 end
1004 1004
1005 1005 def back_url_hidden_field_tag
1006 1006 url = back_url
1007 1007 hidden_field_tag('back_url', url, :id => nil) unless url.blank?
1008 1008 end
1009 1009
1010 1010 def check_all_links(form_name)
1011 1011 link_to_function(l(:button_check_all), "checkAll('#{form_name}', true)") +
1012 1012 " | ".html_safe +
1013 1013 link_to_function(l(:button_uncheck_all), "checkAll('#{form_name}', false)")
1014 1014 end
1015 1015
1016 1016 def progress_bar(pcts, options={})
1017 1017 pcts = [pcts, pcts] unless pcts.is_a?(Array)
1018 1018 pcts = pcts.collect(&:round)
1019 1019 pcts[1] = pcts[1] - pcts[0]
1020 1020 pcts << (100 - pcts[1] - pcts[0])
1021 1021 width = options[:width] || '100px;'
1022 1022 legend = options[:legend] || ''
1023 1023 content_tag('table',
1024 1024 content_tag('tr',
1025 1025 (pcts[0] > 0 ? content_tag('td', '', :style => "width: #{pcts[0]}%;", :class => 'closed') : ''.html_safe) +
1026 1026 (pcts[1] > 0 ? content_tag('td', '', :style => "width: #{pcts[1]}%;", :class => 'done') : ''.html_safe) +
1027 1027 (pcts[2] > 0 ? content_tag('td', '', :style => "width: #{pcts[2]}%;", :class => 'todo') : ''.html_safe)
1028 1028 ), :class => 'progress', :style => "width: #{width};").html_safe +
1029 content_tag('p', legend, :class => 'pourcent').html_safe
1029 content_tag('p', legend, :class => 'percent').html_safe
1030 1030 end
1031 1031
1032 1032 def checked_image(checked=true)
1033 1033 if checked
1034 1034 image_tag 'toggle_check.png'
1035 1035 end
1036 1036 end
1037 1037
1038 1038 def context_menu(url)
1039 1039 unless @context_menu_included
1040 1040 content_for :header_tags do
1041 1041 javascript_include_tag('context_menu') +
1042 1042 stylesheet_link_tag('context_menu')
1043 1043 end
1044 1044 if l(:direction) == 'rtl'
1045 1045 content_for :header_tags do
1046 1046 stylesheet_link_tag('context_menu_rtl')
1047 1047 end
1048 1048 end
1049 1049 @context_menu_included = true
1050 1050 end
1051 1051 javascript_tag "contextMenuInit('#{ url_for(url) }')"
1052 1052 end
1053 1053
1054 1054 def calendar_for(field_id)
1055 1055 include_calendar_headers_tags
1056 1056 javascript_tag("$(function() { $('##{field_id}').datepicker(datepickerOptions); });")
1057 1057 end
1058 1058
1059 1059 def include_calendar_headers_tags
1060 1060 unless @calendar_headers_tags_included
1061 1061 @calendar_headers_tags_included = true
1062 1062 content_for :header_tags do
1063 1063 start_of_week = Setting.start_of_week
1064 1064 start_of_week = l(:general_first_day_of_week, :default => '1') if start_of_week.blank?
1065 1065 # Redmine uses 1..7 (monday..sunday) in settings and locales
1066 1066 # JQuery uses 0..6 (sunday..saturday), 7 needs to be changed to 0
1067 1067 start_of_week = start_of_week.to_i % 7
1068 1068
1069 1069 tags = javascript_tag(
1070 1070 "var datepickerOptions={dateFormat: 'yy-mm-dd', firstDay: #{start_of_week}, " +
1071 1071 "showOn: 'button', buttonImageOnly: true, buttonImage: '" +
1072 1072 path_to_image('/images/calendar.png') +
1073 1073 "', showButtonPanel: true};")
1074 1074 jquery_locale = l('jquery.locale', :default => current_language.to_s)
1075 1075 unless jquery_locale == 'en'
1076 1076 tags << javascript_include_tag("i18n/jquery.ui.datepicker-#{jquery_locale}.js")
1077 1077 end
1078 1078 tags
1079 1079 end
1080 1080 end
1081 1081 end
1082 1082
1083 1083 # Overrides Rails' stylesheet_link_tag with themes and plugins support.
1084 1084 # Examples:
1085 1085 # stylesheet_link_tag('styles') # => picks styles.css from the current theme or defaults
1086 1086 # stylesheet_link_tag('styles', :plugin => 'foo) # => picks styles.css from plugin's assets
1087 1087 #
1088 1088 def stylesheet_link_tag(*sources)
1089 1089 options = sources.last.is_a?(Hash) ? sources.pop : {}
1090 1090 plugin = options.delete(:plugin)
1091 1091 sources = sources.map do |source|
1092 1092 if plugin
1093 1093 "/plugin_assets/#{plugin}/stylesheets/#{source}"
1094 1094 elsif current_theme && current_theme.stylesheets.include?(source)
1095 1095 current_theme.stylesheet_path(source)
1096 1096 else
1097 1097 source
1098 1098 end
1099 1099 end
1100 1100 super sources, options
1101 1101 end
1102 1102
1103 1103 # Overrides Rails' image_tag with themes and plugins support.
1104 1104 # Examples:
1105 1105 # image_tag('image.png') # => picks image.png from the current theme or defaults
1106 1106 # image_tag('image.png', :plugin => 'foo) # => picks image.png from plugin's assets
1107 1107 #
1108 1108 def image_tag(source, options={})
1109 1109 if plugin = options.delete(:plugin)
1110 1110 source = "/plugin_assets/#{plugin}/images/#{source}"
1111 1111 elsif current_theme && current_theme.images.include?(source)
1112 1112 source = current_theme.image_path(source)
1113 1113 end
1114 1114 super source, options
1115 1115 end
1116 1116
1117 1117 # Overrides Rails' javascript_include_tag with plugins support
1118 1118 # Examples:
1119 1119 # javascript_include_tag('scripts') # => picks scripts.js from defaults
1120 1120 # javascript_include_tag('scripts', :plugin => 'foo) # => picks scripts.js from plugin's assets
1121 1121 #
1122 1122 def javascript_include_tag(*sources)
1123 1123 options = sources.last.is_a?(Hash) ? sources.pop : {}
1124 1124 if plugin = options.delete(:plugin)
1125 1125 sources = sources.map do |source|
1126 1126 if plugin
1127 1127 "/plugin_assets/#{plugin}/javascripts/#{source}"
1128 1128 else
1129 1129 source
1130 1130 end
1131 1131 end
1132 1132 end
1133 1133 super sources, options
1134 1134 end
1135 1135
1136 1136 def content_for(name, content = nil, &block)
1137 1137 @has_content ||= {}
1138 1138 @has_content[name] = true
1139 1139 super(name, content, &block)
1140 1140 end
1141 1141
1142 1142 def has_content?(name)
1143 1143 (@has_content && @has_content[name]) || false
1144 1144 end
1145 1145
1146 1146 def sidebar_content?
1147 1147 has_content?(:sidebar) || view_layouts_base_sidebar_hook_response.present?
1148 1148 end
1149 1149
1150 1150 def view_layouts_base_sidebar_hook_response
1151 1151 @view_layouts_base_sidebar_hook_response ||= call_hook(:view_layouts_base_sidebar)
1152 1152 end
1153 1153
1154 1154 def email_delivery_enabled?
1155 1155 !!ActionMailer::Base.perform_deliveries
1156 1156 end
1157 1157
1158 1158 # Returns the avatar image tag for the given +user+ if avatars are enabled
1159 1159 # +user+ can be a User or a string that will be scanned for an email address (eg. 'joe <joe@foo.bar>')
1160 1160 def avatar(user, options = { })
1161 1161 if Setting.gravatar_enabled?
1162 1162 options.merge!({:ssl => (request && request.ssl?), :default => Setting.gravatar_default})
1163 1163 email = nil
1164 1164 if user.respond_to?(:mail)
1165 1165 email = user.mail
1166 1166 elsif user.to_s =~ %r{<(.+?)>}
1167 1167 email = $1
1168 1168 end
1169 1169 return gravatar(email.to_s.downcase, options) unless email.blank? rescue nil
1170 1170 else
1171 1171 ''
1172 1172 end
1173 1173 end
1174 1174
1175 1175 def sanitize_anchor_name(anchor)
1176 1176 if ''.respond_to?(:encoding) || RUBY_PLATFORM == 'java'
1177 1177 anchor.gsub(%r{[^\p{Word}\s\-]}, '').gsub(%r{\s+(\-+\s*)?}, '-')
1178 1178 else
1179 1179 # TODO: remove when ruby1.8 is no longer supported
1180 1180 anchor.gsub(%r{[^\w\s\-]}, '').gsub(%r{\s+(\-+\s*)?}, '-')
1181 1181 end
1182 1182 end
1183 1183
1184 1184 # Returns the javascript tags that are included in the html layout head
1185 1185 def javascript_heads
1186 1186 tags = javascript_include_tag('jquery-1.8.3-ui-1.9.2-ujs-2.0.3', 'application')
1187 1187 unless User.current.pref.warn_on_leaving_unsaved == '0'
1188 1188 tags << "\n".html_safe + javascript_tag("$(window).load(function(){ warnLeavingUnsaved('#{escape_javascript l(:text_warn_on_leaving_unsaved)}'); });")
1189 1189 end
1190 1190 tags
1191 1191 end
1192 1192
1193 1193 def favicon
1194 1194 "<link rel='shortcut icon' href='#{image_path('/favicon.ico')}' />".html_safe
1195 1195 end
1196 1196
1197 1197 def robot_exclusion_tag
1198 1198 '<meta name="robots" content="noindex,follow,noarchive" />'.html_safe
1199 1199 end
1200 1200
1201 1201 # Returns true if arg is expected in the API response
1202 1202 def include_in_api_response?(arg)
1203 1203 unless @included_in_api_response
1204 1204 param = params[:include]
1205 1205 @included_in_api_response = param.is_a?(Array) ? param.collect(&:to_s) : param.to_s.split(',')
1206 1206 @included_in_api_response.collect!(&:strip)
1207 1207 end
1208 1208 @included_in_api_response.include?(arg.to_s)
1209 1209 end
1210 1210
1211 1211 # Returns options or nil if nometa param or X-Redmine-Nometa header
1212 1212 # was set in the request
1213 1213 def api_meta(options)
1214 1214 if params[:nometa].present? || request.headers['X-Redmine-Nometa']
1215 1215 # compatibility mode for activeresource clients that raise
1216 1216 # an error when unserializing an array with attributes
1217 1217 nil
1218 1218 else
1219 1219 options
1220 1220 end
1221 1221 end
1222 1222
1223 1223 private
1224 1224
1225 1225 def wiki_helper
1226 1226 helper = Redmine::WikiFormatting.helper_for(Setting.text_formatting)
1227 1227 extend helper
1228 1228 return self
1229 1229 end
1230 1230
1231 1231 def link_to_content_update(text, url_params = {}, html_options = {})
1232 1232 link_to(text, url_params, html_options)
1233 1233 end
1234 1234 end
@@ -1,969 +1,969
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 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 overidden Activities
30 30 has_many :time_entry_activities
31 31 has_many :members, :include => [:principal, :roles], :conditions => "#{Principal.table_name}.type='User' AND #{Principal.table_name}.status=#{Principal::STATUS_ACTIVE}"
32 32 has_many :memberships, :class_name => 'Member'
33 33 has_many :member_principals, :class_name => 'Member',
34 34 :include => :principal,
35 35 :conditions => "#{Principal.table_name}.type='Group' OR (#{Principal.table_name}.type='User' AND #{Principal.table_name}.status=#{Principal::STATUS_ACTIVE})"
36 36 has_many :users, :through => :members
37 37 has_many :principals, :through => :member_principals, :source => :principal
38 38
39 39 has_many :enabled_modules, :dependent => :delete_all
40 40 has_and_belongs_to_many :trackers, :order => "#{Tracker.table_name}.position"
41 41 has_many :issues, :dependent => :destroy, :include => [:status, :tracker]
42 42 has_many :issue_changes, :through => :issues, :source => :journals
43 43 has_many :versions, :dependent => :destroy, :order => "#{Version.table_name}.effective_date DESC, #{Version.table_name}.name DESC"
44 44 has_many :time_entries, :dependent => :delete_all
45 45 has_many :queries, :class_name => 'IssueQuery', :dependent => :delete_all
46 46 has_many :documents, :dependent => :destroy
47 47 has_many :news, :dependent => :destroy, :include => :author
48 48 has_many :issue_categories, :dependent => :delete_all, :order => "#{IssueCategory.table_name}.name"
49 49 has_many :boards, :dependent => :destroy, :order => "position ASC"
50 50 has_one :repository, :conditions => ["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 :class_name => 'IssueCustomField',
57 57 :order => "#{CustomField.table_name}.position",
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 :order => 'name', :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 # donwcase 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 before_destroy :delete_all_members
86 86
87 87 scope :has_module, lambda {|mod|
88 88 where("#{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name=?)", mod.to_s)
89 89 }
90 90 scope :active, lambda { where(:status => STATUS_ACTIVE) }
91 91 scope :status, lambda {|arg| where(arg.blank? ? nil : {:status => arg.to_i}) }
92 92 scope :all_public, lambda { where(:is_public => true) }
93 93 scope :visible, lambda {|*args| where(Project.visible_condition(args.shift || User.current, *args)) }
94 94 scope :allowed_to, lambda {|*args|
95 95 user = User.current
96 96 permission = nil
97 97 if args.first.is_a?(Symbol)
98 98 permission = args.shift
99 99 else
100 100 user = args.shift
101 101 permission = args.shift
102 102 end
103 103 where(Project.allowed_to_condition(user, permission, *args))
104 104 }
105 105 scope :like, lambda {|arg|
106 106 if arg.blank?
107 107 where(nil)
108 108 else
109 109 pattern = "%#{arg.to_s.strip.downcase}%"
110 110 where("LOWER(identifier) LIKE :p OR LOWER(name) LIKE :p", :p => pattern)
111 111 end
112 112 }
113 113
114 114 def initialize(attributes=nil, *args)
115 115 super
116 116
117 117 initialized = (attributes || {}).stringify_keys
118 118 if !initialized.key?('identifier') && Setting.sequential_project_identifiers?
119 119 self.identifier = Project.next_identifier
120 120 end
121 121 if !initialized.key?('is_public')
122 122 self.is_public = Setting.default_projects_public?
123 123 end
124 124 if !initialized.key?('enabled_module_names')
125 125 self.enabled_module_names = Setting.default_projects_modules
126 126 end
127 127 if !initialized.key?('trackers') && !initialized.key?('tracker_ids')
128 128 self.trackers = Tracker.sorted.all
129 129 end
130 130 end
131 131
132 132 def identifier=(identifier)
133 133 super unless identifier_frozen?
134 134 end
135 135
136 136 def identifier_frozen?
137 137 errors[:identifier].blank? && !(new_record? || identifier.blank?)
138 138 end
139 139
140 140 # returns latest created projects
141 141 # non public projects will be returned only if user is a member of those
142 142 def self.latest(user=nil, count=5)
143 143 visible(user).limit(count).order("created_on DESC").all
144 144 end
145 145
146 146 # Returns true if the project is visible to +user+ or to the current user.
147 147 def visible?(user=User.current)
148 148 user.allowed_to?(:view_project, self)
149 149 end
150 150
151 151 # Returns a SQL conditions string used to find all projects visible by the specified user.
152 152 #
153 153 # Examples:
154 154 # Project.visible_condition(admin) => "projects.status = 1"
155 155 # Project.visible_condition(normal_user) => "((projects.status = 1) AND (projects.is_public = 1 OR projects.id IN (1,3,4)))"
156 156 # Project.visible_condition(anonymous) => "((projects.status = 1) AND (projects.is_public = 1))"
157 157 def self.visible_condition(user, options={})
158 158 allowed_to_condition(user, :view_project, options)
159 159 end
160 160
161 161 # Returns a SQL conditions string used to find all projects for which +user+ has the given +permission+
162 162 #
163 163 # Valid options:
164 164 # * :project => limit the condition to project
165 165 # * :with_subprojects => limit the condition to project and its subprojects
166 166 # * :member => limit the condition to the user projects
167 167 def self.allowed_to_condition(user, permission, options={})
168 168 perm = Redmine::AccessControl.permission(permission)
169 169 base_statement = (perm && perm.read? ? "#{Project.table_name}.status <> #{Project::STATUS_ARCHIVED}" : "#{Project.table_name}.status = #{Project::STATUS_ACTIVE}")
170 170 if perm && perm.project_module
171 171 # If the permission belongs to a project module, make sure the module is enabled
172 172 base_statement << " AND #{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name='#{perm.project_module}')"
173 173 end
174 174 if options[:project]
175 175 project_statement = "#{Project.table_name}.id = #{options[:project].id}"
176 176 project_statement << " OR (#{Project.table_name}.lft > #{options[:project].lft} AND #{Project.table_name}.rgt < #{options[:project].rgt})" if options[:with_subprojects]
177 177 base_statement = "(#{project_statement}) AND (#{base_statement})"
178 178 end
179 179
180 180 if user.admin?
181 181 base_statement
182 182 else
183 183 statement_by_role = {}
184 184 unless options[:member]
185 185 role = user.logged? ? Role.non_member : Role.anonymous
186 186 if role.allowed_to?(permission)
187 187 statement_by_role[role] = "#{Project.table_name}.is_public = #{connection.quoted_true}"
188 188 end
189 189 end
190 190 if user.logged?
191 191 user.projects_by_role.each do |role, projects|
192 192 if role.allowed_to?(permission) && projects.any?
193 193 statement_by_role[role] = "#{Project.table_name}.id IN (#{projects.collect(&:id).join(',')})"
194 194 end
195 195 end
196 196 end
197 197 if statement_by_role.empty?
198 198 "1=0"
199 199 else
200 200 if block_given?
201 201 statement_by_role.each do |role, statement|
202 202 if s = yield(role, user)
203 203 statement_by_role[role] = "(#{statement} AND (#{s}))"
204 204 end
205 205 end
206 206 end
207 207 "((#{base_statement}) AND (#{statement_by_role.values.join(' OR ')}))"
208 208 end
209 209 end
210 210 end
211 211
212 212 # Returns the Systemwide and project specific activities
213 213 def activities(include_inactive=false)
214 214 if include_inactive
215 215 return all_activities
216 216 else
217 217 return active_activities
218 218 end
219 219 end
220 220
221 221 # Will create a new Project specific Activity or update an existing one
222 222 #
223 223 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
224 224 # does not successfully save.
225 225 def update_or_create_time_entry_activity(id, activity_hash)
226 226 if activity_hash.respond_to?(:has_key?) && activity_hash.has_key?('parent_id')
227 227 self.create_time_entry_activity_if_needed(activity_hash)
228 228 else
229 229 activity = project.time_entry_activities.find_by_id(id.to_i)
230 230 activity.update_attributes(activity_hash) if activity
231 231 end
232 232 end
233 233
234 234 # Create a new TimeEntryActivity if it overrides a system TimeEntryActivity
235 235 #
236 236 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
237 237 # does not successfully save.
238 238 def create_time_entry_activity_if_needed(activity)
239 239 if activity['parent_id']
240 240
241 241 parent_activity = TimeEntryActivity.find(activity['parent_id'])
242 242 activity['name'] = parent_activity.name
243 243 activity['position'] = parent_activity.position
244 244
245 245 if Enumeration.overridding_change?(activity, parent_activity)
246 246 project_activity = self.time_entry_activities.create(activity)
247 247
248 248 if project_activity.new_record?
249 249 raise ActiveRecord::Rollback, "Overridding TimeEntryActivity was not successfully saved"
250 250 else
251 251 self.time_entries.update_all("activity_id = #{project_activity.id}", ["activity_id = ?", parent_activity.id])
252 252 end
253 253 end
254 254 end
255 255 end
256 256
257 257 # Returns a :conditions SQL string that can be used to find the issues associated with this project.
258 258 #
259 259 # Examples:
260 260 # project.project_condition(true) => "(projects.id = 1 OR (projects.lft > 1 AND projects.rgt < 10))"
261 261 # project.project_condition(false) => "projects.id = 1"
262 262 def project_condition(with_subprojects)
263 263 cond = "#{Project.table_name}.id = #{id}"
264 264 cond = "(#{cond} OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt}))" if with_subprojects
265 265 cond
266 266 end
267 267
268 268 def self.find(*args)
269 269 if args.first && args.first.is_a?(String) && !args.first.match(/^\d*$/)
270 270 project = find_by_identifier(*args)
271 271 raise ActiveRecord::RecordNotFound, "Couldn't find Project with identifier=#{args.first}" if project.nil?
272 272 project
273 273 else
274 274 super
275 275 end
276 276 end
277 277
278 278 def self.find_by_param(*args)
279 279 self.find(*args)
280 280 end
281 281
282 282 def reload(*args)
283 283 @shared_versions = nil
284 284 @rolled_up_versions = nil
285 285 @rolled_up_trackers = nil
286 286 @all_issue_custom_fields = nil
287 287 @all_time_entry_custom_fields = nil
288 288 @to_param = nil
289 289 @allowed_parents = nil
290 290 @allowed_permissions = nil
291 291 @actions_allowed = nil
292 292 super
293 293 end
294 294
295 295 def to_param
296 296 # id is used for projects with a numeric identifier (compatibility)
297 297 @to_param ||= (identifier.to_s =~ %r{^\d*$} ? id.to_s : identifier)
298 298 end
299 299
300 300 def active?
301 301 self.status == STATUS_ACTIVE
302 302 end
303 303
304 304 def archived?
305 305 self.status == STATUS_ARCHIVED
306 306 end
307 307
308 308 # Archives the project and its descendants
309 309 def archive
310 310 # Check that there is no issue of a non descendant project that is assigned
311 311 # to one of the project or descendant versions
312 312 v_ids = self_and_descendants.collect {|p| p.version_ids}.flatten
313 313 if v_ids.any? &&
314 314 Issue.
315 315 includes(:project).
316 316 where("#{Project.table_name}.lft < ? OR #{Project.table_name}.rgt > ?", lft, rgt).
317 317 where("#{Issue.table_name}.fixed_version_id IN (?)", v_ids).
318 318 exists?
319 319 return false
320 320 end
321 321 Project.transaction do
322 322 archive!
323 323 end
324 324 true
325 325 end
326 326
327 327 # Unarchives the project
328 328 # All its ancestors must be active
329 329 def unarchive
330 330 return false if ancestors.detect {|a| !a.active?}
331 331 update_attribute :status, STATUS_ACTIVE
332 332 end
333 333
334 334 def close
335 335 self_and_descendants.status(STATUS_ACTIVE).update_all :status => STATUS_CLOSED
336 336 end
337 337
338 338 def reopen
339 339 self_and_descendants.status(STATUS_CLOSED).update_all :status => STATUS_ACTIVE
340 340 end
341 341
342 342 # Returns an array of projects the project can be moved to
343 343 # by the current user
344 344 def allowed_parents
345 345 return @allowed_parents if @allowed_parents
346 346 @allowed_parents = Project.where(Project.allowed_to_condition(User.current, :add_subprojects)).all
347 347 @allowed_parents = @allowed_parents - self_and_descendants
348 348 if User.current.allowed_to?(:add_project, nil, :global => true) || (!new_record? && parent.nil?)
349 349 @allowed_parents << nil
350 350 end
351 351 unless parent.nil? || @allowed_parents.empty? || @allowed_parents.include?(parent)
352 352 @allowed_parents << parent
353 353 end
354 354 @allowed_parents
355 355 end
356 356
357 357 # Sets the parent of the project with authorization check
358 358 def set_allowed_parent!(p)
359 359 unless p.nil? || p.is_a?(Project)
360 360 if p.to_s.blank?
361 361 p = nil
362 362 else
363 363 p = Project.find_by_id(p)
364 364 return false unless p
365 365 end
366 366 end
367 367 if p.nil?
368 368 if !new_record? && allowed_parents.empty?
369 369 return false
370 370 end
371 371 elsif !allowed_parents.include?(p)
372 372 return false
373 373 end
374 374 set_parent!(p)
375 375 end
376 376
377 377 # Sets the parent of the project
378 378 # Argument can be either a Project, a String, a Fixnum or nil
379 379 def set_parent!(p)
380 380 unless p.nil? || p.is_a?(Project)
381 381 if p.to_s.blank?
382 382 p = nil
383 383 else
384 384 p = Project.find_by_id(p)
385 385 return false unless p
386 386 end
387 387 end
388 388 if p == parent && !p.nil?
389 389 # Nothing to do
390 390 true
391 391 elsif p.nil? || (p.active? && move_possible?(p))
392 392 set_or_update_position_under(p)
393 393 Issue.update_versions_from_hierarchy_change(self)
394 394 true
395 395 else
396 396 # Can not move to the given target
397 397 false
398 398 end
399 399 end
400 400
401 401 # Recalculates all lft and rgt values based on project names
402 402 # Unlike Project.rebuild!, these values are recalculated even if the tree "looks" valid
403 403 # Used in BuildProjectsTree migration
404 404 def self.rebuild_tree!
405 405 transaction do
406 406 update_all "lft = NULL, rgt = NULL"
407 407 rebuild!(false)
408 408 end
409 409 end
410 410
411 411 # Returns an array of the trackers used by the project and its active sub projects
412 412 def rolled_up_trackers
413 413 @rolled_up_trackers ||=
414 414 Tracker.
415 415 joins(:projects).
416 416 select("DISTINCT #{Tracker.table_name}.*").
417 417 where("#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ? AND #{Project.table_name}.status <> #{STATUS_ARCHIVED}", lft, rgt).
418 418 sorted.
419 419 all
420 420 end
421 421
422 422 # Closes open and locked project versions that are completed
423 423 def close_completed_versions
424 424 Version.transaction do
425 425 versions.where(:status => %w(open locked)).all.each do |version|
426 426 if version.completed?
427 427 version.update_attribute(:status, 'closed')
428 428 end
429 429 end
430 430 end
431 431 end
432 432
433 433 # Returns a scope of the Versions on subprojects
434 434 def rolled_up_versions
435 435 @rolled_up_versions ||=
436 436 Version.scoped(:include => :project,
437 437 :conditions => ["#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ? AND #{Project.table_name}.status <> #{STATUS_ARCHIVED}", lft, rgt])
438 438 end
439 439
440 440 # Returns a scope of the Versions used by the project
441 441 def shared_versions
442 442 if new_record?
443 443 Version.scoped(:include => :project,
444 444 :conditions => "#{Project.table_name}.status <> #{Project::STATUS_ARCHIVED} AND #{Version.table_name}.sharing = 'system'")
445 445 else
446 446 @shared_versions ||= begin
447 447 r = root? ? self : root
448 448 Version.scoped(:include => :project,
449 449 :conditions => "#{Project.table_name}.id = #{id}" +
450 450 " OR (#{Project.table_name}.status <> #{Project::STATUS_ARCHIVED} AND (" +
451 451 " #{Version.table_name}.sharing = 'system'" +
452 452 " OR (#{Project.table_name}.lft >= #{r.lft} AND #{Project.table_name}.rgt <= #{r.rgt} AND #{Version.table_name}.sharing = 'tree')" +
453 453 " OR (#{Project.table_name}.lft < #{lft} AND #{Project.table_name}.rgt > #{rgt} AND #{Version.table_name}.sharing IN ('hierarchy', 'descendants'))" +
454 454 " OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt} AND #{Version.table_name}.sharing = 'hierarchy')" +
455 455 "))")
456 456 end
457 457 end
458 458 end
459 459
460 460 # Returns a hash of project users grouped by role
461 461 def users_by_role
462 462 members.includes(:user, :roles).all.inject({}) do |h, m|
463 463 m.roles.each do |r|
464 464 h[r] ||= []
465 465 h[r] << m.user
466 466 end
467 467 h
468 468 end
469 469 end
470 470
471 471 # Deletes all project's members
472 472 def delete_all_members
473 473 me, mr = Member.table_name, MemberRole.table_name
474 474 connection.delete("DELETE FROM #{mr} WHERE #{mr}.member_id IN (SELECT #{me}.id FROM #{me} WHERE #{me}.project_id = #{id})")
475 475 Member.delete_all(['project_id = ?', id])
476 476 end
477 477
478 478 # Users/groups issues can be assigned to
479 479 def assignable_users
480 480 assignable = Setting.issue_group_assignment? ? member_principals : members
481 481 assignable.select {|m| m.roles.detect {|role| role.assignable?}}.collect {|m| m.principal}.sort
482 482 end
483 483
484 484 # Returns the mail adresses of users that should be always notified on project events
485 485 def recipients
486 486 notified_users.collect {|user| user.mail}
487 487 end
488 488
489 489 # Returns the users that should be notified on project events
490 490 def notified_users
491 491 # TODO: User part should be extracted to User#notify_about?
492 492 members.select {|m| m.principal.present? && (m.mail_notification? || m.principal.mail_notification == 'all')}.collect {|m| m.principal}
493 493 end
494 494
495 495 # Returns an array of all custom fields enabled for project issues
496 496 # (explictly associated custom fields and custom fields enabled for all projects)
497 497 def all_issue_custom_fields
498 498 @all_issue_custom_fields ||= (IssueCustomField.for_all + issue_custom_fields).uniq.sort
499 499 end
500 500
501 501 # Returns an array of all custom fields enabled for project time entries
502 502 # (explictly associated custom fields and custom fields enabled for all projects)
503 503 def all_time_entry_custom_fields
504 504 @all_time_entry_custom_fields ||= (TimeEntryCustomField.for_all + time_entry_custom_fields).uniq.sort
505 505 end
506 506
507 507 def project
508 508 self
509 509 end
510 510
511 511 def <=>(project)
512 512 name.downcase <=> project.name.downcase
513 513 end
514 514
515 515 def to_s
516 516 name
517 517 end
518 518
519 519 # Returns a short description of the projects (first lines)
520 520 def short_description(length = 255)
521 521 description.gsub(/^(.{#{length}}[^\n\r]*).*$/m, '\1...').strip if description
522 522 end
523 523
524 524 def css_classes
525 525 s = 'project'
526 526 s << ' root' if root?
527 527 s << ' child' if child?
528 528 s << (leaf? ? ' leaf' : ' parent')
529 529 unless active?
530 530 if archived?
531 531 s << ' archived'
532 532 else
533 533 s << ' closed'
534 534 end
535 535 end
536 536 s
537 537 end
538 538
539 539 # The earliest start date of a project, based on it's issues and versions
540 540 def start_date
541 541 [
542 542 issues.minimum('start_date'),
543 543 shared_versions.collect(&:effective_date),
544 544 shared_versions.collect(&:start_date)
545 545 ].flatten.compact.min
546 546 end
547 547
548 548 # The latest due date of an issue or version
549 549 def due_date
550 550 [
551 551 issues.maximum('due_date'),
552 552 shared_versions.collect(&:effective_date),
553 553 shared_versions.collect {|v| v.fixed_issues.maximum('due_date')}
554 554 ].flatten.compact.max
555 555 end
556 556
557 557 def overdue?
558 558 active? && !due_date.nil? && (due_date < Date.today)
559 559 end
560 560
561 561 # Returns the percent completed for this project, based on the
562 562 # progress on it's versions.
563 563 def completed_percent(options={:include_subprojects => false})
564 564 if options.delete(:include_subprojects)
565 565 total = self_and_descendants.collect(&:completed_percent).sum
566 566
567 567 total / self_and_descendants.count
568 568 else
569 569 if versions.count > 0
570 total = versions.collect(&:completed_pourcent).sum
570 total = versions.collect(&:completed_percent).sum
571 571
572 572 total / versions.count
573 573 else
574 574 100
575 575 end
576 576 end
577 577 end
578 578
579 579 # Return true if this project allows to do the specified action.
580 580 # action can be:
581 581 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
582 582 # * a permission Symbol (eg. :edit_project)
583 583 def allows_to?(action)
584 584 if archived?
585 585 # No action allowed on archived projects
586 586 return false
587 587 end
588 588 unless active? || Redmine::AccessControl.read_action?(action)
589 589 # No write action allowed on closed projects
590 590 return false
591 591 end
592 592 # No action allowed on disabled modules
593 593 if action.is_a? Hash
594 594 allowed_actions.include? "#{action[:controller]}/#{action[:action]}"
595 595 else
596 596 allowed_permissions.include? action
597 597 end
598 598 end
599 599
600 600 def module_enabled?(module_name)
601 601 module_name = module_name.to_s
602 602 enabled_modules.detect {|m| m.name == module_name}
603 603 end
604 604
605 605 def enabled_module_names=(module_names)
606 606 if module_names && module_names.is_a?(Array)
607 607 module_names = module_names.collect(&:to_s).reject(&:blank?)
608 608 self.enabled_modules = module_names.collect {|name| enabled_modules.detect {|mod| mod.name == name} || EnabledModule.new(:name => name)}
609 609 else
610 610 enabled_modules.clear
611 611 end
612 612 end
613 613
614 614 # Returns an array of the enabled modules names
615 615 def enabled_module_names
616 616 enabled_modules.collect(&:name)
617 617 end
618 618
619 619 # Enable a specific module
620 620 #
621 621 # Examples:
622 622 # project.enable_module!(:issue_tracking)
623 623 # project.enable_module!("issue_tracking")
624 624 def enable_module!(name)
625 625 enabled_modules << EnabledModule.new(:name => name.to_s) unless module_enabled?(name)
626 626 end
627 627
628 628 # Disable a module if it exists
629 629 #
630 630 # Examples:
631 631 # project.disable_module!(:issue_tracking)
632 632 # project.disable_module!("issue_tracking")
633 633 # project.disable_module!(project.enabled_modules.first)
634 634 def disable_module!(target)
635 635 target = enabled_modules.detect{|mod| target.to_s == mod.name} unless enabled_modules.include?(target)
636 636 target.destroy unless target.blank?
637 637 end
638 638
639 639 safe_attributes 'name',
640 640 'description',
641 641 'homepage',
642 642 'is_public',
643 643 'identifier',
644 644 'custom_field_values',
645 645 'custom_fields',
646 646 'tracker_ids',
647 647 'issue_custom_field_ids'
648 648
649 649 safe_attributes 'enabled_module_names',
650 650 :if => lambda {|project, user| project.new_record? || user.allowed_to?(:select_project_modules, project) }
651 651
652 652 # Returns an array of projects that are in this project's hierarchy
653 653 #
654 654 # Example: parents, children, siblings
655 655 def hierarchy
656 656 parents = project.self_and_ancestors || []
657 657 descendants = project.descendants || []
658 658 project_hierarchy = parents | descendants # Set union
659 659 end
660 660
661 661 # Returns an auto-generated project identifier based on the last identifier used
662 662 def self.next_identifier
663 663 p = Project.order('created_on DESC').first
664 664 p.nil? ? nil : p.identifier.to_s.succ
665 665 end
666 666
667 667 # Copies and saves the Project instance based on the +project+.
668 668 # Duplicates the source project's:
669 669 # * Wiki
670 670 # * Versions
671 671 # * Categories
672 672 # * Issues
673 673 # * Members
674 674 # * Queries
675 675 #
676 676 # Accepts an +options+ argument to specify what to copy
677 677 #
678 678 # Examples:
679 679 # project.copy(1) # => copies everything
680 680 # project.copy(1, :only => 'members') # => copies members only
681 681 # project.copy(1, :only => ['members', 'versions']) # => copies members and versions
682 682 def copy(project, options={})
683 683 project = project.is_a?(Project) ? project : Project.find(project)
684 684
685 685 to_be_copied = %w(wiki versions issue_categories issues members queries boards)
686 686 to_be_copied = to_be_copied & options[:only].to_a unless options[:only].nil?
687 687
688 688 Project.transaction do
689 689 if save
690 690 reload
691 691 to_be_copied.each do |name|
692 692 send "copy_#{name}", project
693 693 end
694 694 Redmine::Hook.call_hook(:model_project_copy_before_save, :source_project => project, :destination_project => self)
695 695 save
696 696 end
697 697 end
698 698 end
699 699
700 700 # Returns a new unsaved Project instance with attributes copied from +project+
701 701 def self.copy_from(project)
702 702 project = project.is_a?(Project) ? project : Project.find(project)
703 703 # clear unique attributes
704 704 attributes = project.attributes.dup.except('id', 'name', 'identifier', 'status', 'parent_id', 'lft', 'rgt')
705 705 copy = Project.new(attributes)
706 706 copy.enabled_modules = project.enabled_modules
707 707 copy.trackers = project.trackers
708 708 copy.custom_values = project.custom_values.collect {|v| v.clone}
709 709 copy.issue_custom_fields = project.issue_custom_fields
710 710 copy
711 711 end
712 712
713 713 # Yields the given block for each project with its level in the tree
714 714 def self.project_tree(projects, &block)
715 715 ancestors = []
716 716 projects.sort_by(&:lft).each do |project|
717 717 while (ancestors.any? && !project.is_descendant_of?(ancestors.last))
718 718 ancestors.pop
719 719 end
720 720 yield project, ancestors.size
721 721 ancestors << project
722 722 end
723 723 end
724 724
725 725 private
726 726
727 727 # Copies wiki from +project+
728 728 def copy_wiki(project)
729 729 # Check that the source project has a wiki first
730 730 unless project.wiki.nil?
731 731 self.wiki ||= Wiki.new
732 732 wiki.attributes = project.wiki.attributes.dup.except("id", "project_id")
733 733 wiki_pages_map = {}
734 734 project.wiki.pages.each do |page|
735 735 # Skip pages without content
736 736 next if page.content.nil?
737 737 new_wiki_content = WikiContent.new(page.content.attributes.dup.except("id", "page_id", "updated_on"))
738 738 new_wiki_page = WikiPage.new(page.attributes.dup.except("id", "wiki_id", "created_on", "parent_id"))
739 739 new_wiki_page.content = new_wiki_content
740 740 wiki.pages << new_wiki_page
741 741 wiki_pages_map[page.id] = new_wiki_page
742 742 end
743 743 wiki.save
744 744 # Reproduce page hierarchy
745 745 project.wiki.pages.each do |page|
746 746 if page.parent_id && wiki_pages_map[page.id]
747 747 wiki_pages_map[page.id].parent = wiki_pages_map[page.parent_id]
748 748 wiki_pages_map[page.id].save
749 749 end
750 750 end
751 751 end
752 752 end
753 753
754 754 # Copies versions from +project+
755 755 def copy_versions(project)
756 756 project.versions.each do |version|
757 757 new_version = Version.new
758 758 new_version.attributes = version.attributes.dup.except("id", "project_id", "created_on", "updated_on")
759 759 self.versions << new_version
760 760 end
761 761 end
762 762
763 763 # Copies issue categories from +project+
764 764 def copy_issue_categories(project)
765 765 project.issue_categories.each do |issue_category|
766 766 new_issue_category = IssueCategory.new
767 767 new_issue_category.attributes = issue_category.attributes.dup.except("id", "project_id")
768 768 self.issue_categories << new_issue_category
769 769 end
770 770 end
771 771
772 772 # Copies issues from +project+
773 773 def copy_issues(project)
774 774 # Stores the source issue id as a key and the copied issues as the
775 775 # value. Used to map the two togeather for issue relations.
776 776 issues_map = {}
777 777
778 778 # Store status and reopen locked/closed versions
779 779 version_statuses = versions.reject(&:open?).map {|version| [version, version.status]}
780 780 version_statuses.each do |version, status|
781 781 version.update_attribute :status, 'open'
782 782 end
783 783
784 784 # Get issues sorted by root_id, lft so that parent issues
785 785 # get copied before their children
786 786 project.issues.reorder('root_id, lft').all.each do |issue|
787 787 new_issue = Issue.new
788 788 new_issue.copy_from(issue, :subtasks => false, :link => false)
789 789 new_issue.project = self
790 790 # Reassign fixed_versions by name, since names are unique per project
791 791 if issue.fixed_version && issue.fixed_version.project == project
792 792 new_issue.fixed_version = self.versions.detect {|v| v.name == issue.fixed_version.name}
793 793 end
794 794 # Reassign the category by name, since names are unique per project
795 795 if issue.category
796 796 new_issue.category = self.issue_categories.detect {|c| c.name == issue.category.name}
797 797 end
798 798 # Parent issue
799 799 if issue.parent_id
800 800 if copied_parent = issues_map[issue.parent_id]
801 801 new_issue.parent_issue_id = copied_parent.id
802 802 end
803 803 end
804 804
805 805 self.issues << new_issue
806 806 if new_issue.new_record?
807 807 logger.info "Project#copy_issues: issue ##{issue.id} could not be copied: #{new_issue.errors.full_messages}" if logger && logger.info
808 808 else
809 809 issues_map[issue.id] = new_issue unless new_issue.new_record?
810 810 end
811 811 end
812 812
813 813 # Restore locked/closed version statuses
814 814 version_statuses.each do |version, status|
815 815 version.update_attribute :status, status
816 816 end
817 817
818 818 # Relations after in case issues related each other
819 819 project.issues.each do |issue|
820 820 new_issue = issues_map[issue.id]
821 821 unless new_issue
822 822 # Issue was not copied
823 823 next
824 824 end
825 825
826 826 # Relations
827 827 issue.relations_from.each do |source_relation|
828 828 new_issue_relation = IssueRelation.new
829 829 new_issue_relation.attributes = source_relation.attributes.dup.except("id", "issue_from_id", "issue_to_id")
830 830 new_issue_relation.issue_to = issues_map[source_relation.issue_to_id]
831 831 if new_issue_relation.issue_to.nil? && Setting.cross_project_issue_relations?
832 832 new_issue_relation.issue_to = source_relation.issue_to
833 833 end
834 834 new_issue.relations_from << new_issue_relation
835 835 end
836 836
837 837 issue.relations_to.each do |source_relation|
838 838 new_issue_relation = IssueRelation.new
839 839 new_issue_relation.attributes = source_relation.attributes.dup.except("id", "issue_from_id", "issue_to_id")
840 840 new_issue_relation.issue_from = issues_map[source_relation.issue_from_id]
841 841 if new_issue_relation.issue_from.nil? && Setting.cross_project_issue_relations?
842 842 new_issue_relation.issue_from = source_relation.issue_from
843 843 end
844 844 new_issue.relations_to << new_issue_relation
845 845 end
846 846 end
847 847 end
848 848
849 849 # Copies members from +project+
850 850 def copy_members(project)
851 851 # Copy users first, then groups to handle members with inherited and given roles
852 852 members_to_copy = []
853 853 members_to_copy += project.memberships.select {|m| m.principal.is_a?(User)}
854 854 members_to_copy += project.memberships.select {|m| !m.principal.is_a?(User)}
855 855
856 856 members_to_copy.each do |member|
857 857 new_member = Member.new
858 858 new_member.attributes = member.attributes.dup.except("id", "project_id", "created_on")
859 859 # only copy non inherited roles
860 860 # inherited roles will be added when copying the group membership
861 861 role_ids = member.member_roles.reject(&:inherited?).collect(&:role_id)
862 862 next if role_ids.empty?
863 863 new_member.role_ids = role_ids
864 864 new_member.project = self
865 865 self.members << new_member
866 866 end
867 867 end
868 868
869 869 # Copies queries from +project+
870 870 def copy_queries(project)
871 871 project.queries.each do |query|
872 872 new_query = IssueQuery.new
873 873 new_query.attributes = query.attributes.dup.except("id", "project_id", "sort_criteria")
874 874 new_query.sort_criteria = query.sort_criteria if query.sort_criteria
875 875 new_query.project = self
876 876 new_query.user_id = query.user_id
877 877 self.queries << new_query
878 878 end
879 879 end
880 880
881 881 # Copies boards from +project+
882 882 def copy_boards(project)
883 883 project.boards.each do |board|
884 884 new_board = Board.new
885 885 new_board.attributes = board.attributes.dup.except("id", "project_id", "topics_count", "messages_count", "last_message_id")
886 886 new_board.project = self
887 887 self.boards << new_board
888 888 end
889 889 end
890 890
891 891 def allowed_permissions
892 892 @allowed_permissions ||= begin
893 893 module_names = enabled_modules.all(:select => :name).collect {|m| m.name}
894 894 Redmine::AccessControl.modules_permissions(module_names).collect {|p| p.name}
895 895 end
896 896 end
897 897
898 898 def allowed_actions
899 899 @actions_allowed ||= allowed_permissions.inject([]) { |actions, permission| actions += Redmine::AccessControl.allowed_actions(permission) }.flatten
900 900 end
901 901
902 902 # Returns all the active Systemwide and project specific activities
903 903 def active_activities
904 904 overridden_activity_ids = self.time_entry_activities.collect(&:parent_id)
905 905
906 906 if overridden_activity_ids.empty?
907 907 return TimeEntryActivity.shared.active
908 908 else
909 909 return system_activities_and_project_overrides
910 910 end
911 911 end
912 912
913 913 # Returns all the Systemwide and project specific activities
914 914 # (inactive and active)
915 915 def all_activities
916 916 overridden_activity_ids = self.time_entry_activities.collect(&:parent_id)
917 917
918 918 if overridden_activity_ids.empty?
919 919 return TimeEntryActivity.shared
920 920 else
921 921 return system_activities_and_project_overrides(true)
922 922 end
923 923 end
924 924
925 925 # Returns the systemwide active activities merged with the project specific overrides
926 926 def system_activities_and_project_overrides(include_inactive=false)
927 927 if include_inactive
928 928 return TimeEntryActivity.shared.
929 929 where("id NOT IN (?)", self.time_entry_activities.collect(&:parent_id)).all +
930 930 self.time_entry_activities
931 931 else
932 932 return TimeEntryActivity.shared.active.
933 933 where("id NOT IN (?)", self.time_entry_activities.collect(&:parent_id)).all +
934 934 self.time_entry_activities.active
935 935 end
936 936 end
937 937
938 938 # Archives subprojects recursively
939 939 def archive!
940 940 children.each do |subproject|
941 941 subproject.send :archive!
942 942 end
943 943 update_attribute :status, STATUS_ARCHIVED
944 944 end
945 945
946 946 def update_position_under_parent
947 947 set_or_update_position_under(parent)
948 948 end
949 949
950 950 # Inserts/moves the project so that target's children or root projects stay alphabetically sorted
951 951 def set_or_update_position_under(target_parent)
952 952 sibs = (target_parent.nil? ? self.class.roots : target_parent.children)
953 953 to_be_inserted_before = sibs.sort_by {|c| c.name.to_s.downcase}.detect {|c| c.name.to_s.downcase > name.to_s.downcase }
954 954
955 955 if to_be_inserted_before
956 956 move_to_left_of(to_be_inserted_before)
957 957 elsif target_parent.nil?
958 958 if sibs.empty?
959 959 # move_to_root adds the project in first (ie. left) position
960 960 move_to_root
961 961 else
962 962 move_to_right_of(sibs.last) unless self == sibs.last
963 963 end
964 964 else
965 965 # move_to_child_of adds the project in last (ie.right) position
966 966 move_to_child_of(target_parent)
967 967 end
968 968 end
969 969 end
@@ -1,284 +1,296
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 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_format_of :effective_date, :with => /\A\d{4}-\d{2}-\d{2}\z/, :message => :not_a_date, :allow_nil => true
34 34 validates_inclusion_of :status, :in => VERSION_STATUSES
35 35 validates_inclusion_of :sharing, :in => VERSION_SHARINGS
36 36 validate :validate_version
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 includes(:project).where(Project.allowed_to_condition(args.first || User.current, :view_issues))
42 42 }
43 43
44 44 safe_attributes 'name',
45 45 'description',
46 46 'effective_date',
47 47 'due_date',
48 48 'wiki_page_title',
49 49 'status',
50 50 'sharing',
51 51 'custom_field_values'
52 52
53 53 # Returns true if +user+ or current user is allowed to view the version
54 54 def visible?(user=User.current)
55 55 user.allowed_to?(:view_issues, self.project)
56 56 end
57 57
58 58 # Version files have same visibility as project files
59 59 def attachments_visible?(*args)
60 60 project.present? && project.attachments_visible?(*args)
61 61 end
62 62
63 63 def start_date
64 64 @start_date ||= fixed_issues.minimum('start_date')
65 65 end
66 66
67 67 def due_date
68 68 effective_date
69 69 end
70 70
71 71 def due_date=(arg)
72 72 self.effective_date=(arg)
73 73 end
74 74
75 75 # Returns the total estimated time for this version
76 76 # (sum of leaves estimated_hours)
77 77 def estimated_hours
78 78 @estimated_hours ||= fixed_issues.leaves.sum(:estimated_hours).to_f
79 79 end
80 80
81 81 # Returns the total reported time for this version
82 82 def spent_hours
83 83 @spent_hours ||= TimeEntry.joins(:issue).where("#{Issue.table_name}.fixed_version_id = ?", id).sum(:hours).to_f
84 84 end
85 85
86 86 def closed?
87 87 status == 'closed'
88 88 end
89 89
90 90 def open?
91 91 status == 'open'
92 92 end
93 93
94 94 # Returns true if the version is completed: due date reached and no open issues
95 95 def completed?
96 96 effective_date && (effective_date < Date.today) && (open_issues_count == 0)
97 97 end
98 98
99 99 def behind_schedule?
100 if completed_pourcent == 100
100 if completed_percent == 100
101 101 return false
102 102 elsif due_date && start_date
103 done_date = start_date + ((due_date - start_date+1)* completed_pourcent/100).floor
103 done_date = start_date + ((due_date - start_date+1)* completed_percent/100).floor
104 104 return done_date <= Date.today
105 105 else
106 106 false # No issues so it's not late
107 107 end
108 108 end
109 109
110 110 # Returns the completion percentage of this version based on the amount of open/closed issues
111 111 # and the time spent on the open issues.
112 def completed_pourcent
112 def completed_percent
113 113 if issues_count == 0
114 114 0
115 115 elsif open_issues_count == 0
116 116 100
117 117 else
118 118 issues_progress(false) + issues_progress(true)
119 119 end
120 120 end
121 121
122 # TODO: remove in Redmine 3.0
123 def completed_pourcent
124 ActiveSupport::Deprecation.warn "Version#completed_pourcent is deprecated and will be removed in Redmine 3.0. Please use #completed_percent instead."
125 completed_percent
126 end
127
122 128 # Returns the percentage of issues that have been marked as 'closed'.
123 def closed_pourcent
129 def closed_percent
124 130 if issues_count == 0
125 131 0
126 132 else
127 133 issues_progress(false)
128 134 end
129 135 end
130 136
137 # TODO: remove in Redmine 3.0
138 def closed_pourcent
139 ActiveSupport::Deprecation.warn "Version#closed_pourcent is deprecated and will be removed in Redmine 3.0. Please use #closed_percent instead."
140 closed_percent
141 end
142
131 143 # Returns true if the version is overdue: due date reached and some open issues
132 144 def overdue?
133 145 effective_date && (effective_date < Date.today) && (open_issues_count > 0)
134 146 end
135 147
136 148 # Returns assigned issues count
137 149 def issues_count
138 150 load_issue_counts
139 151 @issue_count
140 152 end
141 153
142 154 # Returns the total amount of open issues for this version.
143 155 def open_issues_count
144 156 load_issue_counts
145 157 @open_issues_count
146 158 end
147 159
148 160 # Returns the total amount of closed issues for this version.
149 161 def closed_issues_count
150 162 load_issue_counts
151 163 @closed_issues_count
152 164 end
153 165
154 166 def wiki_page
155 167 if project.wiki && !wiki_page_title.blank?
156 168 @wiki_page ||= project.wiki.find_page(wiki_page_title)
157 169 end
158 170 @wiki_page
159 171 end
160 172
161 173 def to_s; name end
162 174
163 175 def to_s_with_project
164 176 "#{project} - #{name}"
165 177 end
166 178
167 179 # Versions are sorted by effective_date and name
168 180 # Those with no effective_date are at the end, sorted by name
169 181 def <=>(version)
170 182 if self.effective_date
171 183 if version.effective_date
172 184 if self.effective_date == version.effective_date
173 185 name == version.name ? id <=> version.id : name <=> version.name
174 186 else
175 187 self.effective_date <=> version.effective_date
176 188 end
177 189 else
178 190 -1
179 191 end
180 192 else
181 193 if version.effective_date
182 194 1
183 195 else
184 196 name == version.name ? id <=> version.id : name <=> version.name
185 197 end
186 198 end
187 199 end
188 200
189 201 def self.fields_for_order_statement(table=nil)
190 202 table ||= table_name
191 203 ["(CASE WHEN #{table}.effective_date IS NULL THEN 1 ELSE 0 END)", "#{table}.effective_date", "#{table}.name", "#{table}.id"]
192 204 end
193 205
194 206 scope :sorted, order(fields_for_order_statement)
195 207
196 208 # Returns the sharings that +user+ can set the version to
197 209 def allowed_sharings(user = User.current)
198 210 VERSION_SHARINGS.select do |s|
199 211 if sharing == s
200 212 true
201 213 else
202 214 case s
203 215 when 'system'
204 216 # Only admin users can set a systemwide sharing
205 217 user.admin?
206 218 when 'hierarchy', 'tree'
207 219 # Only users allowed to manage versions of the root project can
208 220 # set sharing to hierarchy or tree
209 221 project.nil? || user.allowed_to?(:manage_versions, project.root)
210 222 else
211 223 true
212 224 end
213 225 end
214 226 end
215 227 end
216 228
217 229 private
218 230
219 231 def load_issue_counts
220 232 unless @issue_count
221 233 @open_issues_count = 0
222 234 @closed_issues_count = 0
223 235 fixed_issues.count(:all, :group => :status).each do |status, count|
224 236 if status.is_closed?
225 237 @closed_issues_count += count
226 238 else
227 239 @open_issues_count += count
228 240 end
229 241 end
230 242 @issue_count = @open_issues_count + @closed_issues_count
231 243 end
232 244 end
233 245
234 246 # Update the issue's fixed versions. Used if a version's sharing changes.
235 247 def update_issues_from_sharing_change
236 248 if sharing_changed?
237 249 if VERSION_SHARINGS.index(sharing_was).nil? ||
238 250 VERSION_SHARINGS.index(sharing).nil? ||
239 251 VERSION_SHARINGS.index(sharing_was) > VERSION_SHARINGS.index(sharing)
240 252 Issue.update_versions_from_sharing_change self
241 253 end
242 254 end
243 255 end
244 256
245 257 # Returns the average estimated time of assigned issues
246 258 # or 1 if no issue has an estimated time
247 259 # Used to weigth unestimated issues in progress calculation
248 260 def estimated_average
249 261 if @estimated_average.nil?
250 262 average = fixed_issues.average(:estimated_hours).to_f
251 263 if average == 0
252 264 average = 1
253 265 end
254 266 @estimated_average = average
255 267 end
256 268 @estimated_average
257 269 end
258 270
259 271 # Returns the total progress of open or closed issues. The returned percentage takes into account
260 272 # the amount of estimated time set for this version.
261 273 #
262 274 # Examples:
263 275 # issues_progress(true) => returns the progress percentage for open issues.
264 276 # issues_progress(false) => returns the progress percentage for closed issues.
265 277 def issues_progress(open)
266 278 @issues_progress ||= {}
267 279 @issues_progress[open] ||= begin
268 280 progress = 0
269 281 if issues_count > 0
270 282 ratio = open ? 'done_ratio' : 100
271 283
272 284 done = fixed_issues.open(open).sum("COALESCE(estimated_hours, #{estimated_average}) * #{ratio}").to_f
273 285 progress = done / (estimated_average * issues_count)
274 286 end
275 287 progress
276 288 end
277 289 end
278 290
279 291 def validate_version
280 292 if effective_date.nil? && @attributes['effective_date'].present?
281 293 errors.add :effective_date, :not_a_date
282 294 end
283 295 end
284 296 end
@@ -1,32 +1,32
1 1 <% if version.completed? %>
2 2 <p><%= format_date(version.effective_date) %></p>
3 3 <% elsif version.effective_date %>
4 4 <p><strong><%= due_date_distance_in_words(version.effective_date) %></strong> (<%= format_date(version.effective_date) %>)</p>
5 5 <% end %>
6 6
7 7 <p><%=h version.description %></p>
8 8 <% if version.custom_field_values.any? %>
9 9 <ul>
10 10 <% version.custom_field_values.each do |custom_value| %>
11 11 <% if custom_value.value.present? %>
12 12 <li><%=h custom_value.custom_field.name %>: <%=h show_value(custom_value) %></li>
13 13 <% end %>
14 14 <% end %>
15 15 </ul>
16 16 <% end %>
17 17
18 18 <% if version.issues_count > 0 %>
19 <%= progress_bar([version.closed_pourcent, version.completed_pourcent], :width => '40em', :legend => ('%0.0f%' % version.completed_pourcent)) %>
19 <%= progress_bar([version.closed_percent, version.completed_percent], :width => '40em', :legend => ('%0.0f%' % version.completed_percent)) %>
20 20 <p class="progress-info">
21 21 <%= link_to(l(:label_x_issues, :count => version.issues_count),
22 22 project_issues_path(version.project, :status_id => '*', :fixed_version_id => version, :set_filter => 1)) %>
23 23 &nbsp;
24 24 (<%= link_to_if(version.closed_issues_count > 0, l(:label_x_closed_issues_abbr, :count => version.closed_issues_count),
25 25 project_issues_path(version.project, :status_id => 'c', :fixed_version_id => version, :set_filter => 1)) %>
26 26 &#8212;
27 27 <%= link_to_if(version.open_issues_count > 0, l(:label_x_open_issues_abbr, :count => version.open_issues_count),
28 28 project_issues_path(version.project, :status_id => 'o', :fixed_version_id => version, :set_filter => 1)) %>)
29 29 </p>
30 30 <% else %>
31 31 <p class="progress-info"><%= l(:label_roadmap_no_issues) %></p>
32 32 <% end %>
@@ -1,883 +1,883
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 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 Helpers
20 20 # Simple class to handle gantt chart data
21 21 class Gantt
22 22 include ERB::Util
23 23 include Redmine::I18n
24 24 include Redmine::Utils::DateCalculation
25 25
26 26 # :nodoc:
27 27 # Some utility methods for the PDF export
28 28 class PDF
29 29 MaxCharactorsForSubject = 45
30 30 TotalWidth = 280
31 31 LeftPaneWidth = 100
32 32
33 33 def self.right_pane_width
34 34 TotalWidth - LeftPaneWidth
35 35 end
36 36 end
37 37
38 38 attr_reader :year_from, :month_from, :date_from, :date_to, :zoom, :months, :truncated, :max_rows
39 39 attr_accessor :query
40 40 attr_accessor :project
41 41 attr_accessor :view
42 42
43 43 def initialize(options={})
44 44 options = options.dup
45 45 if options[:year] && options[:year].to_i >0
46 46 @year_from = options[:year].to_i
47 47 if options[:month] && options[:month].to_i >=1 && options[:month].to_i <= 12
48 48 @month_from = options[:month].to_i
49 49 else
50 50 @month_from = 1
51 51 end
52 52 else
53 53 @month_from ||= Date.today.month
54 54 @year_from ||= Date.today.year
55 55 end
56 56 zoom = (options[:zoom] || User.current.pref[:gantt_zoom]).to_i
57 57 @zoom = (zoom > 0 && zoom < 5) ? zoom : 2
58 58 months = (options[:months] || User.current.pref[:gantt_months]).to_i
59 59 @months = (months > 0 && months < 25) ? months : 6
60 60 # Save gantt parameters as user preference (zoom and months count)
61 61 if (User.current.logged? && (@zoom != User.current.pref[:gantt_zoom] ||
62 62 @months != User.current.pref[:gantt_months]))
63 63 User.current.pref[:gantt_zoom], User.current.pref[:gantt_months] = @zoom, @months
64 64 User.current.preference.save
65 65 end
66 66 @date_from = Date.civil(@year_from, @month_from, 1)
67 67 @date_to = (@date_from >> @months) - 1
68 68 @subjects = ''
69 69 @lines = ''
70 70 @number_of_rows = nil
71 71 @issue_ancestors = []
72 72 @truncated = false
73 73 if options.has_key?(:max_rows)
74 74 @max_rows = options[:max_rows]
75 75 else
76 76 @max_rows = Setting.gantt_items_limit.blank? ? nil : Setting.gantt_items_limit.to_i
77 77 end
78 78 end
79 79
80 80 def common_params
81 81 { :controller => 'gantts', :action => 'show', :project_id => @project }
82 82 end
83 83
84 84 def params
85 85 common_params.merge({:zoom => zoom, :year => year_from,
86 86 :month => month_from, :months => months})
87 87 end
88 88
89 89 def params_previous
90 90 common_params.merge({:year => (date_from << months).year,
91 91 :month => (date_from << months).month,
92 92 :zoom => zoom, :months => months})
93 93 end
94 94
95 95 def params_next
96 96 common_params.merge({:year => (date_from >> months).year,
97 97 :month => (date_from >> months).month,
98 98 :zoom => zoom, :months => months})
99 99 end
100 100
101 101 # Returns the number of rows that will be rendered on the Gantt chart
102 102 def number_of_rows
103 103 return @number_of_rows if @number_of_rows
104 104 rows = projects.inject(0) {|total, p| total += number_of_rows_on_project(p)}
105 105 rows > @max_rows ? @max_rows : rows
106 106 end
107 107
108 108 # Returns the number of rows that will be used to list a project on
109 109 # the Gantt chart. This will recurse for each subproject.
110 110 def number_of_rows_on_project(project)
111 111 return 0 unless projects.include?(project)
112 112 count = 1
113 113 count += project_issues(project).size
114 114 count += project_versions(project).size
115 115 count
116 116 end
117 117
118 118 # Renders the subjects of the Gantt chart, the left side.
119 119 def subjects(options={})
120 120 render(options.merge(:only => :subjects)) unless @subjects_rendered
121 121 @subjects
122 122 end
123 123
124 124 # Renders the lines of the Gantt chart, the right side
125 125 def lines(options={})
126 126 render(options.merge(:only => :lines)) unless @lines_rendered
127 127 @lines
128 128 end
129 129
130 130 # Returns issues that will be rendered
131 131 def issues
132 132 @issues ||= @query.issues(
133 133 :include => [:assigned_to, :tracker, :priority, :category, :fixed_version],
134 134 :order => "#{Project.table_name}.lft ASC, #{Issue.table_name}.id ASC",
135 135 :limit => @max_rows
136 136 )
137 137 end
138 138
139 139 # Return all the project nodes that will be displayed
140 140 def projects
141 141 return @projects if @projects
142 142 ids = issues.collect(&:project).uniq.collect(&:id)
143 143 if ids.any?
144 144 # All issues projects and their visible ancestors
145 145 @projects = Project.visible.all(
146 146 :joins => "LEFT JOIN #{Project.table_name} child ON #{Project.table_name}.lft <= child.lft AND #{Project.table_name}.rgt >= child.rgt",
147 147 :conditions => ["child.id IN (?)", ids],
148 148 :order => "#{Project.table_name}.lft ASC"
149 149 ).uniq
150 150 else
151 151 @projects = []
152 152 end
153 153 end
154 154
155 155 # Returns the issues that belong to +project+
156 156 def project_issues(project)
157 157 @issues_by_project ||= issues.group_by(&:project)
158 158 @issues_by_project[project] || []
159 159 end
160 160
161 161 # Returns the distinct versions of the issues that belong to +project+
162 162 def project_versions(project)
163 163 project_issues(project).collect(&:fixed_version).compact.uniq
164 164 end
165 165
166 166 # Returns the issues that belong to +project+ and are assigned to +version+
167 167 def version_issues(project, version)
168 168 project_issues(project).select {|issue| issue.fixed_version == version}
169 169 end
170 170
171 171 def render(options={})
172 172 options = {:top => 0, :top_increment => 20,
173 173 :indent_increment => 20, :render => :subject,
174 174 :format => :html}.merge(options)
175 175 indent = options[:indent] || 4
176 176 @subjects = '' unless options[:only] == :lines
177 177 @lines = '' unless options[:only] == :subjects
178 178 @number_of_rows = 0
179 179 Project.project_tree(projects) do |project, level|
180 180 options[:indent] = indent + level * options[:indent_increment]
181 181 render_project(project, options)
182 182 break if abort?
183 183 end
184 184 @subjects_rendered = true unless options[:only] == :lines
185 185 @lines_rendered = true unless options[:only] == :subjects
186 186 render_end(options)
187 187 end
188 188
189 189 def render_project(project, options={})
190 190 subject_for_project(project, options) unless options[:only] == :lines
191 191 line_for_project(project, options) unless options[:only] == :subjects
192 192 options[:top] += options[:top_increment]
193 193 options[:indent] += options[:indent_increment]
194 194 @number_of_rows += 1
195 195 return if abort?
196 196 issues = project_issues(project).select {|i| i.fixed_version.nil?}
197 197 sort_issues!(issues)
198 198 if issues
199 199 render_issues(issues, options)
200 200 return if abort?
201 201 end
202 202 versions = project_versions(project)
203 203 versions.each do |version|
204 204 render_version(project, version, options)
205 205 end
206 206 # Remove indent to hit the next sibling
207 207 options[:indent] -= options[:indent_increment]
208 208 end
209 209
210 210 def render_issues(issues, options={})
211 211 @issue_ancestors = []
212 212 issues.each do |i|
213 213 subject_for_issue(i, options) unless options[:only] == :lines
214 214 line_for_issue(i, options) unless options[:only] == :subjects
215 215 options[:top] += options[:top_increment]
216 216 @number_of_rows += 1
217 217 break if abort?
218 218 end
219 219 options[:indent] -= (options[:indent_increment] * @issue_ancestors.size)
220 220 end
221 221
222 222 def render_version(project, version, options={})
223 223 # Version header
224 224 subject_for_version(version, options) unless options[:only] == :lines
225 225 line_for_version(version, options) unless options[:only] == :subjects
226 226 options[:top] += options[:top_increment]
227 227 @number_of_rows += 1
228 228 return if abort?
229 229 issues = version_issues(project, version)
230 230 if issues
231 231 sort_issues!(issues)
232 232 # Indent issues
233 233 options[:indent] += options[:indent_increment]
234 234 render_issues(issues, options)
235 235 options[:indent] -= options[:indent_increment]
236 236 end
237 237 end
238 238
239 239 def render_end(options={})
240 240 case options[:format]
241 241 when :pdf
242 242 options[:pdf].Line(15, options[:top], PDF::TotalWidth, options[:top])
243 243 end
244 244 end
245 245
246 246 def subject_for_project(project, options)
247 247 case options[:format]
248 248 when :html
249 249 html_class = ""
250 250 html_class << 'icon icon-projects '
251 251 html_class << (project.overdue? ? 'project-overdue' : '')
252 252 s = view.link_to_project(project).html_safe
253 253 subject = view.content_tag(:span, s,
254 254 :class => html_class).html_safe
255 255 html_subject(options, subject, :css => "project-name")
256 256 when :image
257 257 image_subject(options, project.name)
258 258 when :pdf
259 259 pdf_new_page?(options)
260 260 pdf_subject(options, project.name)
261 261 end
262 262 end
263 263
264 264 def line_for_project(project, options)
265 265 # Skip versions that don't have a start_date or due date
266 266 if project.is_a?(Project) && project.start_date && project.due_date
267 267 options[:zoom] ||= 1
268 268 options[:g_width] ||= (self.date_to - self.date_from + 1) * options[:zoom]
269 269 coords = coordinates(project.start_date, project.due_date, nil, options[:zoom])
270 270 label = h(project)
271 271 case options[:format]
272 272 when :html
273 273 html_task(options, coords, :css => "project task", :label => label, :markers => true)
274 274 when :image
275 275 image_task(options, coords, :label => label, :markers => true, :height => 3)
276 276 when :pdf
277 277 pdf_task(options, coords, :label => label, :markers => true, :height => 0.8)
278 278 end
279 279 else
280 280 ActiveRecord::Base.logger.debug "Gantt#line_for_project was not given a project with a start_date"
281 281 ''
282 282 end
283 283 end
284 284
285 285 def subject_for_version(version, options)
286 286 case options[:format]
287 287 when :html
288 288 html_class = ""
289 289 html_class << 'icon icon-package '
290 290 html_class << (version.behind_schedule? ? 'version-behind-schedule' : '') << " "
291 291 html_class << (version.overdue? ? 'version-overdue' : '')
292 292 s = view.link_to_version(version).html_safe
293 293 subject = view.content_tag(:span, s,
294 294 :class => html_class).html_safe
295 295 html_subject(options, subject, :css => "version-name")
296 296 when :image
297 297 image_subject(options, version.to_s_with_project)
298 298 when :pdf
299 299 pdf_new_page?(options)
300 300 pdf_subject(options, version.to_s_with_project)
301 301 end
302 302 end
303 303
304 304 def line_for_version(version, options)
305 305 # Skip versions that don't have a start_date
306 306 if version.is_a?(Version) && version.start_date && version.due_date
307 307 options[:zoom] ||= 1
308 308 options[:g_width] ||= (self.date_to - self.date_from + 1) * options[:zoom]
309 309 coords = coordinates(version.start_date,
310 version.due_date, version.completed_pourcent,
310 version.due_date, version.completed_percent,
311 311 options[:zoom])
312 label = "#{h version} #{h version.completed_pourcent.to_i.to_s}%"
312 label = "#{h version} #{h version.completed_percent.to_i.to_s}%"
313 313 label = h("#{version.project} -") + label unless @project && @project == version.project
314 314 case options[:format]
315 315 when :html
316 316 html_task(options, coords, :css => "version task", :label => label, :markers => true)
317 317 when :image
318 318 image_task(options, coords, :label => label, :markers => true, :height => 3)
319 319 when :pdf
320 320 pdf_task(options, coords, :label => label, :markers => true, :height => 0.8)
321 321 end
322 322 else
323 323 ActiveRecord::Base.logger.debug "Gantt#line_for_version was not given a version with a start_date"
324 324 ''
325 325 end
326 326 end
327 327
328 328 def subject_for_issue(issue, options)
329 329 while @issue_ancestors.any? && !issue.is_descendant_of?(@issue_ancestors.last)
330 330 @issue_ancestors.pop
331 331 options[:indent] -= options[:indent_increment]
332 332 end
333 333 output = case options[:format]
334 334 when :html
335 335 css_classes = ''
336 336 css_classes << ' issue-overdue' if issue.overdue?
337 337 css_classes << ' issue-behind-schedule' if issue.behind_schedule?
338 338 css_classes << ' icon icon-issue' unless Setting.gravatar_enabled? && issue.assigned_to
339 339 s = "".html_safe
340 340 if issue.assigned_to.present?
341 341 assigned_string = l(:field_assigned_to) + ": " + issue.assigned_to.name
342 342 s << view.avatar(issue.assigned_to,
343 343 :class => 'gravatar icon-gravatar',
344 344 :size => 10,
345 345 :title => assigned_string).to_s.html_safe
346 346 end
347 347 s << view.link_to_issue(issue).html_safe
348 348 subject = view.content_tag(:span, s, :class => css_classes).html_safe
349 349 html_subject(options, subject, :css => "issue-subject",
350 350 :title => issue.subject) + "\n"
351 351 when :image
352 352 image_subject(options, issue.subject)
353 353 when :pdf
354 354 pdf_new_page?(options)
355 355 pdf_subject(options, issue.subject)
356 356 end
357 357 unless issue.leaf?
358 358 @issue_ancestors << issue
359 359 options[:indent] += options[:indent_increment]
360 360 end
361 361 output
362 362 end
363 363
364 364 def line_for_issue(issue, options)
365 365 # Skip issues that don't have a due_before (due_date or version's due_date)
366 366 if issue.is_a?(Issue) && issue.due_before
367 367 coords = coordinates(issue.start_date, issue.due_before, issue.done_ratio, options[:zoom])
368 368 label = "#{issue.status.name} #{issue.done_ratio}%"
369 369 case options[:format]
370 370 when :html
371 371 html_task(options, coords,
372 372 :css => "task " + (issue.leaf? ? 'leaf' : 'parent'),
373 373 :label => label, :issue => issue,
374 374 :markers => !issue.leaf?)
375 375 when :image
376 376 image_task(options, coords, :label => label)
377 377 when :pdf
378 378 pdf_task(options, coords, :label => label)
379 379 end
380 380 else
381 381 ActiveRecord::Base.logger.debug "GanttHelper#line_for_issue was not given an issue with a due_before"
382 382 ''
383 383 end
384 384 end
385 385
386 386 # Generates a gantt image
387 387 # Only defined if RMagick is avalaible
388 388 def to_image(format='PNG')
389 389 date_to = (@date_from >> @months) - 1
390 390 show_weeks = @zoom > 1
391 391 show_days = @zoom > 2
392 392 subject_width = 400
393 393 header_height = 18
394 394 # width of one day in pixels
395 395 zoom = @zoom * 2
396 396 g_width = (@date_to - @date_from + 1) * zoom
397 397 g_height = 20 * number_of_rows + 30
398 398 headers_height = (show_weeks ? 2 * header_height : header_height)
399 399 height = g_height + headers_height
400 400 imgl = Magick::ImageList.new
401 401 imgl.new_image(subject_width + g_width + 1, height)
402 402 gc = Magick::Draw.new
403 403 gc.font = Redmine::Configuration['rmagick_font_path'] || ""
404 404 # Subjects
405 405 gc.stroke('transparent')
406 406 subjects(:image => gc, :top => (headers_height + 20), :indent => 4, :format => :image)
407 407 # Months headers
408 408 month_f = @date_from
409 409 left = subject_width
410 410 @months.times do
411 411 width = ((month_f >> 1) - month_f) * zoom
412 412 gc.fill('white')
413 413 gc.stroke('grey')
414 414 gc.stroke_width(1)
415 415 gc.rectangle(left, 0, left + width, height)
416 416 gc.fill('black')
417 417 gc.stroke('transparent')
418 418 gc.stroke_width(1)
419 419 gc.text(left.round + 8, 14, "#{month_f.year}-#{month_f.month}")
420 420 left = left + width
421 421 month_f = month_f >> 1
422 422 end
423 423 # Weeks headers
424 424 if show_weeks
425 425 left = subject_width
426 426 height = header_height
427 427 if @date_from.cwday == 1
428 428 # date_from is monday
429 429 week_f = date_from
430 430 else
431 431 # find next monday after date_from
432 432 week_f = @date_from + (7 - @date_from.cwday + 1)
433 433 width = (7 - @date_from.cwday + 1) * zoom
434 434 gc.fill('white')
435 435 gc.stroke('grey')
436 436 gc.stroke_width(1)
437 437 gc.rectangle(left, header_height, left + width, 2 * header_height + g_height - 1)
438 438 left = left + width
439 439 end
440 440 while week_f <= date_to
441 441 width = (week_f + 6 <= date_to) ? 7 * zoom : (date_to - week_f + 1) * zoom
442 442 gc.fill('white')
443 443 gc.stroke('grey')
444 444 gc.stroke_width(1)
445 445 gc.rectangle(left.round, header_height, left.round + width, 2 * header_height + g_height - 1)
446 446 gc.fill('black')
447 447 gc.stroke('transparent')
448 448 gc.stroke_width(1)
449 449 gc.text(left.round + 2, header_height + 14, week_f.cweek.to_s)
450 450 left = left + width
451 451 week_f = week_f + 7
452 452 end
453 453 end
454 454 # Days details (week-end in grey)
455 455 if show_days
456 456 left = subject_width
457 457 height = g_height + header_height - 1
458 458 wday = @date_from.cwday
459 459 (date_to - @date_from + 1).to_i.times do
460 460 width = zoom
461 461 gc.fill(non_working_week_days.include?(wday) ? '#eee' : 'white')
462 462 gc.stroke('#ddd')
463 463 gc.stroke_width(1)
464 464 gc.rectangle(left, 2 * header_height, left + width, 2 * header_height + g_height - 1)
465 465 left = left + width
466 466 wday = wday + 1
467 467 wday = 1 if wday > 7
468 468 end
469 469 end
470 470 # border
471 471 gc.fill('transparent')
472 472 gc.stroke('grey')
473 473 gc.stroke_width(1)
474 474 gc.rectangle(0, 0, subject_width + g_width, headers_height)
475 475 gc.stroke('black')
476 476 gc.rectangle(0, 0, subject_width + g_width, g_height + headers_height - 1)
477 477 # content
478 478 top = headers_height + 20
479 479 gc.stroke('transparent')
480 480 lines(:image => gc, :top => top, :zoom => zoom,
481 481 :subject_width => subject_width, :format => :image)
482 482 # today red line
483 483 if Date.today >= @date_from and Date.today <= date_to
484 484 gc.stroke('red')
485 485 x = (Date.today - @date_from + 1) * zoom + subject_width
486 486 gc.line(x, headers_height, x, headers_height + g_height - 1)
487 487 end
488 488 gc.draw(imgl)
489 489 imgl.format = format
490 490 imgl.to_blob
491 491 end if Object.const_defined?(:Magick)
492 492
493 493 def to_pdf
494 494 pdf = ::Redmine::Export::PDF::ITCPDF.new(current_language)
495 495 pdf.SetTitle("#{l(:label_gantt)} #{project}")
496 496 pdf.alias_nb_pages
497 497 pdf.footer_date = format_date(Date.today)
498 498 pdf.AddPage("L")
499 499 pdf.SetFontStyle('B', 12)
500 500 pdf.SetX(15)
501 501 pdf.RDMCell(PDF::LeftPaneWidth, 20, project.to_s)
502 502 pdf.Ln
503 503 pdf.SetFontStyle('B', 9)
504 504 subject_width = PDF::LeftPaneWidth
505 505 header_height = 5
506 506 headers_height = header_height
507 507 show_weeks = false
508 508 show_days = false
509 509 if self.months < 7
510 510 show_weeks = true
511 511 headers_height = 2 * header_height
512 512 if self.months < 3
513 513 show_days = true
514 514 headers_height = 3 * header_height
515 515 end
516 516 end
517 517 g_width = PDF.right_pane_width
518 518 zoom = (g_width) / (self.date_to - self.date_from + 1)
519 519 g_height = 120
520 520 t_height = g_height + headers_height
521 521 y_start = pdf.GetY
522 522 # Months headers
523 523 month_f = self.date_from
524 524 left = subject_width
525 525 height = header_height
526 526 self.months.times do
527 527 width = ((month_f >> 1) - month_f) * zoom
528 528 pdf.SetY(y_start)
529 529 pdf.SetX(left)
530 530 pdf.RDMCell(width, height, "#{month_f.year}-#{month_f.month}", "LTR", 0, "C")
531 531 left = left + width
532 532 month_f = month_f >> 1
533 533 end
534 534 # Weeks headers
535 535 if show_weeks
536 536 left = subject_width
537 537 height = header_height
538 538 if self.date_from.cwday == 1
539 539 # self.date_from is monday
540 540 week_f = self.date_from
541 541 else
542 542 # find next monday after self.date_from
543 543 week_f = self.date_from + (7 - self.date_from.cwday + 1)
544 544 width = (7 - self.date_from.cwday + 1) * zoom-1
545 545 pdf.SetY(y_start + header_height)
546 546 pdf.SetX(left)
547 547 pdf.RDMCell(width + 1, height, "", "LTR")
548 548 left = left + width + 1
549 549 end
550 550 while week_f <= self.date_to
551 551 width = (week_f + 6 <= self.date_to) ? 7 * zoom : (self.date_to - week_f + 1) * zoom
552 552 pdf.SetY(y_start + header_height)
553 553 pdf.SetX(left)
554 554 pdf.RDMCell(width, height, (width >= 5 ? week_f.cweek.to_s : ""), "LTR", 0, "C")
555 555 left = left + width
556 556 week_f = week_f + 7
557 557 end
558 558 end
559 559 # Days headers
560 560 if show_days
561 561 left = subject_width
562 562 height = header_height
563 563 wday = self.date_from.cwday
564 564 pdf.SetFontStyle('B', 7)
565 565 (self.date_to - self.date_from + 1).to_i.times do
566 566 width = zoom
567 567 pdf.SetY(y_start + 2 * header_height)
568 568 pdf.SetX(left)
569 569 pdf.RDMCell(width, height, day_name(wday).first, "LTR", 0, "C")
570 570 left = left + width
571 571 wday = wday + 1
572 572 wday = 1 if wday > 7
573 573 end
574 574 end
575 575 pdf.SetY(y_start)
576 576 pdf.SetX(15)
577 577 pdf.RDMCell(subject_width + g_width - 15, headers_height, "", 1)
578 578 # Tasks
579 579 top = headers_height + y_start
580 580 options = {
581 581 :top => top,
582 582 :zoom => zoom,
583 583 :subject_width => subject_width,
584 584 :g_width => g_width,
585 585 :indent => 0,
586 586 :indent_increment => 5,
587 587 :top_increment => 5,
588 588 :format => :pdf,
589 589 :pdf => pdf
590 590 }
591 591 render(options)
592 592 pdf.Output
593 593 end
594 594
595 595 private
596 596
597 597 def coordinates(start_date, end_date, progress, zoom=nil)
598 598 zoom ||= @zoom
599 599 coords = {}
600 600 if start_date && end_date && start_date < self.date_to && end_date > self.date_from
601 601 if start_date > self.date_from
602 602 coords[:start] = start_date - self.date_from
603 603 coords[:bar_start] = start_date - self.date_from
604 604 else
605 605 coords[:bar_start] = 0
606 606 end
607 607 if end_date < self.date_to
608 608 coords[:end] = end_date - self.date_from
609 609 coords[:bar_end] = end_date - self.date_from + 1
610 610 else
611 611 coords[:bar_end] = self.date_to - self.date_from + 1
612 612 end
613 613 if progress
614 614 progress_date = start_date + (end_date - start_date + 1) * (progress / 100.0)
615 615 if progress_date > self.date_from && progress_date > start_date
616 616 if progress_date < self.date_to
617 617 coords[:bar_progress_end] = progress_date - self.date_from
618 618 else
619 619 coords[:bar_progress_end] = self.date_to - self.date_from + 1
620 620 end
621 621 end
622 622 if progress_date < Date.today
623 623 late_date = [Date.today, end_date].min
624 624 if late_date > self.date_from && late_date > start_date
625 625 if late_date < self.date_to
626 626 coords[:bar_late_end] = late_date - self.date_from + 1
627 627 else
628 628 coords[:bar_late_end] = self.date_to - self.date_from + 1
629 629 end
630 630 end
631 631 end
632 632 end
633 633 end
634 634 # Transforms dates into pixels witdh
635 635 coords.keys.each do |key|
636 636 coords[key] = (coords[key] * zoom).floor
637 637 end
638 638 coords
639 639 end
640 640
641 641 # Sorts a collection of issues by start_date, due_date, id for gantt rendering
642 642 def sort_issues!(issues)
643 643 issues.sort! { |a, b| gantt_issue_compare(a, b) }
644 644 end
645 645
646 646 # TODO: top level issues should be sorted by start date
647 647 def gantt_issue_compare(x, y)
648 648 if x.root_id == y.root_id
649 649 x.lft <=> y.lft
650 650 else
651 651 x.root_id <=> y.root_id
652 652 end
653 653 end
654 654
655 655 def current_limit
656 656 if @max_rows
657 657 @max_rows - @number_of_rows
658 658 else
659 659 nil
660 660 end
661 661 end
662 662
663 663 def abort?
664 664 if @max_rows && @number_of_rows >= @max_rows
665 665 @truncated = true
666 666 end
667 667 end
668 668
669 669 def pdf_new_page?(options)
670 670 if options[:top] > 180
671 671 options[:pdf].Line(15, options[:top], PDF::TotalWidth, options[:top])
672 672 options[:pdf].AddPage("L")
673 673 options[:top] = 15
674 674 options[:pdf].Line(15, options[:top] - 0.1, PDF::TotalWidth, options[:top] - 0.1)
675 675 end
676 676 end
677 677
678 678 def html_subject(params, subject, options={})
679 679 style = "position: absolute;top:#{params[:top]}px;left:#{params[:indent]}px;"
680 680 style << "width:#{params[:subject_width] - params[:indent]}px;" if params[:subject_width]
681 681 output = view.content_tag('div', subject,
682 682 :class => options[:css], :style => style,
683 683 :title => options[:title])
684 684 @subjects << output
685 685 output
686 686 end
687 687
688 688 def pdf_subject(params, subject, options={})
689 689 params[:pdf].SetY(params[:top])
690 690 params[:pdf].SetX(15)
691 691 char_limit = PDF::MaxCharactorsForSubject - params[:indent]
692 692 params[:pdf].RDMCell(params[:subject_width] - 15, 5,
693 693 (" " * params[:indent]) +
694 694 subject.to_s.sub(/^(.{#{char_limit}}[^\s]*\s).*$/, '\1 (...)'),
695 695 "LR")
696 696 params[:pdf].SetY(params[:top])
697 697 params[:pdf].SetX(params[:subject_width])
698 698 params[:pdf].RDMCell(params[:g_width], 5, "", "LR")
699 699 end
700 700
701 701 def image_subject(params, subject, options={})
702 702 params[:image].fill('black')
703 703 params[:image].stroke('transparent')
704 704 params[:image].stroke_width(1)
705 705 params[:image].text(params[:indent], params[:top] + 2, subject)
706 706 end
707 707
708 708 def html_task(params, coords, options={})
709 709 output = ''
710 710 # Renders the task bar, with progress and late
711 711 if coords[:bar_start] && coords[:bar_end]
712 712 width = coords[:bar_end] - coords[:bar_start] - 2
713 713 style = ""
714 714 style << "top:#{params[:top]}px;"
715 715 style << "left:#{coords[:bar_start]}px;"
716 716 style << "width:#{width}px;"
717 717 output << view.content_tag(:div, '&nbsp;'.html_safe,
718 718 :style => style,
719 719 :class => "#{options[:css]} task_todo")
720 720 if coords[:bar_late_end]
721 721 width = coords[:bar_late_end] - coords[:bar_start] - 2
722 722 style = ""
723 723 style << "top:#{params[:top]}px;"
724 724 style << "left:#{coords[:bar_start]}px;"
725 725 style << "width:#{width}px;"
726 726 output << view.content_tag(:div, '&nbsp;'.html_safe,
727 727 :style => style,
728 728 :class => "#{options[:css]} task_late")
729 729 end
730 730 if coords[:bar_progress_end]
731 731 width = coords[:bar_progress_end] - coords[:bar_start] - 2
732 732 style = ""
733 733 style << "top:#{params[:top]}px;"
734 734 style << "left:#{coords[:bar_start]}px;"
735 735 style << "width:#{width}px;"
736 736 output << view.content_tag(:div, '&nbsp;'.html_safe,
737 737 :style => style,
738 738 :class => "#{options[:css]} task_done")
739 739 end
740 740 end
741 741 # Renders the markers
742 742 if options[:markers]
743 743 if coords[:start]
744 744 style = ""
745 745 style << "top:#{params[:top]}px;"
746 746 style << "left:#{coords[:start]}px;"
747 747 style << "width:15px;"
748 748 output << view.content_tag(:div, '&nbsp;'.html_safe,
749 749 :style => style,
750 750 :class => "#{options[:css]} marker starting")
751 751 end
752 752 if coords[:end]
753 753 style = ""
754 754 style << "top:#{params[:top]}px;"
755 755 style << "left:#{coords[:end] + params[:zoom]}px;"
756 756 style << "width:15px;"
757 757 output << view.content_tag(:div, '&nbsp;'.html_safe,
758 758 :style => style,
759 759 :class => "#{options[:css]} marker ending")
760 760 end
761 761 end
762 762 # Renders the label on the right
763 763 if options[:label]
764 764 style = ""
765 765 style << "top:#{params[:top]}px;"
766 766 style << "left:#{(coords[:bar_end] || 0) + 8}px;"
767 767 style << "width:15px;"
768 768 output << view.content_tag(:div, options[:label],
769 769 :style => style,
770 770 :class => "#{options[:css]} label")
771 771 end
772 772 # Renders the tooltip
773 773 if options[:issue] && coords[:bar_start] && coords[:bar_end]
774 774 s = view.content_tag(:span,
775 775 view.render_issue_tooltip(options[:issue]).html_safe,
776 776 :class => "tip")
777 777 style = ""
778 778 style << "position: absolute;"
779 779 style << "top:#{params[:top]}px;"
780 780 style << "left:#{coords[:bar_start]}px;"
781 781 style << "width:#{coords[:bar_end] - coords[:bar_start]}px;"
782 782 style << "height:12px;"
783 783 output << view.content_tag(:div, s.html_safe,
784 784 :style => style,
785 785 :class => "tooltip")
786 786 end
787 787 @lines << output
788 788 output
789 789 end
790 790
791 791 def pdf_task(params, coords, options={})
792 792 height = options[:height] || 2
793 793 # Renders the task bar, with progress and late
794 794 if coords[:bar_start] && coords[:bar_end]
795 795 params[:pdf].SetY(params[:top] + 1.5)
796 796 params[:pdf].SetX(params[:subject_width] + coords[:bar_start])
797 797 params[:pdf].SetFillColor(200, 200, 200)
798 798 params[:pdf].RDMCell(coords[:bar_end] - coords[:bar_start], height, "", 0, 0, "", 1)
799 799 if coords[:bar_late_end]
800 800 params[:pdf].SetY(params[:top] + 1.5)
801 801 params[:pdf].SetX(params[:subject_width] + coords[:bar_start])
802 802 params[:pdf].SetFillColor(255, 100, 100)
803 803 params[:pdf].RDMCell(coords[:bar_late_end] - coords[:bar_start], height, "", 0, 0, "", 1)
804 804 end
805 805 if coords[:bar_progress_end]
806 806 params[:pdf].SetY(params[:top] + 1.5)
807 807 params[:pdf].SetX(params[:subject_width] + coords[:bar_start])
808 808 params[:pdf].SetFillColor(90, 200, 90)
809 809 params[:pdf].RDMCell(coords[:bar_progress_end] - coords[:bar_start], height, "", 0, 0, "", 1)
810 810 end
811 811 end
812 812 # Renders the markers
813 813 if options[:markers]
814 814 if coords[:start]
815 815 params[:pdf].SetY(params[:top] + 1)
816 816 params[:pdf].SetX(params[:subject_width] + coords[:start] - 1)
817 817 params[:pdf].SetFillColor(50, 50, 200)
818 818 params[:pdf].RDMCell(2, 2, "", 0, 0, "", 1)
819 819 end
820 820 if coords[:end]
821 821 params[:pdf].SetY(params[:top] + 1)
822 822 params[:pdf].SetX(params[:subject_width] + coords[:end] - 1)
823 823 params[:pdf].SetFillColor(50, 50, 200)
824 824 params[:pdf].RDMCell(2, 2, "", 0, 0, "", 1)
825 825 end
826 826 end
827 827 # Renders the label on the right
828 828 if options[:label]
829 829 params[:pdf].SetX(params[:subject_width] + (coords[:bar_end] || 0) + 5)
830 830 params[:pdf].RDMCell(30, 2, options[:label])
831 831 end
832 832 end
833 833
834 834 def image_task(params, coords, options={})
835 835 height = options[:height] || 6
836 836 # Renders the task bar, with progress and late
837 837 if coords[:bar_start] && coords[:bar_end]
838 838 params[:image].fill('#aaa')
839 839 params[:image].rectangle(params[:subject_width] + coords[:bar_start],
840 840 params[:top],
841 841 params[:subject_width] + coords[:bar_end],
842 842 params[:top] - height)
843 843 if coords[:bar_late_end]
844 844 params[:image].fill('#f66')
845 845 params[:image].rectangle(params[:subject_width] + coords[:bar_start],
846 846 params[:top],
847 847 params[:subject_width] + coords[:bar_late_end],
848 848 params[:top] - height)
849 849 end
850 850 if coords[:bar_progress_end]
851 851 params[:image].fill('#00c600')
852 852 params[:image].rectangle(params[:subject_width] + coords[:bar_start],
853 853 params[:top],
854 854 params[:subject_width] + coords[:bar_progress_end],
855 855 params[:top] - height)
856 856 end
857 857 end
858 858 # Renders the markers
859 859 if options[:markers]
860 860 if coords[:start]
861 861 x = params[:subject_width] + coords[:start]
862 862 y = params[:top] - height / 2
863 863 params[:image].fill('blue')
864 864 params[:image].polygon(x - 4, y, x, y - 4, x + 4, y, x, y + 4)
865 865 end
866 866 if coords[:end]
867 867 x = params[:subject_width] + coords[:end] + params[:zoom]
868 868 y = params[:top] - height / 2
869 869 params[:image].fill('blue')
870 870 params[:image].polygon(x - 4, y, x, y - 4, x + 4, y, x, y + 4)
871 871 end
872 872 end
873 873 # Renders the label on the right
874 874 if options[:label]
875 875 params[:image].fill('black')
876 876 params[:image].text(params[:subject_width] + (coords[:bar_end] || 0) + 5,
877 877 params[:top] + 1,
878 878 options[:label])
879 879 end
880 880 end
881 881 end
882 882 end
883 883 end
@@ -1,1147 +1,1147
1 1 html {overflow-y:scroll;}
2 2 body { font-family: Verdana, sans-serif; font-size: 12px; color:#484848; margin: 0; padding: 0; min-width: 900px; }
3 3
4 4 h1, h2, h3, h4 {font-family: "Trebuchet MS", Verdana, sans-serif;padding: 2px 10px 1px 0px;margin: 0 0 10px 0;}
5 5 #content h1, h2, h3, h4 {color: #555;}
6 6 h2, .wiki h1 {font-size: 20px;}
7 7 h3, .wiki h2 {font-size: 16px;}
8 8 h4, .wiki h3 {font-size: 13px;}
9 9 h4 {border-bottom: 1px dotted #bbb;}
10 10
11 11 /***** Layout *****/
12 12 #wrapper {background: white;}
13 13
14 14 #top-menu {background: #3E5B76; color: #fff; height:1.8em; font-size: 0.8em; padding: 2px 2px 0px 6px;}
15 15 #top-menu ul {margin: 0; padding: 0;}
16 16 #top-menu li {
17 17 float:left;
18 18 list-style-type:none;
19 19 margin: 0px 0px 0px 0px;
20 20 padding: 0px 0px 0px 0px;
21 21 white-space:nowrap;
22 22 }
23 23 #top-menu a {color: #fff; margin-right: 8px; font-weight: bold;}
24 24 #top-menu #loggedas { float: right; margin-right: 0.5em; color: #fff; }
25 25
26 26 #account {float:right;}
27 27
28 28 #header {height:5.3em;margin:0;background-color:#628DB6;color:#f8f8f8; padding: 4px 8px 0px 6px; position:relative;}
29 29 #header a {color:#f8f8f8;}
30 30 #header h1 a.ancestor { font-size: 80%; }
31 31 #quick-search {float:right;}
32 32
33 33 #main-menu {position: absolute; bottom: 0px; left:6px; margin-right: -500px;}
34 34 #main-menu ul {margin: 0; padding: 0;}
35 35 #main-menu li {
36 36 float:left;
37 37 list-style-type:none;
38 38 margin: 0px 2px 0px 0px;
39 39 padding: 0px 0px 0px 0px;
40 40 white-space:nowrap;
41 41 }
42 42 #main-menu li a {
43 43 display: block;
44 44 color: #fff;
45 45 text-decoration: none;
46 46 font-weight: bold;
47 47 margin: 0;
48 48 padding: 4px 10px 4px 10px;
49 49 }
50 50 #main-menu li a:hover {background:#759FCF; color:#fff;}
51 51 #main-menu li a.selected, #main-menu li a.selected:hover {background:#fff; color:#555;}
52 52
53 53 #admin-menu ul {margin: 0; padding: 0;}
54 54 #admin-menu li {margin: 0; padding: 0 0 6px 0; list-style-type:none;}
55 55
56 56 #admin-menu a { background-position: 0% 40%; background-repeat: no-repeat; padding-left: 20px; padding-top: 2px; padding-bottom: 3px;}
57 57 #admin-menu a.projects { background-image: url(../images/projects.png); }
58 58 #admin-menu a.users { background-image: url(../images/user.png); }
59 59 #admin-menu a.groups { background-image: url(../images/group.png); }
60 60 #admin-menu a.roles { background-image: url(../images/database_key.png); }
61 61 #admin-menu a.trackers { background-image: url(../images/ticket.png); }
62 62 #admin-menu a.issue_statuses { background-image: url(../images/ticket_edit.png); }
63 63 #admin-menu a.workflows { background-image: url(../images/ticket_go.png); }
64 64 #admin-menu a.custom_fields { background-image: url(../images/textfield.png); }
65 65 #admin-menu a.enumerations { background-image: url(../images/text_list_bullets.png); }
66 66 #admin-menu a.settings { background-image: url(../images/changeset.png); }
67 67 #admin-menu a.plugins { background-image: url(../images/plugin.png); }
68 68 #admin-menu a.info { background-image: url(../images/help.png); }
69 69 #admin-menu a.server_authentication { background-image: url(../images/server_key.png); }
70 70
71 71 #main {background-color:#EEEEEE;}
72 72
73 73 #sidebar{ float: right; width: 22%; position: relative; z-index: 9; padding: 0; margin: 0;}
74 74 * html #sidebar{ width: 22%; }
75 75 #sidebar h3{ font-size: 14px; margin-top:14px; color: #666; }
76 76 #sidebar hr{ width: 100%; margin: 0 auto; height: 1px; background: #ccc; border: 0; }
77 77 * html #sidebar hr{ width: 95%; position: relative; left: -6px; color: #ccc; }
78 78 #sidebar .contextual { margin-right: 1em; }
79 79
80 80 #content { width: 75%; background-color: #fff; margin: 0px; border-right: 1px solid #ddd; padding: 6px 10px 10px 10px; z-index: 10; }
81 81 * html #content{ width: 75%; padding-left: 0; margin-top: 0px; padding: 6px 10px 10px 10px;}
82 82 html>body #content { min-height: 600px; }
83 83 * html body #content { height: 600px; } /* IE */
84 84
85 85 #main.nosidebar #sidebar{ display: none; }
86 86 #main.nosidebar #content{ width: auto; border-right: 0; }
87 87
88 88 #footer {clear: both; border-top: 1px solid #bbb; font-size: 0.9em; color: #aaa; padding: 5px; text-align:center; background:#fff;}
89 89
90 90 #login-form table {margin-top:5em; padding:1em; margin-left: auto; margin-right: auto; border: 2px solid #FDBF3B; background-color:#FFEBC1; }
91 91 #login-form table td {padding: 6px;}
92 92 #login-form label {font-weight: bold;}
93 93 #login-form input#username, #login-form input#password { width: 300px; }
94 94
95 95 div.modal { border-radius:5px; background:#fff; z-index:50; padding:4px;}
96 96 div.modal h3.title {display:none;}
97 97 div.modal p.buttons {text-align:right; margin-bottom:0;}
98 98
99 99 input#openid_url { background: url(../images/openid-bg.gif) no-repeat; background-color: #fff; background-position: 0 50%; padding-left: 18px; }
100 100
101 101 .clear:after{ content: "."; display: block; height: 0; clear: both; visibility: hidden; }
102 102
103 103 /***** Links *****/
104 104 a, a:link, a:visited{ color: #169; text-decoration: none; }
105 105 a:hover, a:active{ color: #c61a1a; text-decoration: underline;}
106 106 a img{ border: 0; }
107 107
108 108 a.issue.closed, a.issue.closed:link, a.issue.closed:visited { color: #999; text-decoration: line-through; }
109 109 a.project.closed, a.project.closed:link, a.project.closed:visited { color: #999; }
110 110 a.user.locked, a.user.locked:link, a.user.locked:visited {color: #999;}
111 111
112 112 #sidebar a.selected {line-height:1.7em; padding:1px 3px 2px 2px; margin-left:-2px; background-color:#9DB9D5; color:#fff; border-radius:2px;}
113 113 #sidebar a.selected:hover {text-decoration:none;}
114 114 #admin-menu a {line-height:1.7em;}
115 115 #admin-menu a.selected {padding-left: 20px !important; background-position: 2px 40%;}
116 116
117 117 a.collapsible {padding-left: 12px; background: url(../images/arrow_expanded.png) no-repeat -3px 40%;}
118 118 a.collapsible.collapsed {background: url(../images/arrow_collapsed.png) no-repeat -5px 40%;}
119 119
120 120 a#toggle-completed-versions {color:#999;}
121 121 /***** Tables *****/
122 122 table.list { border: 1px solid #e4e4e4; border-collapse: collapse; width: 100%; margin-bottom: 4px; }
123 123 table.list th { background-color:#EEEEEE; padding: 4px; white-space:nowrap; }
124 124 table.list td { vertical-align: top; padding-right:10px; }
125 125 table.list td.id { width: 2%; text-align: center;}
126 126 table.list td.checkbox { width: 15px; padding: 2px 0 0 0; }
127 127 table.list td.checkbox input {padding:0px;}
128 128 table.list td.buttons { width: 15%; white-space:nowrap; text-align: right; }
129 129 table.list td.buttons a { padding-right: 0.6em; }
130 130 table.list caption { text-align: left; padding: 0.5em 0.5em 0.5em 0; }
131 131
132 132 tr.project td.name a { white-space:nowrap; }
133 133 tr.project.closed, tr.project.archived { color: #aaa; }
134 134 tr.project.closed a, tr.project.archived a { color: #aaa; }
135 135
136 136 tr.project.idnt td.name span {background: url(../images/bullet_arrow_right.png) no-repeat 0 50%; padding-left: 16px;}
137 137 tr.project.idnt-1 td.name {padding-left: 0.5em;}
138 138 tr.project.idnt-2 td.name {padding-left: 2em;}
139 139 tr.project.idnt-3 td.name {padding-left: 3.5em;}
140 140 tr.project.idnt-4 td.name {padding-left: 5em;}
141 141 tr.project.idnt-5 td.name {padding-left: 6.5em;}
142 142 tr.project.idnt-6 td.name {padding-left: 8em;}
143 143 tr.project.idnt-7 td.name {padding-left: 9.5em;}
144 144 tr.project.idnt-8 td.name {padding-left: 11em;}
145 145 tr.project.idnt-9 td.name {padding-left: 12.5em;}
146 146
147 147 tr.issue { text-align: center; white-space: nowrap; }
148 148 tr.issue td.subject, tr.issue td.category, td.assigned_to, tr.issue td.string, tr.issue td.text, tr.issue td.relations { white-space: normal; }
149 149 tr.issue td.subject, tr.issue td.relations { text-align: left; }
150 150 tr.issue td.done_ratio table.progress { margin-left:auto; margin-right: auto;}
151 151 tr.issue td.relations span {white-space: nowrap;}
152 152 table.issues td.description {color:#777; font-size:90%; padding:4px 4px 4px 24px; text-align:left; white-space:normal;}
153 153 table.issues td.description pre {white-space:normal;}
154 154
155 155 tr.issue.idnt td.subject a {background: url(../images/bullet_arrow_right.png) no-repeat 0 50%; padding-left: 16px;}
156 156 tr.issue.idnt-1 td.subject {padding-left: 0.5em;}
157 157 tr.issue.idnt-2 td.subject {padding-left: 2em;}
158 158 tr.issue.idnt-3 td.subject {padding-left: 3.5em;}
159 159 tr.issue.idnt-4 td.subject {padding-left: 5em;}
160 160 tr.issue.idnt-5 td.subject {padding-left: 6.5em;}
161 161 tr.issue.idnt-6 td.subject {padding-left: 8em;}
162 162 tr.issue.idnt-7 td.subject {padding-left: 9.5em;}
163 163 tr.issue.idnt-8 td.subject {padding-left: 11em;}
164 164 tr.issue.idnt-9 td.subject {padding-left: 12.5em;}
165 165
166 166 tr.entry { border: 1px solid #f8f8f8; }
167 167 tr.entry td { white-space: nowrap; }
168 168 tr.entry td.filename { width: 30%; }
169 169 tr.entry td.filename_no_report { width: 70%; }
170 170 tr.entry td.size { text-align: right; font-size: 90%; }
171 171 tr.entry td.revision, tr.entry td.author { text-align: center; }
172 172 tr.entry td.age { text-align: right; }
173 173 tr.entry.file td.filename a { margin-left: 16px; }
174 174 tr.entry.file td.filename_no_report a { margin-left: 16px; }
175 175
176 176 tr span.expander {background-image: url(../images/bullet_toggle_plus.png); padding-left: 8px; margin-left: 0; cursor: pointer;}
177 177 tr.open span.expander {background-image: url(../images/bullet_toggle_minus.png);}
178 178
179 179 tr.changeset { height: 20px }
180 180 tr.changeset ul, ol { margin-top: 0px; margin-bottom: 0px; }
181 181 tr.changeset td.revision_graph { width: 15%; background-color: #fffffb; }
182 182 tr.changeset td.author { text-align: center; width: 15%; white-space:nowrap;}
183 183 tr.changeset td.committed_on { text-align: center; width: 15%; white-space:nowrap;}
184 184
185 185 table.files tr.file td { text-align: center; }
186 186 table.files tr.file td.filename { text-align: left; padding-left: 24px; }
187 187 table.files tr.file td.digest { font-size: 80%; }
188 188
189 189 table.members td.roles, table.memberships td.roles { width: 45%; }
190 190
191 191 tr.message { height: 2.6em; }
192 192 tr.message td.subject { padding-left: 20px; }
193 193 tr.message td.created_on { white-space: nowrap; }
194 194 tr.message td.last_message { font-size: 80%; white-space: nowrap; }
195 195 tr.message.locked td.subject { background: url(../images/locked.png) no-repeat 0 1px; }
196 196 tr.message.sticky td.subject { background: url(../images/bullet_go.png) no-repeat 0 1px; font-weight: bold; }
197 197
198 198 tr.version.closed, tr.version.closed a { color: #999; }
199 199 tr.version td.name { padding-left: 20px; }
200 200 tr.version.shared td.name { background: url(../images/link.png) no-repeat 0% 70%; }
201 201 tr.version td.date, tr.version td.status, tr.version td.sharing { text-align: center; white-space:nowrap; }
202 202
203 203 tr.user td { width:13%; }
204 204 tr.user td.email { width:18%; }
205 205 tr.user td { white-space: nowrap; }
206 206 tr.user.locked, tr.user.registered { color: #aaa; }
207 207 tr.user.locked a, tr.user.registered a { color: #aaa; }
208 208
209 209 table.permissions td.role {color:#999;font-size:90%;font-weight:normal !important;text-align:center;vertical-align:bottom;}
210 210
211 211 tr.wiki-page-version td.updated_on, tr.wiki-page-version td.author {text-align:center;}
212 212
213 213 tr.time-entry { text-align: center; white-space: nowrap; }
214 214 tr.time-entry td.issue, tr.time-entry td.comments { text-align: left; white-space: normal; }
215 215 td.hours { text-align: right; font-weight: bold; padding-right: 0.5em; }
216 216 td.hours .hours-dec { font-size: 0.9em; }
217 217
218 218 table.plugins td { vertical-align: middle; }
219 219 table.plugins td.configure { text-align: right; padding-right: 1em; }
220 220 table.plugins span.name { font-weight: bold; display: block; margin-bottom: 6px; }
221 221 table.plugins span.description { display: block; font-size: 0.9em; }
222 222 table.plugins span.url { display: block; font-size: 0.9em; }
223 223
224 224 table.list tbody tr.group td { padding: 0.8em 0 0.5em 0.3em; font-weight: bold; border-bottom: 1px solid #ccc; }
225 225 table.list tbody tr.group span.count {position:relative; top:-1px; color:#fff; font-size:10px; background:#9DB9D5; padding:0px 6px 1px 6px; border-radius:3px; margin-left:4px;}
226 226 tr.group a.toggle-all { color: #aaa; font-size: 80%; font-weight: normal; display:none;}
227 227 tr.group:hover a.toggle-all { display:inline;}
228 228 a.toggle-all:hover {text-decoration:none;}
229 229
230 230 table.list tbody tr:hover { background-color:#ffffdd; }
231 231 table.list tbody tr.group:hover { background-color:inherit; }
232 232 table td {padding:2px;}
233 233 table p {margin:0;}
234 234 .odd {background-color:#f6f7f8;}
235 235 .even {background-color: #fff;}
236 236
237 237 a.sort { padding-right: 16px; background-position: 100% 50%; background-repeat: no-repeat; }
238 238 a.sort.asc { background-image: url(../images/sort_asc.png); }
239 239 a.sort.desc { background-image: url(../images/sort_desc.png); }
240 240
241 241 table.attributes { width: 100% }
242 242 table.attributes th { vertical-align: top; text-align: left; }
243 243 table.attributes td { vertical-align: top; }
244 244
245 245 table.boards a.board, h3.comments { background: url(../images/comment.png) no-repeat 0% 50%; padding-left: 20px; }
246 246 table.boards td.topic-count, table.boards td.message-count {text-align:center;}
247 247 table.boards td.last-message {font-size:80%;}
248 248
249 249 table.messages td.author, table.messages td.created_on, table.messages td.reply-count {text-align:center;}
250 250
251 251 table.query-columns {
252 252 border-collapse: collapse;
253 253 border: 0;
254 254 }
255 255
256 256 table.query-columns td.buttons {
257 257 vertical-align: middle;
258 258 text-align: center;
259 259 }
260 260
261 261 td.center {text-align:center;}
262 262
263 263 h3.version { background: url(../images/package.png) no-repeat 0% 50%; padding-left: 20px; }
264 264
265 265 div.issues h3 { background: url(../images/ticket.png) no-repeat 0% 50%; padding-left: 20px; }
266 266 div.members h3 { background: url(../images/group.png) no-repeat 0% 50%; padding-left: 20px; }
267 267 div.news h3 { background: url(../images/news.png) no-repeat 0% 50%; padding-left: 20px; }
268 268 div.projects h3 { background: url(../images/projects.png) no-repeat 0% 50%; padding-left: 20px; }
269 269
270 270 #watchers ul {margin: 0; padding: 0;}
271 271 #watchers li {list-style-type:none;margin: 0px 2px 0px 0px; padding: 0px 0px 0px 0px;}
272 272 #watchers select {width: 95%; display: block;}
273 273 #watchers a.delete {opacity: 0.4;}
274 274 #watchers a.delete:hover {opacity: 1;}
275 275 #watchers img.gravatar {margin: 0 4px 2px 0;}
276 276
277 277 span#watchers_inputs {overflow:auto; display:block;}
278 278 span.search_for_watchers {display:block;}
279 279 span.search_for_watchers, span.add_attachment {font-size:80%; line-height:2.5em;}
280 280 span.search_for_watchers a, span.add_attachment a {padding-left:16px; background: url(../images/bullet_add.png) no-repeat 0 50%; }
281 281
282 282
283 283 .highlight { background-color: #FCFD8D;}
284 284 .highlight.token-1 { background-color: #faa;}
285 285 .highlight.token-2 { background-color: #afa;}
286 286 .highlight.token-3 { background-color: #aaf;}
287 287
288 288 .box{
289 289 padding:6px;
290 290 margin-bottom: 10px;
291 291 background-color:#f6f6f6;
292 292 color:#505050;
293 293 line-height:1.5em;
294 294 border: 1px solid #e4e4e4;
295 295 }
296 296
297 297 div.square {
298 298 border: 1px solid #999;
299 299 float: left;
300 300 margin: .3em .4em 0 .4em;
301 301 overflow: hidden;
302 302 width: .6em; height: .6em;
303 303 }
304 304 .contextual {float:right; white-space: nowrap; line-height:1.4em;margin-top:5px; padding-left: 10px; font-size:0.9em;}
305 305 .contextual input, .contextual select {font-size:0.9em;}
306 306 .message .contextual { margin-top: 0; }
307 307
308 308 .splitcontent {overflow:auto;}
309 309 .splitcontentleft{float:left; width:49%;}
310 310 .splitcontentright{float:right; width:49%;}
311 311 form {display: inline;}
312 312 input, select {vertical-align: middle; margin-top: 1px; margin-bottom: 1px;}
313 313 fieldset {border: 1px solid #e4e4e4; margin:0;}
314 314 legend {color: #484848;}
315 315 hr { width: 100%; height: 1px; background: #ccc; border: 0;}
316 316 blockquote { font-style: italic; border-left: 3px solid #e0e0e0; padding-left: 0.6em; margin-left: 2.4em;}
317 317 blockquote blockquote { margin-left: 0;}
318 318 acronym { border-bottom: 1px dotted; cursor: help; }
319 319 textarea.wiki-edit {width:99%; resize:vertical;}
320 320 li p {margin-top: 0;}
321 321 div.issue {background:#ffffdd; padding:6px; margin-bottom:6px;border: 1px solid #d7d7d7;}
322 322 p.breadcrumb { font-size: 0.9em; margin: 4px 0 4px 0;}
323 323 p.subtitle { font-size: 0.9em; margin: -6px 0 12px 0; font-style: italic; }
324 324 p.footnote { font-size: 0.9em; margin-top: 0px; margin-bottom: 0px; }
325 325
326 326 div.issue div.subject div div { padding-left: 16px; }
327 327 div.issue div.subject p {margin: 0; margin-bottom: 0.1em; font-size: 90%; color: #999;}
328 328 div.issue div.subject>div>p { margin-top: 0.5em; }
329 329 div.issue div.subject h3 {margin: 0; margin-bottom: 0.1em;}
330 330 div.issue span.private { position:relative; bottom: 2px; text-transform: uppercase; background: #d22; color: #fff; font-weight:bold; padding: 0px 2px 0px 2px; font-size: 60%; margin-right: 2px; border-radius: 2px;}
331 331 div.issue .next-prev-links {color:#999;}
332 332 div.issue table.attributes th {width:22%;}
333 333 div.issue table.attributes td {width:28%;}
334 334
335 335 #issue_tree table.issues, #relations table.issues { border: 0; }
336 336 #issue_tree td.checkbox, #relations td.checkbox {display:none;}
337 337 #relations td.buttons {padding:0;}
338 338
339 339 fieldset.collapsible { border-width: 1px 0 0 0; font-size: 0.9em; }
340 340 fieldset.collapsible legend { padding-left: 16px; background: url(../images/arrow_expanded.png) no-repeat 0% 40%; cursor:pointer; }
341 341 fieldset.collapsible.collapsed legend { background-image: url(../images/arrow_collapsed.png); }
342 342
343 343 fieldset#date-range p { margin: 2px 0 2px 0; }
344 344 fieldset#filters table { border-collapse: collapse; }
345 345 fieldset#filters table td { padding: 0; vertical-align: middle; }
346 346 fieldset#filters tr.filter { height: 2.1em; }
347 347 fieldset#filters td.field { width:230px; }
348 348 fieldset#filters td.operator { width:180px; }
349 349 fieldset#filters td.operator select {max-width:170px;}
350 350 fieldset#filters td.values { white-space:nowrap; }
351 351 fieldset#filters td.values select {min-width:130px;}
352 352 fieldset#filters td.values input {height:1em;}
353 353 fieldset#filters td.add-filter { text-align: right; vertical-align: top; }
354 354
355 355 .toggle-multiselect {background: url(../images/bullet_toggle_plus.png) no-repeat 0% 40%; padding-left:8px; margin-left:0; cursor:pointer;}
356 356 .buttons { font-size: 0.9em; margin-bottom: 1.4em; margin-top: 1em; }
357 357
358 358 div#issue-changesets {float:right; width:45%; margin-left: 1em; margin-bottom: 1em; background: #fff; padding-left: 1em; font-size: 90%;}
359 359 div#issue-changesets div.changeset { padding: 4px;}
360 360 div#issue-changesets div.changeset { border-bottom: 1px solid #ddd; }
361 361 div#issue-changesets p { margin-top: 0; margin-bottom: 1em;}
362 362
363 363 .journal ul.details img {margin:0 0 -3px 4px;}
364 364 div.journal {overflow:auto;}
365 365 div.journal.private-notes {border-left:2px solid #d22; padding-left:4px; margin-left:-6px;}
366 366
367 367 div#activity dl, #search-results { margin-left: 2em; }
368 368 div#activity dd, #search-results dd { margin-bottom: 1em; padding-left: 18px; font-size: 0.9em; }
369 369 div#activity dt, #search-results dt { margin-bottom: 0px; padding-left: 20px; line-height: 18px; background-position: 0 50%; background-repeat: no-repeat; }
370 370 div#activity dt.me .time { border-bottom: 1px solid #999; }
371 371 div#activity dt .time { color: #777; font-size: 80%; }
372 372 div#activity dd .description, #search-results dd .description { font-style: italic; }
373 373 div#activity span.project:after, #search-results span.project:after { content: " -"; }
374 374 div#activity dd span.description, #search-results dd span.description { display:block; color: #808080; }
375 375 div#activity dt.grouped {margin-left:5em;}
376 376 div#activity dd.grouped {margin-left:9em;}
377 377
378 378 #search-results dd { margin-bottom: 1em; padding-left: 20px; margin-left:0px; }
379 379
380 380 div#search-results-counts {float:right;}
381 381 div#search-results-counts ul { margin-top: 0.5em; }
382 382 div#search-results-counts li { list-style-type:none; float: left; margin-left: 1em; }
383 383
384 384 dt.issue { background-image: url(../images/ticket.png); }
385 385 dt.issue-edit { background-image: url(../images/ticket_edit.png); }
386 386 dt.issue-closed { background-image: url(../images/ticket_checked.png); }
387 387 dt.issue-note { background-image: url(../images/ticket_note.png); }
388 388 dt.changeset { background-image: url(../images/changeset.png); }
389 389 dt.news { background-image: url(../images/news.png); }
390 390 dt.message { background-image: url(../images/message.png); }
391 391 dt.reply { background-image: url(../images/comments.png); }
392 392 dt.wiki-page { background-image: url(../images/wiki_edit.png); }
393 393 dt.attachment { background-image: url(../images/attachment.png); }
394 394 dt.document { background-image: url(../images/document.png); }
395 395 dt.project { background-image: url(../images/projects.png); }
396 396 dt.time-entry { background-image: url(../images/time.png); }
397 397
398 398 #search-results dt.issue.closed { background-image: url(../images/ticket_checked.png); }
399 399
400 400 div#roadmap .related-issues { margin-bottom: 1em; }
401 401 div#roadmap .related-issues td.checkbox { display: none; }
402 402 div#roadmap .wiki h1:first-child { display: none; }
403 403 div#roadmap .wiki h1 { font-size: 120%; }
404 404 div#roadmap .wiki h2 { font-size: 110%; }
405 405 body.controller-versions.action-show div#roadmap .related-issues {width:70%;}
406 406
407 407 div#version-summary { float:right; width:28%; margin-left: 16px; margin-bottom: 16px; background-color: #fff; }
408 408 div#version-summary fieldset { margin-bottom: 1em; }
409 409 div#version-summary fieldset.time-tracking table { width:100%; }
410 410 div#version-summary th, div#version-summary td.total-hours { text-align: right; }
411 411
412 412 table#time-report td.hours, table#time-report th.period, table#time-report th.total { text-align: right; padding-right: 0.5em; }
413 413 table#time-report tbody tr.subtotal { font-style: italic; color:#777;}
414 414 table#time-report tbody tr.subtotal td.hours { color:#b0b0b0; }
415 415 table#time-report tbody tr.total { font-weight: bold; background-color:#EEEEEE; border-top:1px solid #e4e4e4;}
416 416 table#time-report .hours-dec { font-size: 0.9em; }
417 417
418 418 div.wiki-page .contextual a {opacity: 0.4}
419 419 div.wiki-page .contextual a:hover {opacity: 1}
420 420
421 421 form .attributes select { width: 60%; }
422 422 input#issue_subject { width: 99%; }
423 423 select#issue_done_ratio { width: 95px; }
424 424
425 425 ul.projects {margin:0; padding-left:1em;}
426 426 ul.projects ul {padding-left:1.6em;}
427 427 ul.projects.root {margin:0; padding:0;}
428 428 ul.projects li {list-style-type:none;}
429 429
430 430 #projects-index ul.projects ul.projects { border-left: 3px solid #e0e0e0; padding-left:1em;}
431 431 #projects-index ul.projects li.root {margin-bottom: 1em;}
432 432 #projects-index ul.projects li.child {margin-top: 1em;}
433 433 #projects-index ul.projects div.root a.project { font-family: "Trebuchet MS", Verdana, sans-serif; font-weight: bold; font-size: 16px; margin: 0 0 10px 0; }
434 434 .my-project { padding-left: 18px; background: url(../images/fav.png) no-repeat 0 50%; }
435 435
436 436 #notified-projects ul, #tracker_project_ids ul {max-height:250px; overflow-y:auto;}
437 437
438 438 #related-issues li img {vertical-align:middle;}
439 439
440 440 ul.properties {padding:0; font-size: 0.9em; color: #777;}
441 441 ul.properties li {list-style-type:none;}
442 442 ul.properties li span {font-style:italic;}
443 443
444 444 .total-hours { font-size: 110%; font-weight: bold; }
445 445 .total-hours span.hours-int { font-size: 120%; }
446 446
447 447 .autoscroll {overflow-x: auto; padding:1px; margin-bottom: 1.2em;}
448 448 #user_login, #user_firstname, #user_lastname, #user_mail, #my_account_form select, #user_form select, #user_identity_url { width: 90%; }
449 449
450 450 #workflow_copy_form select { width: 200px; }
451 451 table.transitions td.enabled {background: #bfb;}
452 452 table.fields_permissions select {font-size:90%}
453 453 table.fields_permissions td.readonly {background:#ddd;}
454 454 table.fields_permissions td.required {background:#d88;}
455 455
456 456 textarea#custom_field_possible_values {width: 99%}
457 457 input#content_comments {width: 99%}
458 458
459 459 p.pagination {margin-top:8px; font-size: 90%}
460 460
461 461 /***** Tabular forms ******/
462 462 .tabular p{
463 463 margin: 0;
464 464 padding: 3px 0 3px 0;
465 465 padding-left: 180px; /* width of left column containing the label elements */
466 466 min-height: 1.8em;
467 467 clear:left;
468 468 }
469 469
470 470 html>body .tabular p {overflow:hidden;}
471 471
472 472 .tabular label{
473 473 font-weight: bold;
474 474 float: left;
475 475 text-align: right;
476 476 /* width of left column */
477 477 margin-left: -180px;
478 478 /* width of labels. Should be smaller than left column to create some right margin */
479 479 width: 175px;
480 480 }
481 481
482 482 .tabular label.floating{
483 483 font-weight: normal;
484 484 margin-left: 0px;
485 485 text-align: left;
486 486 width: 270px;
487 487 }
488 488
489 489 .tabular label.block{
490 490 font-weight: normal;
491 491 margin-left: 0px !important;
492 492 text-align: left;
493 493 float: none;
494 494 display: block;
495 495 width: auto;
496 496 }
497 497
498 498 .tabular label.inline{
499 499 font-weight: normal;
500 500 float:none;
501 501 margin-left: 5px !important;
502 502 width: auto;
503 503 }
504 504
505 505 label.no-css {
506 506 font-weight: inherit;
507 507 float:none;
508 508 text-align:left;
509 509 margin-left:0px;
510 510 width:auto;
511 511 }
512 512 input#time_entry_comments { width: 90%;}
513 513
514 514 #preview fieldset {margin-top: 1em; background: url(../images/draft.png)}
515 515
516 516 .tabular.settings p{ padding-left: 300px; }
517 517 .tabular.settings label{ margin-left: -300px; width: 295px; }
518 518 .tabular.settings textarea { width: 99%; }
519 519
520 520 .settings.enabled_scm table {width:100%}
521 521 .settings.enabled_scm td.scm_name{ font-weight: bold; }
522 522
523 523 fieldset.settings label { display: block; }
524 524 fieldset#notified_events .parent { padding-left: 20px; }
525 525
526 526 span.required {color: #bb0000;}
527 527 .summary {font-style: italic;}
528 528
529 529 #attachments_fields input.description {margin-left:4px; width:340px;}
530 530 #attachments_fields span {display:block; white-space:nowrap;}
531 531 #attachments_fields input.filename {border:0; height:1.8em; width:250px; color:#555; background-color:inherit; background:url(../images/attachment.png) no-repeat 1px 50%; padding-left:18px;}
532 532 #attachments_fields .ajax-waiting input.filename {background:url(../images/hourglass.png) no-repeat 0px 50%;}
533 533 #attachments_fields .ajax-loading input.filename {background:url(../images/loading.gif) no-repeat 0px 50%;}
534 534 #attachments_fields div.ui-progressbar { width: 100px; height:14px; margin: 2px 0 -5px 8px; display: inline-block; }
535 535 a.remove-upload {background: url(../images/delete.png) no-repeat 1px 50%; width:1px; display:inline-block; padding-left:16px;}
536 536 a.remove-upload:hover {text-decoration:none !important;}
537 537
538 538 div.fileover { background-color: lavender; }
539 539
540 540 div.attachments { margin-top: 12px; }
541 541 div.attachments p { margin:4px 0 2px 0; }
542 542 div.attachments img { vertical-align: middle; }
543 543 div.attachments span.author { font-size: 0.9em; color: #888; }
544 544
545 545 div.thumbnails {margin-top:0.6em;}
546 546 div.thumbnails div {background:#fff;border:2px solid #ddd;display:inline-block;margin-right:2px;}
547 547 div.thumbnails img {margin: 3px;}
548 548
549 549 p.other-formats { text-align: right; font-size:0.9em; color: #666; }
550 550 .other-formats span + span:before { content: "| "; }
551 551
552 552 a.atom { background: url(../images/feed.png) no-repeat 1px 50%; padding: 2px 0px 3px 16px; }
553 553
554 554 em.info {font-style:normal;font-size:90%;color:#888;display:block;}
555 555 em.info.error {padding-left:20px; background:url(../images/exclamation.png) no-repeat 0 50%;}
556 556
557 557 textarea.text_cf {width:90%;}
558 558
559 559 /* Project members tab */
560 560 div#tab-content-members .splitcontentleft, div#tab-content-memberships .splitcontentleft, div#tab-content-users .splitcontentleft { width: 64% }
561 561 div#tab-content-members .splitcontentright, div#tab-content-memberships .splitcontentright, div#tab-content-users .splitcontentright { width: 34% }
562 562 div#tab-content-members fieldset, div#tab-content-memberships fieldset, div#tab-content-users fieldset { padding:1em; margin-bottom: 1em; }
563 563 div#tab-content-members fieldset legend, div#tab-content-memberships fieldset legend, div#tab-content-users fieldset legend { font-weight: bold; }
564 564 div#tab-content-members fieldset label, div#tab-content-memberships fieldset label, div#tab-content-users fieldset label { display: block; }
565 565 div#tab-content-members fieldset div, div#tab-content-users fieldset div { max-height: 400px; overflow:auto; }
566 566
567 567 #users_for_watcher {height: 200px; overflow:auto;}
568 568 #users_for_watcher label {display: block;}
569 569
570 570 table.members td.group { padding-left: 20px; background: url(../images/group.png) no-repeat 0% 50%; }
571 571
572 572 input#principal_search, input#user_search {width:90%}
573 573
574 574 input.autocomplete {
575 575 background: #fff url(../images/magnifier.png) no-repeat 2px 50%; padding-left:20px;
576 576 border:1px solid #9EB1C2; border-radius:2px; height:1.5em;
577 577 }
578 578 input.autocomplete.ajax-loading {
579 579 background-image: url(../images/loading.gif);
580 580 }
581 581
582 582 * html div#tab-content-members fieldset div { height: 450px; }
583 583
584 584 /***** Flash & error messages ****/
585 585 #errorExplanation, div.flash, .nodata, .warning, .conflict {
586 586 padding: 4px 4px 4px 30px;
587 587 margin-bottom: 12px;
588 588 font-size: 1.1em;
589 589 border: 2px solid;
590 590 }
591 591
592 592 div.flash {margin-top: 8px;}
593 593
594 594 div.flash.error, #errorExplanation {
595 595 background: url(../images/exclamation.png) 8px 50% no-repeat;
596 596 background-color: #ffe3e3;
597 597 border-color: #dd0000;
598 598 color: #880000;
599 599 }
600 600
601 601 div.flash.notice {
602 602 background: url(../images/true.png) 8px 5px no-repeat;
603 603 background-color: #dfffdf;
604 604 border-color: #9fcf9f;
605 605 color: #005f00;
606 606 }
607 607
608 608 div.flash.warning, .conflict {
609 609 background: url(../images/warning.png) 8px 5px no-repeat;
610 610 background-color: #FFEBC1;
611 611 border-color: #FDBF3B;
612 612 color: #A6750C;
613 613 text-align: left;
614 614 }
615 615
616 616 .nodata, .warning {
617 617 text-align: center;
618 618 background-color: #FFEBC1;
619 619 border-color: #FDBF3B;
620 620 color: #A6750C;
621 621 }
622 622
623 623 #errorExplanation ul { font-size: 0.9em;}
624 624 #errorExplanation h2, #errorExplanation p { display: none; }
625 625
626 626 .conflict-details {font-size:80%;}
627 627
628 628 /***** Ajax indicator ******/
629 629 #ajax-indicator {
630 630 position: absolute; /* fixed not supported by IE */
631 631 background-color:#eee;
632 632 border: 1px solid #bbb;
633 633 top:35%;
634 634 left:40%;
635 635 width:20%;
636 636 font-weight:bold;
637 637 text-align:center;
638 638 padding:0.6em;
639 639 z-index:100;
640 640 opacity: 0.5;
641 641 }
642 642
643 643 html>body #ajax-indicator { position: fixed; }
644 644
645 645 #ajax-indicator span {
646 646 background-position: 0% 40%;
647 647 background-repeat: no-repeat;
648 648 background-image: url(../images/loading.gif);
649 649 padding-left: 26px;
650 650 vertical-align: bottom;
651 651 }
652 652
653 653 /***** Calendar *****/
654 654 table.cal {border-collapse: collapse; width: 100%; margin: 0px 0 6px 0;border: 1px solid #d7d7d7;}
655 655 table.cal thead th {width: 14%; background-color:#EEEEEE; padding: 4px; }
656 656 table.cal thead th.week-number {width: auto;}
657 657 table.cal tbody tr {height: 100px;}
658 658 table.cal td {border: 1px solid #d7d7d7; vertical-align: top; font-size: 0.9em;}
659 659 table.cal td.week-number { background-color:#EEEEEE; padding: 4px; border:none; font-size: 1em;}
660 660 table.cal td p.day-num {font-size: 1.1em; text-align:right;}
661 661 table.cal td.odd p.day-num {color: #bbb;}
662 662 table.cal td.today {background:#ffffdd;}
663 663 table.cal td.today p.day-num {font-weight: bold;}
664 664 table.cal .starting a, p.cal.legend .starting {background: url(../images/bullet_go.png) no-repeat -1px -2px; padding-left:16px;}
665 665 table.cal .ending a, p.cal.legend .ending {background: url(../images/bullet_end.png) no-repeat -1px -2px; padding-left:16px;}
666 666 table.cal .starting.ending a, p.cal.legend .starting.ending {background: url(../images/bullet_diamond.png) no-repeat -1px -2px; padding-left:16px;}
667 667 p.cal.legend span {display:block;}
668 668
669 669 /***** Tooltips ******/
670 670 .tooltip{position:relative;z-index:24;}
671 671 .tooltip:hover{z-index:25;color:#000;}
672 672 .tooltip span.tip{display: none; text-align:left;}
673 673
674 674 div.tooltip:hover span.tip{
675 675 display:block;
676 676 position:absolute;
677 677 top:12px; left:24px; width:270px;
678 678 border:1px solid #555;
679 679 background-color:#fff;
680 680 padding: 4px;
681 681 font-size: 0.8em;
682 682 color:#505050;
683 683 }
684 684
685 685 img.ui-datepicker-trigger {
686 686 cursor: pointer;
687 687 vertical-align: middle;
688 688 margin-left: 4px;
689 689 }
690 690
691 691 /***** Progress bar *****/
692 692 table.progress {
693 693 border-collapse: collapse;
694 694 border-spacing: 0pt;
695 695 empty-cells: show;
696 696 text-align: center;
697 697 float:left;
698 698 margin: 1px 6px 1px 0px;
699 699 }
700 700
701 701 table.progress td { height: 1em; }
702 702 table.progress td.closed { background: #BAE0BA none repeat scroll 0%; }
703 703 table.progress td.done { background: #D3EDD3 none repeat scroll 0%; }
704 704 table.progress td.todo { background: #eee none repeat scroll 0%; }
705 p.pourcent {font-size: 80%;}
705 p.percent {font-size: 80%;}
706 706 p.progress-info {clear: left; font-size: 80%; margin-top:-4px; color:#777;}
707 707
708 708 #roadmap table.progress td { height: 1.2em; }
709 709 /***** Tabs *****/
710 710 #content .tabs {height: 2.6em; margin-bottom:1.2em; position:relative; overflow:hidden;}
711 711 #content .tabs ul {margin:0; position:absolute; bottom:0; padding-left:0.5em; width: 2000px; border-bottom: 1px solid #bbbbbb;}
712 712 #content .tabs ul li {
713 713 float:left;
714 714 list-style-type:none;
715 715 white-space:nowrap;
716 716 margin-right:4px;
717 717 background:#fff;
718 718 position:relative;
719 719 margin-bottom:-1px;
720 720 }
721 721 #content .tabs ul li a{
722 722 display:block;
723 723 font-size: 0.9em;
724 724 text-decoration:none;
725 725 line-height:1.3em;
726 726 padding:4px 6px 4px 6px;
727 727 border: 1px solid #ccc;
728 728 border-bottom: 1px solid #bbbbbb;
729 729 background-color: #f6f6f6;
730 730 color:#999;
731 731 font-weight:bold;
732 732 border-top-left-radius:3px;
733 733 border-top-right-radius:3px;
734 734 }
735 735
736 736 #content .tabs ul li a:hover {
737 737 background-color: #ffffdd;
738 738 text-decoration:none;
739 739 }
740 740
741 741 #content .tabs ul li a.selected {
742 742 background-color: #fff;
743 743 border: 1px solid #bbbbbb;
744 744 border-bottom: 1px solid #fff;
745 745 color:#444;
746 746 }
747 747
748 748 #content .tabs ul li a.selected:hover {background-color: #fff;}
749 749
750 750 div.tabs-buttons { position:absolute; right: 0; width: 48px; height: 24px; background: white; bottom: 0; border-bottom: 1px solid #bbbbbb; }
751 751
752 752 button.tab-left, button.tab-right {
753 753 font-size: 0.9em;
754 754 cursor: pointer;
755 755 height:24px;
756 756 border: 1px solid #ccc;
757 757 border-bottom: 1px solid #bbbbbb;
758 758 position:absolute;
759 759 padding:4px;
760 760 width: 20px;
761 761 bottom: -1px;
762 762 }
763 763
764 764 button.tab-left {
765 765 right: 20px;
766 766 background: #eeeeee url(../images/bullet_arrow_left.png) no-repeat 50% 50%;
767 767 border-top-left-radius:3px;
768 768 }
769 769
770 770 button.tab-right {
771 771 right: 0;
772 772 background: #eeeeee url(../images/bullet_arrow_right.png) no-repeat 50% 50%;
773 773 border-top-right-radius:3px;
774 774 }
775 775
776 776 /***** Diff *****/
777 777 .diff_out { background: #fcc; }
778 778 .diff_out span { background: #faa; }
779 779 .diff_in { background: #cfc; }
780 780 .diff_in span { background: #afa; }
781 781
782 782 .text-diff {
783 783 padding: 1em;
784 784 background-color:#f6f6f6;
785 785 color:#505050;
786 786 border: 1px solid #e4e4e4;
787 787 }
788 788
789 789 /***** Wiki *****/
790 790 div.wiki table {
791 791 border-collapse: collapse;
792 792 margin-bottom: 1em;
793 793 }
794 794
795 795 div.wiki table, div.wiki td, div.wiki th {
796 796 border: 1px solid #bbb;
797 797 padding: 4px;
798 798 }
799 799
800 800 div.wiki .noborder, div.wiki .noborder td, div.wiki .noborder th {border:0;}
801 801
802 802 div.wiki .external {
803 803 background-position: 0% 60%;
804 804 background-repeat: no-repeat;
805 805 padding-left: 12px;
806 806 background-image: url(../images/external.png);
807 807 }
808 808
809 809 div.wiki a.new {color: #b73535;}
810 810
811 811 div.wiki ul, div.wiki ol {margin-bottom:1em;}
812 812
813 813 div.wiki pre {
814 814 margin: 1em 1em 1em 1.6em;
815 815 padding: 8px;
816 816 background-color: #fafafa;
817 817 border: 1px solid #e2e2e2;
818 818 width:auto;
819 819 overflow-x: auto;
820 820 overflow-y: hidden;
821 821 }
822 822
823 823 div.wiki ul.toc {
824 824 background-color: #ffffdd;
825 825 border: 1px solid #e4e4e4;
826 826 padding: 4px;
827 827 line-height: 1.2em;
828 828 margin-bottom: 12px;
829 829 margin-right: 12px;
830 830 margin-left: 0;
831 831 display: table
832 832 }
833 833 * html div.wiki ul.toc { width: 50%; } /* IE6 doesn't autosize div */
834 834
835 835 div.wiki ul.toc.right { float: right; margin-left: 12px; margin-right: 0; width: auto; }
836 836 div.wiki ul.toc.left { float: left; margin-right: 12px; margin-left: 0; width: auto; }
837 837 div.wiki ul.toc ul { margin: 0; padding: 0; }
838 838 div.wiki ul.toc li {list-style-type:none; margin: 0; font-size:12px;}
839 839 div.wiki ul.toc li li {margin-left: 1.5em; font-size:10px;}
840 840 div.wiki ul.toc a {
841 841 font-size: 0.9em;
842 842 font-weight: normal;
843 843 text-decoration: none;
844 844 color: #606060;
845 845 }
846 846 div.wiki ul.toc a:hover { color: #c61a1a; text-decoration: underline;}
847 847
848 848 a.wiki-anchor { display: none; margin-left: 6px; text-decoration: none; }
849 849 a.wiki-anchor:hover { color: #aaa !important; text-decoration: none; }
850 850 h1:hover a.wiki-anchor, h2:hover a.wiki-anchor, h3:hover a.wiki-anchor { display: inline; color: #ddd; }
851 851
852 852 div.wiki img { vertical-align: middle; }
853 853
854 854 /***** My page layout *****/
855 855 .block-receiver {
856 856 border:1px dashed #c0c0c0;
857 857 margin-bottom: 20px;
858 858 padding: 15px 0 15px 0;
859 859 }
860 860
861 861 .mypage-box {
862 862 margin:0 0 20px 0;
863 863 color:#505050;
864 864 line-height:1.5em;
865 865 }
866 866
867 867 .handle {cursor: move;}
868 868
869 869 a.close-icon {
870 870 display:block;
871 871 margin-top:3px;
872 872 overflow:hidden;
873 873 width:12px;
874 874 height:12px;
875 875 background-repeat: no-repeat;
876 876 cursor:pointer;
877 877 background-image:url('../images/close.png');
878 878 }
879 879 a.close-icon:hover {background-image:url('../images/close_hl.png');}
880 880
881 881 /***** Gantt chart *****/
882 882 .gantt_hdr {
883 883 position:absolute;
884 884 top:0;
885 885 height:16px;
886 886 border-top: 1px solid #c0c0c0;
887 887 border-bottom: 1px solid #c0c0c0;
888 888 border-right: 1px solid #c0c0c0;
889 889 text-align: center;
890 890 overflow: hidden;
891 891 }
892 892
893 893 .gantt_hdr.nwday {background-color:#f1f1f1;}
894 894
895 895 .gantt_subjects { font-size: 0.8em; }
896 896 .gantt_subjects div { line-height:16px;height:16px;overflow:hidden;white-space:nowrap;text-overflow: ellipsis; }
897 897
898 898 .task {
899 899 position: absolute;
900 900 height:8px;
901 901 font-size:0.8em;
902 902 color:#888;
903 903 padding:0;
904 904 margin:0;
905 905 line-height:16px;
906 906 white-space:nowrap;
907 907 }
908 908
909 909 .task.label {width:100%;}
910 910 .task.label.project, .task.label.version { font-weight: bold; }
911 911
912 912 .task_late { background:#f66 url(../images/task_late.png); border: 1px solid #f66; }
913 913 .task_done { background:#00c600 url(../images/task_done.png); border: 1px solid #00c600; }
914 914 .task_todo { background:#aaa url(../images/task_todo.png); border: 1px solid #aaa; }
915 915
916 916 .task_todo.parent { background: #888; border: 1px solid #888; height: 3px;}
917 917 .task_late.parent, .task_done.parent { height: 3px;}
918 918 .task.parent.marker.starting { position: absolute; background: url(../images/task_parent_end.png) no-repeat 0 0; width: 8px; height: 16px; margin-left: -4px; left: 0px; top: -1px;}
919 919 .task.parent.marker.ending { position: absolute; background: url(../images/task_parent_end.png) no-repeat 0 0; width: 8px; height: 16px; margin-left: -4px; right: 0px; top: -1px;}
920 920
921 921 .version.task_late { background:#f66 url(../images/milestone_late.png); border: 1px solid #f66; height: 2px; margin-top: 3px;}
922 922 .version.task_done { background:#00c600 url(../images/milestone_done.png); border: 1px solid #00c600; height: 2px; margin-top: 3px;}
923 923 .version.task_todo { background:#fff url(../images/milestone_todo.png); border: 1px solid #fff; height: 2px; margin-top: 3px;}
924 924 .version.marker { background-image:url(../images/version_marker.png); background-repeat: no-repeat; border: 0; margin-left: -4px; margin-top: 1px; }
925 925
926 926 .project.task_late { background:#f66 url(../images/milestone_late.png); border: 1px solid #f66; height: 2px; margin-top: 3px;}
927 927 .project.task_done { background:#00c600 url(../images/milestone_done.png); border: 1px solid #00c600; height: 2px; margin-top: 3px;}
928 928 .project.task_todo { background:#fff url(../images/milestone_todo.png); border: 1px solid #fff; height: 2px; margin-top: 3px;}
929 929 .project.marker { background-image:url(../images/project_marker.png); background-repeat: no-repeat; border: 0; margin-left: -4px; margin-top: 1px; }
930 930
931 931 .version-behind-schedule a, .issue-behind-schedule a {color: #f66914;}
932 932 .version-overdue a, .issue-overdue a, .project-overdue a {color: #f00;}
933 933
934 934 /***** Icons *****/
935 935 .icon {
936 936 background-position: 0% 50%;
937 937 background-repeat: no-repeat;
938 938 padding-left: 20px;
939 939 padding-top: 2px;
940 940 padding-bottom: 3px;
941 941 }
942 942
943 943 .icon-add { background-image: url(../images/add.png); }
944 944 .icon-edit { background-image: url(../images/edit.png); }
945 945 .icon-copy { background-image: url(../images/copy.png); }
946 946 .icon-duplicate { background-image: url(../images/duplicate.png); }
947 947 .icon-del { background-image: url(../images/delete.png); }
948 948 .icon-move { background-image: url(../images/move.png); }
949 949 .icon-save { background-image: url(../images/save.png); }
950 950 .icon-cancel { background-image: url(../images/cancel.png); }
951 951 .icon-multiple { background-image: url(../images/table_multiple.png); }
952 952 .icon-folder { background-image: url(../images/folder.png); }
953 953 .open .icon-folder { background-image: url(../images/folder_open.png); }
954 954 .icon-package { background-image: url(../images/package.png); }
955 955 .icon-user { background-image: url(../images/user.png); }
956 956 .icon-projects { background-image: url(../images/projects.png); }
957 957 .icon-help { background-image: url(../images/help.png); }
958 958 .icon-attachment { background-image: url(../images/attachment.png); }
959 959 .icon-history { background-image: url(../images/history.png); }
960 960 .icon-time { background-image: url(../images/time.png); }
961 961 .icon-time-add { background-image: url(../images/time_add.png); }
962 962 .icon-stats { background-image: url(../images/stats.png); }
963 963 .icon-warning { background-image: url(../images/warning.png); }
964 964 .icon-fav { background-image: url(../images/fav.png); }
965 965 .icon-fav-off { background-image: url(../images/fav_off.png); }
966 966 .icon-reload { background-image: url(../images/reload.png); }
967 967 .icon-lock { background-image: url(../images/locked.png); }
968 968 .icon-unlock { background-image: url(../images/unlock.png); }
969 969 .icon-checked { background-image: url(../images/true.png); }
970 970 .icon-details { background-image: url(../images/zoom_in.png); }
971 971 .icon-report { background-image: url(../images/report.png); }
972 972 .icon-comment { background-image: url(../images/comment.png); }
973 973 .icon-summary { background-image: url(../images/lightning.png); }
974 974 .icon-server-authentication { background-image: url(../images/server_key.png); }
975 975 .icon-issue { background-image: url(../images/ticket.png); }
976 976 .icon-zoom-in { background-image: url(../images/zoom_in.png); }
977 977 .icon-zoom-out { background-image: url(../images/zoom_out.png); }
978 978 .icon-passwd { background-image: url(../images/textfield_key.png); }
979 979 .icon-test { background-image: url(../images/bullet_go.png); }
980 980
981 981 .icon-file { background-image: url(../images/files/default.png); }
982 982 .icon-file.text-plain { background-image: url(../images/files/text.png); }
983 983 .icon-file.text-x-c { background-image: url(../images/files/c.png); }
984 984 .icon-file.text-x-csharp { background-image: url(../images/files/csharp.png); }
985 985 .icon-file.text-x-java { background-image: url(../images/files/java.png); }
986 986 .icon-file.text-x-javascript { background-image: url(../images/files/js.png); }
987 987 .icon-file.text-x-php { background-image: url(../images/files/php.png); }
988 988 .icon-file.text-x-ruby { background-image: url(../images/files/ruby.png); }
989 989 .icon-file.text-xml { background-image: url(../images/files/xml.png); }
990 990 .icon-file.text-css { background-image: url(../images/files/css.png); }
991 991 .icon-file.text-html { background-image: url(../images/files/html.png); }
992 992 .icon-file.image-gif { background-image: url(../images/files/image.png); }
993 993 .icon-file.image-jpeg { background-image: url(../images/files/image.png); }
994 994 .icon-file.image-png { background-image: url(../images/files/image.png); }
995 995 .icon-file.image-tiff { background-image: url(../images/files/image.png); }
996 996 .icon-file.application-pdf { background-image: url(../images/files/pdf.png); }
997 997 .icon-file.application-zip { background-image: url(../images/files/zip.png); }
998 998 .icon-file.application-x-gzip { background-image: url(../images/files/zip.png); }
999 999
1000 1000 img.gravatar {
1001 1001 padding: 2px;
1002 1002 border: solid 1px #d5d5d5;
1003 1003 background: #fff;
1004 1004 vertical-align: middle;
1005 1005 }
1006 1006
1007 1007 div.issue img.gravatar {
1008 1008 float: left;
1009 1009 margin: 0 6px 0 0;
1010 1010 padding: 5px;
1011 1011 }
1012 1012
1013 1013 div.issue table img.gravatar {
1014 1014 height: 14px;
1015 1015 width: 14px;
1016 1016 padding: 2px;
1017 1017 float: left;
1018 1018 margin: 0 0.5em 0 0;
1019 1019 }
1020 1020
1021 1021 h2 img.gravatar {margin: -2px 4px -4px 0;}
1022 1022 h3 img.gravatar {margin: -4px 4px -4px 0;}
1023 1023 h4 img.gravatar {margin: -6px 4px -4px 0;}
1024 1024 td.username img.gravatar {margin: 0 0.5em 0 0; vertical-align: top;}
1025 1025 #activity dt img.gravatar {float: left; margin: 0 1em 1em 0;}
1026 1026 /* Used on 12px Gravatar img tags without the icon background */
1027 1027 .icon-gravatar {float: left; margin-right: 4px;}
1028 1028
1029 1029 #activity dt, .journal {clear: left;}
1030 1030
1031 1031 .journal-link {float: right;}
1032 1032
1033 1033 h2 img { vertical-align:middle; }
1034 1034
1035 1035 .hascontextmenu { cursor: context-menu; }
1036 1036
1037 1037 /************* CodeRay styles *************/
1038 1038 .syntaxhl div {display: inline;}
1039 1039 .syntaxhl .line-numbers {padding: 2px 4px 2px 4px; background-color: #eee; margin:0px 5px 0px 0px;}
1040 1040 .syntaxhl .code pre { overflow: auto }
1041 1041 .syntaxhl .debug { color: white !important; background: blue !important; }
1042 1042
1043 1043 .syntaxhl .annotation { color:#007 }
1044 1044 .syntaxhl .attribute-name { color:#b48 }
1045 1045 .syntaxhl .attribute-value { color:#700 }
1046 1046 .syntaxhl .binary { color:#509 }
1047 1047 .syntaxhl .char .content { color:#D20 }
1048 1048 .syntaxhl .char .delimiter { color:#710 }
1049 1049 .syntaxhl .char { color:#D20 }
1050 1050 .syntaxhl .class { color:#258; font-weight:bold }
1051 1051 .syntaxhl .class-variable { color:#369 }
1052 1052 .syntaxhl .color { color:#0A0 }
1053 1053 .syntaxhl .comment { color:#385 }
1054 1054 .syntaxhl .comment .char { color:#385 }
1055 1055 .syntaxhl .comment .delimiter { color:#385 }
1056 1056 .syntaxhl .complex { color:#A08 }
1057 1057 .syntaxhl .constant { color:#258; font-weight:bold }
1058 1058 .syntaxhl .decorator { color:#B0B }
1059 1059 .syntaxhl .definition { color:#099; font-weight:bold }
1060 1060 .syntaxhl .delimiter { color:black }
1061 1061 .syntaxhl .directive { color:#088; font-weight:bold }
1062 1062 .syntaxhl .doc { color:#970 }
1063 1063 .syntaxhl .doc-string { color:#D42; font-weight:bold }
1064 1064 .syntaxhl .doctype { color:#34b }
1065 1065 .syntaxhl .entity { color:#800; font-weight:bold }
1066 1066 .syntaxhl .error { color:#F00; background-color:#FAA }
1067 1067 .syntaxhl .escape { color:#666 }
1068 1068 .syntaxhl .exception { color:#C00; font-weight:bold }
1069 1069 .syntaxhl .float { color:#06D }
1070 1070 .syntaxhl .function { color:#06B; font-weight:bold }
1071 1071 .syntaxhl .global-variable { color:#d70 }
1072 1072 .syntaxhl .hex { color:#02b }
1073 1073 .syntaxhl .imaginary { color:#f00 }
1074 1074 .syntaxhl .include { color:#B44; font-weight:bold }
1075 1075 .syntaxhl .inline { background-color: hsla(0,0%,0%,0.07); color: black }
1076 1076 .syntaxhl .inline-delimiter { font-weight: bold; color: #666 }
1077 1077 .syntaxhl .instance-variable { color:#33B }
1078 1078 .syntaxhl .integer { color:#06D }
1079 1079 .syntaxhl .key .char { color: #60f }
1080 1080 .syntaxhl .key .delimiter { color: #404 }
1081 1081 .syntaxhl .key { color: #606 }
1082 1082 .syntaxhl .keyword { color:#939; font-weight:bold }
1083 1083 .syntaxhl .label { color:#970; font-weight:bold }
1084 1084 .syntaxhl .local-variable { color:#963 }
1085 1085 .syntaxhl .namespace { color:#707; font-weight:bold }
1086 1086 .syntaxhl .octal { color:#40E }
1087 1087 .syntaxhl .operator { }
1088 1088 .syntaxhl .predefined { color:#369; font-weight:bold }
1089 1089 .syntaxhl .predefined-constant { color:#069 }
1090 1090 .syntaxhl .predefined-type { color:#0a5; font-weight:bold }
1091 1091 .syntaxhl .preprocessor { color:#579 }
1092 1092 .syntaxhl .pseudo-class { color:#00C; font-weight:bold }
1093 1093 .syntaxhl .regexp .content { color:#808 }
1094 1094 .syntaxhl .regexp .delimiter { color:#404 }
1095 1095 .syntaxhl .regexp .modifier { color:#C2C }
1096 1096 .syntaxhl .regexp { background-color:hsla(300,100%,50%,0.06); }
1097 1097 .syntaxhl .reserved { color:#080; font-weight:bold }
1098 1098 .syntaxhl .shell .content { color:#2B2 }
1099 1099 .syntaxhl .shell .delimiter { color:#161 }
1100 1100 .syntaxhl .shell { background-color:hsla(120,100%,50%,0.06); }
1101 1101 .syntaxhl .string .char { color: #46a }
1102 1102 .syntaxhl .string .content { color: #46a }
1103 1103 .syntaxhl .string .delimiter { color: #46a }
1104 1104 .syntaxhl .string .modifier { color: #46a }
1105 1105 .syntaxhl .symbol .content { color:#d33 }
1106 1106 .syntaxhl .symbol .delimiter { color:#d33 }
1107 1107 .syntaxhl .symbol { color:#d33 }
1108 1108 .syntaxhl .tag { color:#070 }
1109 1109 .syntaxhl .type { color:#339; font-weight:bold }
1110 1110 .syntaxhl .value { color: #088; }
1111 1111 .syntaxhl .variable { color:#037 }
1112 1112
1113 1113 .syntaxhl .insert { background: hsla(120,100%,50%,0.12) }
1114 1114 .syntaxhl .delete { background: hsla(0,100%,50%,0.12) }
1115 1115 .syntaxhl .change { color: #bbf; background: #007; }
1116 1116 .syntaxhl .head { color: #f8f; background: #505 }
1117 1117 .syntaxhl .head .filename { color: white; }
1118 1118
1119 1119 .syntaxhl .delete .eyecatcher { background-color: hsla(0,100%,50%,0.2); border: 1px solid hsla(0,100%,45%,0.5); margin: -1px; border-bottom: none; border-top-left-radius: 5px; border-top-right-radius: 5px; }
1120 1120 .syntaxhl .insert .eyecatcher { background-color: hsla(120,100%,50%,0.2); border: 1px solid hsla(120,100%,25%,0.5); margin: -1px; border-top: none; border-bottom-left-radius: 5px; border-bottom-right-radius: 5px; }
1121 1121
1122 1122 .syntaxhl .insert .insert { color: #0c0; background:transparent; font-weight:bold }
1123 1123 .syntaxhl .delete .delete { color: #c00; background:transparent; font-weight:bold }
1124 1124 .syntaxhl .change .change { color: #88f }
1125 1125 .syntaxhl .head .head { color: #f4f }
1126 1126
1127 1127 /***** Media print specific styles *****/
1128 1128 @media print {
1129 1129 #top-menu, #header, #main-menu, #sidebar, #footer, .contextual, .other-formats { display:none; }
1130 1130 #main { background: #fff; }
1131 1131 #content { width: 99%; margin: 0; padding: 0; border: 0; background: #fff; overflow: visible !important;}
1132 1132 #wiki_add_attachment { display:none; }
1133 1133 .hide-when-print { display: none; }
1134 1134 .autoscroll {overflow-x: visible;}
1135 1135 table.list {margin-top:0.5em;}
1136 1136 table.list th, table.list td {border: 1px solid #aaa;}
1137 1137 }
1138 1138
1139 1139 /* Accessibility specific styles */
1140 1140 .hidden-for-sighted {
1141 1141 position:absolute;
1142 1142 left:-10000px;
1143 1143 top:auto;
1144 1144 width:1px;
1145 1145 height:1px;
1146 1146 overflow:hidden;
1147 1147 }
@@ -1,252 +1,252
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 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 VersionTest < ActiveSupport::TestCase
21 21 fixtures :projects, :users, :issues, :issue_statuses, :trackers, :enumerations, :versions, :projects_trackers
22 22
23 23 def setup
24 24 end
25 25
26 26 def test_create
27 27 v = Version.new(:project => Project.find(1), :name => '1.1', :effective_date => '2011-03-25')
28 28 assert v.save
29 29 assert_equal 'open', v.status
30 30 assert_equal 'none', v.sharing
31 31 end
32 32
33 33 def test_invalid_effective_date_validation
34 34 v = Version.new(:project => Project.find(1), :name => '1.1', :effective_date => '99999-01-01')
35 35 assert !v.valid?
36 36 v.effective_date = '2012-11-33'
37 37 assert !v.valid?
38 38 v.effective_date = '2012-31-11'
39 39 assert !v.valid?
40 40 v.effective_date = 'ABC'
41 41 assert !v.valid?
42 42 assert_include I18n.translate('activerecord.errors.messages.not_a_date'),
43 43 v.errors[:effective_date]
44 44 end
45 45
46 46 def test_progress_should_be_0_with_no_assigned_issues
47 47 project = Project.find(1)
48 48 v = Version.create!(:project => project, :name => 'Progress')
49 assert_equal 0, v.completed_pourcent
50 assert_equal 0, v.closed_pourcent
49 assert_equal 0, v.completed_percent
50 assert_equal 0, v.closed_percent
51 51 end
52 52
53 53 def test_progress_should_be_0_with_unbegun_assigned_issues
54 54 project = Project.find(1)
55 55 v = Version.create!(:project => project, :name => 'Progress')
56 56 add_issue(v)
57 57 add_issue(v, :done_ratio => 0)
58 assert_progress_equal 0, v.completed_pourcent
59 assert_progress_equal 0, v.closed_pourcent
58 assert_progress_equal 0, v.completed_percent
59 assert_progress_equal 0, v.closed_percent
60 60 end
61 61
62 62 def test_progress_should_be_100_with_closed_assigned_issues
63 63 project = Project.find(1)
64 64 status = IssueStatus.where(:is_closed => true).first
65 65 v = Version.create!(:project => project, :name => 'Progress')
66 66 add_issue(v, :status => status)
67 67 add_issue(v, :status => status, :done_ratio => 20)
68 68 add_issue(v, :status => status, :done_ratio => 70, :estimated_hours => 25)
69 69 add_issue(v, :status => status, :estimated_hours => 15)
70 assert_progress_equal 100.0, v.completed_pourcent
71 assert_progress_equal 100.0, v.closed_pourcent
70 assert_progress_equal 100.0, v.completed_percent
71 assert_progress_equal 100.0, v.closed_percent
72 72 end
73 73
74 74 def test_progress_should_consider_done_ratio_of_open_assigned_issues
75 75 project = Project.find(1)
76 76 v = Version.create!(:project => project, :name => 'Progress')
77 77 add_issue(v)
78 78 add_issue(v, :done_ratio => 20)
79 79 add_issue(v, :done_ratio => 70)
80 assert_progress_equal (0.0 + 20.0 + 70.0)/3, v.completed_pourcent
81 assert_progress_equal 0, v.closed_pourcent
80 assert_progress_equal (0.0 + 20.0 + 70.0)/3, v.completed_percent
81 assert_progress_equal 0, v.closed_percent
82 82 end
83 83
84 84 def test_progress_should_consider_closed_issues_as_completed
85 85 project = Project.find(1)
86 86 v = Version.create!(:project => project, :name => 'Progress')
87 87 add_issue(v)
88 88 add_issue(v, :done_ratio => 20)
89 89 add_issue(v, :status => IssueStatus.where(:is_closed => true).first)
90 assert_progress_equal (0.0 + 20.0 + 100.0)/3, v.completed_pourcent
91 assert_progress_equal (100.0)/3, v.closed_pourcent
90 assert_progress_equal (0.0 + 20.0 + 100.0)/3, v.completed_percent
91 assert_progress_equal (100.0)/3, v.closed_percent
92 92 end
93 93
94 94 def test_progress_should_consider_estimated_hours_to_weigth_issues
95 95 project = Project.find(1)
96 96 v = Version.create!(:project => project, :name => 'Progress')
97 97 add_issue(v, :estimated_hours => 10)
98 98 add_issue(v, :estimated_hours => 20, :done_ratio => 30)
99 99 add_issue(v, :estimated_hours => 40, :done_ratio => 10)
100 100 add_issue(v, :estimated_hours => 25, :status => IssueStatus.where(:is_closed => true).first)
101 assert_progress_equal (10.0*0 + 20.0*0.3 + 40*0.1 + 25.0*1)/95.0*100, v.completed_pourcent
102 assert_progress_equal 25.0/95.0*100, v.closed_pourcent
101 assert_progress_equal (10.0*0 + 20.0*0.3 + 40*0.1 + 25.0*1)/95.0*100, v.completed_percent
102 assert_progress_equal 25.0/95.0*100, v.closed_percent
103 103 end
104 104
105 105 def test_progress_should_consider_average_estimated_hours_to_weigth_unestimated_issues
106 106 project = Project.find(1)
107 107 v = Version.create!(:project => project, :name => 'Progress')
108 108 add_issue(v, :done_ratio => 20)
109 109 add_issue(v, :status => IssueStatus.where(:is_closed => true).first)
110 110 add_issue(v, :estimated_hours => 10, :done_ratio => 30)
111 111 add_issue(v, :estimated_hours => 40, :done_ratio => 10)
112 assert_progress_equal (25.0*0.2 + 25.0*1 + 10.0*0.3 + 40.0*0.1)/100.0*100, v.completed_pourcent
113 assert_progress_equal 25.0/100.0*100, v.closed_pourcent
112 assert_progress_equal (25.0*0.2 + 25.0*1 + 10.0*0.3 + 40.0*0.1)/100.0*100, v.completed_percent
113 assert_progress_equal 25.0/100.0*100, v.closed_percent
114 114 end
115 115
116 116 def test_should_sort_scheduled_then_unscheduled_versions
117 117 Version.delete_all
118 118 v4 = Version.create!(:project_id => 1, :name => 'v4')
119 119 v3 = Version.create!(:project_id => 1, :name => 'v2', :effective_date => '2012-07-14')
120 120 v2 = Version.create!(:project_id => 1, :name => 'v1')
121 121 v1 = Version.create!(:project_id => 1, :name => 'v3', :effective_date => '2012-08-02')
122 122 v5 = Version.create!(:project_id => 1, :name => 'v5', :effective_date => '2012-07-02')
123 123
124 124 assert_equal [v5, v3, v1, v2, v4], [v1, v2, v3, v4, v5].sort
125 125 assert_equal [v5, v3, v1, v2, v4], Version.sorted.all
126 126 end
127 127
128 128 def test_completed_should_be_false_when_due_today
129 129 version = Version.create!(:project_id => 1, :effective_date => Date.today, :name => 'Due today')
130 130 assert_equal false, version.completed?
131 131 end
132 132
133 133 context "#behind_schedule?" do
134 134 setup do
135 135 ProjectCustomField.destroy_all # Custom values are a mess to isolate in tests
136 136 @project = Project.create!(:name => 'test0', :identifier => 'test0')
137 137 @project.trackers << Tracker.create!(:name => 'track')
138 138
139 139 @version = Version.create!(:project => @project, :effective_date => nil, :name => 'version')
140 140 end
141 141
142 142 should "be false if there are no issues assigned" do
143 143 @version.update_attribute(:effective_date, Date.yesterday)
144 144 assert_equal false, @version.behind_schedule?
145 145 end
146 146
147 147 should "be false if there is no effective_date" do
148 148 assert_equal false, @version.behind_schedule?
149 149 end
150 150
151 151 should "be false if all of the issues are ahead of schedule" do
152 152 @version.update_attribute(:effective_date, 7.days.from_now.to_date)
153 153 add_issue(@version, :start_date => 7.days.ago, :done_ratio => 60) # 14 day span, 60% done, 50% time left
154 154 add_issue(@version, :start_date => 7.days.ago, :done_ratio => 60) # 14 day span, 60% done, 50% time left
155 assert_equal 60, @version.completed_pourcent
155 assert_equal 60, @version.completed_percent
156 156 assert_equal false, @version.behind_schedule?
157 157 end
158 158
159 159 should "be true if any of the issues are behind schedule" do
160 160 @version.update_attribute(:effective_date, 7.days.from_now.to_date)
161 161 add_issue(@version, :start_date => 7.days.ago, :done_ratio => 60) # 14 day span, 60% done, 50% time left
162 162 add_issue(@version, :start_date => 7.days.ago, :done_ratio => 20) # 14 day span, 20% done, 50% time left
163 assert_equal 40, @version.completed_pourcent
163 assert_equal 40, @version.completed_percent
164 164 assert_equal true, @version.behind_schedule?
165 165 end
166 166
167 167 should "be false if all of the issues are complete" do
168 168 @version.update_attribute(:effective_date, 7.days.from_now.to_date)
169 169 add_issue(@version, :start_date => 14.days.ago, :done_ratio => 100, :status => IssueStatus.find(5)) # 7 day span
170 170 add_issue(@version, :start_date => 14.days.ago, :done_ratio => 100, :status => IssueStatus.find(5)) # 7 day span
171 assert_equal 100, @version.completed_pourcent
171 assert_equal 100, @version.completed_percent
172 172 assert_equal false, @version.behind_schedule?
173 173 end
174 174 end
175 175
176 176 context "#estimated_hours" do
177 177 setup do
178 178 @version = Version.create!(:project_id => 1, :name => '#estimated_hours')
179 179 end
180 180
181 181 should "return 0 with no assigned issues" do
182 182 assert_equal 0, @version.estimated_hours
183 183 end
184 184
185 185 should "return 0 with no estimated hours" do
186 186 add_issue(@version)
187 187 assert_equal 0, @version.estimated_hours
188 188 end
189 189
190 190 should "return the sum of estimated hours" do
191 191 add_issue(@version, :estimated_hours => 2.5)
192 192 add_issue(@version, :estimated_hours => 5)
193 193 assert_equal 7.5, @version.estimated_hours
194 194 end
195 195
196 196 should "return the sum of leaves estimated hours" do
197 197 parent = add_issue(@version)
198 198 add_issue(@version, :estimated_hours => 2.5, :parent_issue_id => parent.id)
199 199 add_issue(@version, :estimated_hours => 5, :parent_issue_id => parent.id)
200 200 assert_equal 7.5, @version.estimated_hours
201 201 end
202 202 end
203 203
204 204 test "should update all issue's fixed_version associations in case the hierarchy changed XXX" do
205 205 User.current = User.find(1) # Need the admin's permissions
206 206
207 207 @version = Version.find(7)
208 208 # Separate hierarchy
209 209 project_1_issue = Issue.find(1)
210 210 project_1_issue.fixed_version = @version
211 211 assert project_1_issue.save, project_1_issue.errors.full_messages.to_s
212 212
213 213 project_5_issue = Issue.find(6)
214 214 project_5_issue.fixed_version = @version
215 215 assert project_5_issue.save
216 216
217 217 # Project
218 218 project_2_issue = Issue.find(4)
219 219 project_2_issue.fixed_version = @version
220 220 assert project_2_issue.save
221 221
222 222 # Update the sharing
223 223 @version.sharing = 'none'
224 224 assert @version.save
225 225
226 226 # Project 1 now out of the shared scope
227 227 project_1_issue.reload
228 228 assert_equal nil, project_1_issue.fixed_version, "Fixed version is still set after changing the Version's sharing"
229 229
230 230 # Project 5 now out of the shared scope
231 231 project_5_issue.reload
232 232 assert_equal nil, project_5_issue.fixed_version, "Fixed version is still set after changing the Version's sharing"
233 233
234 234 # Project 2 issue remains
235 235 project_2_issue.reload
236 236 assert_equal @version, project_2_issue.fixed_version
237 237 end
238 238
239 239 private
240 240
241 241 def add_issue(version, attributes={})
242 242 Issue.create!({:project => version.project,
243 243 :fixed_version => version,
244 244 :subject => 'Test',
245 245 :author => User.first,
246 246 :tracker => version.project.trackers.first}.merge(attributes))
247 247 end
248 248
249 249 def assert_progress_equal(expected_float, actual_float, message="")
250 250 assert_in_delta(expected_float, actual_float, 0.000001, message="")
251 251 end
252 252 end
General Comments 0
You need to be logged in to leave comments. Login now