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