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

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

@@ -1,1339 +1,1346
1 # encoding: utf-8
1 # encoding: utf-8
2 #
2 #
3 # Redmine - project management software
3 # Redmine - project management software
4 # Copyright (C) 2006-2014 Jean-Philippe Lang
4 # Copyright (C) 2006-2014 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 = issue.subject.truncate(60)
75 title = issue.subject.truncate(60)
76 else
76 else
77 subject = issue.subject
77 subject = issue.subject
78 if truncate_length = options[:truncate]
78 if truncate_length = options[:truncate]
79 subject = subject.truncate(truncate_length)
79 subject = subject.truncate(truncate_length)
80 end
80 end
81 end
81 end
82 only_path = options[:only_path].nil? ? true : options[:only_path]
82 only_path = options[:only_path].nil? ? true : options[:only_path]
83 s = link_to(text, issue_path(issue, :only_path => only_path),
83 s = link_to(text, issue_path(issue, :only_path => only_path),
84 :class => issue.css_classes, :title => title)
84 :class => issue.css_classes, :title => title)
85 s << h(": #{subject}") if subject
85 s << h(": #{subject}") if subject
86 s = h("#{issue.project} - ") + s if options[:project]
86 s = h("#{issue.project} - ") + s if options[:project]
87 s
87 s
88 end
88 end
89
89
90 # Generates a link to an attachment.
90 # Generates a link to an attachment.
91 # Options:
91 # Options:
92 # * :text - Link text (default to attachment filename)
92 # * :text - Link text (default to attachment filename)
93 # * :download - Force download (default: false)
93 # * :download - Force download (default: false)
94 def link_to_attachment(attachment, options={})
94 def link_to_attachment(attachment, options={})
95 text = options.delete(:text) || attachment.filename
95 text = options.delete(:text) || attachment.filename
96 route_method = options.delete(:download) ? :download_named_attachment_path : :named_attachment_path
96 route_method = options.delete(:download) ? :download_named_attachment_path : :named_attachment_path
97 html_options = options.slice!(:only_path)
97 html_options = options.slice!(:only_path)
98 url = send(route_method, attachment, attachment.filename, options)
98 url = send(route_method, attachment, attachment.filename, options)
99 link_to text, url, html_options
99 link_to text, url, html_options
100 end
100 end
101
101
102 # Generates a link to a SCM revision
102 # Generates a link to a SCM revision
103 # Options:
103 # Options:
104 # * :text - Link text (default to the formatted revision)
104 # * :text - Link text (default to the formatted revision)
105 def link_to_revision(revision, repository, options={})
105 def link_to_revision(revision, repository, options={})
106 if repository.is_a?(Project)
106 if repository.is_a?(Project)
107 repository = repository.repository
107 repository = repository.repository
108 end
108 end
109 text = options.delete(:text) || format_revision(revision)
109 text = options.delete(:text) || format_revision(revision)
110 rev = revision.respond_to?(:identifier) ? revision.identifier : revision
110 rev = revision.respond_to?(:identifier) ? revision.identifier : revision
111 link_to(
111 link_to(
112 h(text),
112 h(text),
113 {:controller => 'repositories', :action => 'revision', :id => repository.project, :repository_id => repository.identifier_param, :rev => rev},
113 {:controller => 'repositories', :action => 'revision', :id => repository.project, :repository_id => repository.identifier_param, :rev => rev},
114 :title => l(:label_revision_id, format_revision(revision))
114 :title => l(:label_revision_id, format_revision(revision))
115 )
115 )
116 end
116 end
117
117
118 # Generates a link to a message
118 # Generates a link to a message
119 def link_to_message(message, options={}, html_options = nil)
119 def link_to_message(message, options={}, html_options = nil)
120 link_to(
120 link_to(
121 message.subject.truncate(60),
121 message.subject.truncate(60),
122 board_message_path(message.board_id, message.parent_id || message.id, {
122 board_message_path(message.board_id, message.parent_id || message.id, {
123 :r => (message.parent_id && message.id),
123 :r => (message.parent_id && message.id),
124 :anchor => (message.parent_id ? "message-#{message.id}" : nil)
124 :anchor => (message.parent_id ? "message-#{message.id}" : nil)
125 }.merge(options)),
125 }.merge(options)),
126 html_options
126 html_options
127 )
127 )
128 end
128 end
129
129
130 # Generates a link to a project if active
130 # Generates a link to a project if active
131 # Examples:
131 # Examples:
132 #
132 #
133 # link_to_project(project) # => link to the specified project overview
133 # link_to_project(project) # => link to the specified project overview
134 # link_to_project(project, {:only_path => false}, :class => "project") # => 3rd arg adds html options
134 # link_to_project(project, {:only_path => false}, :class => "project") # => 3rd arg adds html options
135 # link_to_project(project, {}, :class => "project") # => html options with default url (project overview)
135 # link_to_project(project, {}, :class => "project") # => html options with default url (project overview)
136 #
136 #
137 def link_to_project(project, options={}, html_options = nil)
137 def link_to_project(project, options={}, html_options = nil)
138 if project.archived?
138 if project.archived?
139 h(project.name)
139 h(project.name)
140 else
140 else
141 link_to project.name, project_path(project, options), html_options
141 link_to project.name, project_path(project, options), html_options
142 end
142 end
143 end
143 end
144
144
145 # Generates a link to a project settings if active
145 # Generates a link to a project settings if active
146 def link_to_project_settings(project, options={}, html_options=nil)
146 def link_to_project_settings(project, options={}, html_options=nil)
147 if project.active?
147 if project.active?
148 link_to project.name, settings_project_path(project, options), html_options
148 link_to project.name, settings_project_path(project, options), html_options
149 elsif project.archived?
149 elsif project.archived?
150 h(project.name)
150 h(project.name)
151 else
151 else
152 link_to project.name, project_path(project, options), html_options
152 link_to project.name, project_path(project, options), html_options
153 end
153 end
154 end
154 end
155
155
156 # Generates a link to a version
157 def link_to_version(version, options = {})
158 return '' unless version && version.is_a?(Version)
159 options = {:title => format_date(version.effective_date)}.merge(options)
160 link_to_if version.visible?, format_version_name(version), version_path(version), options
161 end
162
156 # Helper that formats object for html or text rendering
163 # Helper that formats object for html or text rendering
157 def format_object(object, html=true, &block)
164 def format_object(object, html=true, &block)
158 if block_given?
165 if block_given?
159 object = yield object
166 object = yield object
160 end
167 end
161 case object.class.name
168 case object.class.name
162 when 'Array'
169 when 'Array'
163 object.map {|o| format_object(o, html)}.join(', ').html_safe
170 object.map {|o| format_object(o, html)}.join(', ').html_safe
164 when 'Time'
171 when 'Time'
165 format_time(object)
172 format_time(object)
166 when 'Date'
173 when 'Date'
167 format_date(object)
174 format_date(object)
168 when 'Fixnum'
175 when 'Fixnum'
169 object.to_s
176 object.to_s
170 when 'Float'
177 when 'Float'
171 sprintf "%.2f", object
178 sprintf "%.2f", object
172 when 'User'
179 when 'User'
173 html ? link_to_user(object) : object.to_s
180 html ? link_to_user(object) : object.to_s
174 when 'Project'
181 when 'Project'
175 html ? link_to_project(object) : object.to_s
182 html ? link_to_project(object) : object.to_s
176 when 'Version'
183 when 'Version'
177 html ? link_to(object.name, version_path(object)) : object.to_s
184 html ? link_to_version(object) : object.to_s
178 when 'TrueClass'
185 when 'TrueClass'
179 l(:general_text_Yes)
186 l(:general_text_Yes)
180 when 'FalseClass'
187 when 'FalseClass'
181 l(:general_text_No)
188 l(:general_text_No)
182 when 'Issue'
189 when 'Issue'
183 object.visible? && html ? link_to_issue(object) : "##{object.id}"
190 object.visible? && html ? link_to_issue(object) : "##{object.id}"
184 when 'CustomValue', 'CustomFieldValue'
191 when 'CustomValue', 'CustomFieldValue'
185 if object.custom_field
192 if object.custom_field
186 f = object.custom_field.format.formatted_custom_value(self, object, html)
193 f = object.custom_field.format.formatted_custom_value(self, object, html)
187 if f.nil? || f.is_a?(String)
194 if f.nil? || f.is_a?(String)
188 f
195 f
189 else
196 else
190 format_object(f, html, &block)
197 format_object(f, html, &block)
191 end
198 end
192 else
199 else
193 object.value.to_s
200 object.value.to_s
194 end
201 end
195 else
202 else
196 html ? h(object) : object.to_s
203 html ? h(object) : object.to_s
197 end
204 end
198 end
205 end
199
206
200 def wiki_page_path(page, options={})
207 def wiki_page_path(page, options={})
201 url_for({:controller => 'wiki', :action => 'show', :project_id => page.project, :id => page.title}.merge(options))
208 url_for({:controller => 'wiki', :action => 'show', :project_id => page.project, :id => page.title}.merge(options))
202 end
209 end
203
210
204 def thumbnail_tag(attachment)
211 def thumbnail_tag(attachment)
205 link_to image_tag(thumbnail_path(attachment)),
212 link_to image_tag(thumbnail_path(attachment)),
206 named_attachment_path(attachment, attachment.filename),
213 named_attachment_path(attachment, attachment.filename),
207 :title => attachment.filename
214 :title => attachment.filename
208 end
215 end
209
216
210 def toggle_link(name, id, options={})
217 def toggle_link(name, id, options={})
211 onclick = "$('##{id}').toggle(); "
218 onclick = "$('##{id}').toggle(); "
212 onclick << (options[:focus] ? "$('##{options[:focus]}').focus(); " : "this.blur(); ")
219 onclick << (options[:focus] ? "$('##{options[:focus]}').focus(); " : "this.blur(); ")
213 onclick << "return false;"
220 onclick << "return false;"
214 link_to(name, "#", :onclick => onclick)
221 link_to(name, "#", :onclick => onclick)
215 end
222 end
216
223
217 def image_to_function(name, function, html_options = {})
224 def image_to_function(name, function, html_options = {})
218 html_options.symbolize_keys!
225 html_options.symbolize_keys!
219 tag(:input, html_options.merge({
226 tag(:input, html_options.merge({
220 :type => "image", :src => image_path(name),
227 :type => "image", :src => image_path(name),
221 :onclick => (html_options[:onclick] ? "#{html_options[:onclick]}; " : "") + "#{function};"
228 :onclick => (html_options[:onclick] ? "#{html_options[:onclick]}; " : "") + "#{function};"
222 }))
229 }))
223 end
230 end
224
231
225 def format_activity_title(text)
232 def format_activity_title(text)
226 h(truncate_single_line_raw(text, 100))
233 h(truncate_single_line_raw(text, 100))
227 end
234 end
228
235
229 def format_activity_day(date)
236 def format_activity_day(date)
230 date == User.current.today ? l(:label_today).titleize : format_date(date)
237 date == User.current.today ? l(:label_today).titleize : format_date(date)
231 end
238 end
232
239
233 def format_activity_description(text)
240 def format_activity_description(text)
234 h(text.to_s.truncate(120).gsub(%r{[\r\n]*<(pre|code)>.*$}m, '...')
241 h(text.to_s.truncate(120).gsub(%r{[\r\n]*<(pre|code)>.*$}m, '...')
235 ).gsub(/[\r\n]+/, "<br />").html_safe
242 ).gsub(/[\r\n]+/, "<br />").html_safe
236 end
243 end
237
244
238 def format_version_name(version)
245 def format_version_name(version)
239 if version.project == @project
246 if !version.shared? || version.project == @project
240 h(version)
247 h(version)
241 else
248 else
242 h("#{version.project} - #{version}")
249 h("#{version.project} - #{version}")
243 end
250 end
244 end
251 end
245
252
246 def due_date_distance_in_words(date)
253 def due_date_distance_in_words(date)
247 if date
254 if date
248 l((date < Date.today ? :label_roadmap_overdue : :label_roadmap_due_in), distance_of_date_in_words(Date.today, date))
255 l((date < Date.today ? :label_roadmap_overdue : :label_roadmap_due_in), distance_of_date_in_words(Date.today, date))
249 end
256 end
250 end
257 end
251
258
252 # Renders a tree of projects as a nested set of unordered lists
259 # Renders a tree of projects as a nested set of unordered lists
253 # The given collection may be a subset of the whole project tree
260 # The given collection may be a subset of the whole project tree
254 # (eg. some intermediate nodes are private and can not be seen)
261 # (eg. some intermediate nodes are private and can not be seen)
255 def render_project_nested_lists(projects, &block)
262 def render_project_nested_lists(projects, &block)
256 s = ''
263 s = ''
257 if projects.any?
264 if projects.any?
258 ancestors = []
265 ancestors = []
259 original_project = @project
266 original_project = @project
260 projects.sort_by(&:lft).each do |project|
267 projects.sort_by(&:lft).each do |project|
261 # set the project environment to please macros.
268 # set the project environment to please macros.
262 @project = project
269 @project = project
263 if (ancestors.empty? || project.is_descendant_of?(ancestors.last))
270 if (ancestors.empty? || project.is_descendant_of?(ancestors.last))
264 s << "<ul class='projects #{ ancestors.empty? ? 'root' : nil}'>\n"
271 s << "<ul class='projects #{ ancestors.empty? ? 'root' : nil}'>\n"
265 else
272 else
266 ancestors.pop
273 ancestors.pop
267 s << "</li>"
274 s << "</li>"
268 while (ancestors.any? && !project.is_descendant_of?(ancestors.last))
275 while (ancestors.any? && !project.is_descendant_of?(ancestors.last))
269 ancestors.pop
276 ancestors.pop
270 s << "</ul></li>\n"
277 s << "</ul></li>\n"
271 end
278 end
272 end
279 end
273 classes = (ancestors.empty? ? 'root' : 'child')
280 classes = (ancestors.empty? ? 'root' : 'child')
274 s << "<li class='#{classes}'><div class='#{classes}'>"
281 s << "<li class='#{classes}'><div class='#{classes}'>"
275 s << h(block_given? ? capture(project, &block) : project.name)
282 s << h(block_given? ? capture(project, &block) : project.name)
276 s << "</div>\n"
283 s << "</div>\n"
277 ancestors << project
284 ancestors << project
278 end
285 end
279 s << ("</li></ul>\n" * ancestors.size)
286 s << ("</li></ul>\n" * ancestors.size)
280 @project = original_project
287 @project = original_project
281 end
288 end
282 s.html_safe
289 s.html_safe
283 end
290 end
284
291
285 def render_page_hierarchy(pages, node=nil, options={})
292 def render_page_hierarchy(pages, node=nil, options={})
286 content = ''
293 content = ''
287 if pages[node]
294 if pages[node]
288 content << "<ul class=\"pages-hierarchy\">\n"
295 content << "<ul class=\"pages-hierarchy\">\n"
289 pages[node].each do |page|
296 pages[node].each do |page|
290 content << "<li>"
297 content << "<li>"
291 content << link_to(h(page.pretty_title), {:controller => 'wiki', :action => 'show', :project_id => page.project, :id => page.title, :version => nil},
298 content << link_to(h(page.pretty_title), {:controller => 'wiki', :action => 'show', :project_id => page.project, :id => page.title, :version => nil},
292 :title => (options[:timestamp] && page.updated_on ? l(:label_updated_time, distance_of_time_in_words(Time.now, page.updated_on)) : nil))
299 :title => (options[:timestamp] && page.updated_on ? l(:label_updated_time, distance_of_time_in_words(Time.now, page.updated_on)) : nil))
293 content << "\n" + render_page_hierarchy(pages, page.id, options) if pages[page.id]
300 content << "\n" + render_page_hierarchy(pages, page.id, options) if pages[page.id]
294 content << "</li>\n"
301 content << "</li>\n"
295 end
302 end
296 content << "</ul>\n"
303 content << "</ul>\n"
297 end
304 end
298 content.html_safe
305 content.html_safe
299 end
306 end
300
307
301 # Renders flash messages
308 # Renders flash messages
302 def render_flash_messages
309 def render_flash_messages
303 s = ''
310 s = ''
304 flash.each do |k,v|
311 flash.each do |k,v|
305 s << content_tag('div', v.html_safe, :class => "flash #{k}", :id => "flash_#{k}")
312 s << content_tag('div', v.html_safe, :class => "flash #{k}", :id => "flash_#{k}")
306 end
313 end
307 s.html_safe
314 s.html_safe
308 end
315 end
309
316
310 # Renders tabs and their content
317 # Renders tabs and their content
311 def render_tabs(tabs, selected=params[:tab])
318 def render_tabs(tabs, selected=params[:tab])
312 if tabs.any?
319 if tabs.any?
313 unless tabs.detect {|tab| tab[:name] == selected}
320 unless tabs.detect {|tab| tab[:name] == selected}
314 selected = nil
321 selected = nil
315 end
322 end
316 selected ||= tabs.first[:name]
323 selected ||= tabs.first[:name]
317 render :partial => 'common/tabs', :locals => {:tabs => tabs, :selected_tab => selected}
324 render :partial => 'common/tabs', :locals => {:tabs => tabs, :selected_tab => selected}
318 else
325 else
319 content_tag 'p', l(:label_no_data), :class => "nodata"
326 content_tag 'p', l(:label_no_data), :class => "nodata"
320 end
327 end
321 end
328 end
322
329
323 # Renders the project quick-jump box
330 # Renders the project quick-jump box
324 def render_project_jump_box
331 def render_project_jump_box
325 return unless User.current.logged?
332 return unless User.current.logged?
326 projects = User.current.memberships.collect(&:project).compact.select(&:active?).uniq
333 projects = User.current.memberships.collect(&:project).compact.select(&:active?).uniq
327 if projects.any?
334 if projects.any?
328 options =
335 options =
329 ("<option value=''>#{ l(:label_jump_to_a_project) }</option>" +
336 ("<option value=''>#{ l(:label_jump_to_a_project) }</option>" +
330 '<option value="" disabled="disabled">---</option>').html_safe
337 '<option value="" disabled="disabled">---</option>').html_safe
331
338
332 options << project_tree_options_for_select(projects, :selected => @project) do |p|
339 options << project_tree_options_for_select(projects, :selected => @project) do |p|
333 { :value => project_path(:id => p, :jump => current_menu_item) }
340 { :value => project_path(:id => p, :jump => current_menu_item) }
334 end
341 end
335
342
336 select_tag('project_quick_jump_box', options, :onchange => 'if (this.value != \'\') { window.location = this.value; }')
343 select_tag('project_quick_jump_box', options, :onchange => 'if (this.value != \'\') { window.location = this.value; }')
337 end
344 end
338 end
345 end
339
346
340 def project_tree_options_for_select(projects, options = {})
347 def project_tree_options_for_select(projects, options = {})
341 s = ''.html_safe
348 s = ''.html_safe
342 if options[:include_blank]
349 if options[:include_blank]
343 s << content_tag('option', '&nbsp;'.html_safe, :value => '')
350 s << content_tag('option', '&nbsp;'.html_safe, :value => '')
344 end
351 end
345 project_tree(projects) do |project, level|
352 project_tree(projects) do |project, level|
346 name_prefix = (level > 0 ? '&nbsp;' * 2 * level + '&#187; ' : '').html_safe
353 name_prefix = (level > 0 ? '&nbsp;' * 2 * level + '&#187; ' : '').html_safe
347 tag_options = {:value => project.id}
354 tag_options = {:value => project.id}
348 if project == options[:selected] || (options[:selected].respond_to?(:include?) && options[:selected].include?(project))
355 if project == options[:selected] || (options[:selected].respond_to?(:include?) && options[:selected].include?(project))
349 tag_options[:selected] = 'selected'
356 tag_options[:selected] = 'selected'
350 else
357 else
351 tag_options[:selected] = nil
358 tag_options[:selected] = nil
352 end
359 end
353 tag_options.merge!(yield(project)) if block_given?
360 tag_options.merge!(yield(project)) if block_given?
354 s << content_tag('option', name_prefix + h(project), tag_options)
361 s << content_tag('option', name_prefix + h(project), tag_options)
355 end
362 end
356 s.html_safe
363 s.html_safe
357 end
364 end
358
365
359 # Yields the given block for each project with its level in the tree
366 # Yields the given block for each project with its level in the tree
360 #
367 #
361 # Wrapper for Project#project_tree
368 # Wrapper for Project#project_tree
362 def project_tree(projects, &block)
369 def project_tree(projects, &block)
363 Project.project_tree(projects, &block)
370 Project.project_tree(projects, &block)
364 end
371 end
365
372
366 def principals_check_box_tags(name, principals)
373 def principals_check_box_tags(name, principals)
367 s = ''
374 s = ''
368 principals.each do |principal|
375 principals.each do |principal|
369 s << "<label>#{ check_box_tag name, principal.id, false, :id => nil } #{h principal}</label>\n"
376 s << "<label>#{ check_box_tag name, principal.id, false, :id => nil } #{h principal}</label>\n"
370 end
377 end
371 s.html_safe
378 s.html_safe
372 end
379 end
373
380
374 # Returns a string for users/groups option tags
381 # Returns a string for users/groups option tags
375 def principals_options_for_select(collection, selected=nil)
382 def principals_options_for_select(collection, selected=nil)
376 s = ''
383 s = ''
377 if collection.include?(User.current)
384 if collection.include?(User.current)
378 s << content_tag('option', "<< #{l(:label_me)} >>", :value => User.current.id)
385 s << content_tag('option', "<< #{l(:label_me)} >>", :value => User.current.id)
379 end
386 end
380 groups = ''
387 groups = ''
381 collection.sort.each do |element|
388 collection.sort.each do |element|
382 selected_attribute = ' selected="selected"' if option_value_selected?(element, selected) || element.id.to_s == selected
389 selected_attribute = ' selected="selected"' if option_value_selected?(element, selected) || element.id.to_s == selected
383 (element.is_a?(Group) ? groups : s) << %(<option value="#{element.id}"#{selected_attribute}>#{h element.name}</option>)
390 (element.is_a?(Group) ? groups : s) << %(<option value="#{element.id}"#{selected_attribute}>#{h element.name}</option>)
384 end
391 end
385 unless groups.empty?
392 unless groups.empty?
386 s << %(<optgroup label="#{h(l(:label_group_plural))}">#{groups}</optgroup>)
393 s << %(<optgroup label="#{h(l(:label_group_plural))}">#{groups}</optgroup>)
387 end
394 end
388 s.html_safe
395 s.html_safe
389 end
396 end
390
397
391 # Options for the new membership projects combo-box
398 # Options for the new membership projects combo-box
392 def options_for_membership_project_select(principal, projects)
399 def options_for_membership_project_select(principal, projects)
393 options = content_tag('option', "--- #{l(:actionview_instancetag_blank_option)} ---")
400 options = content_tag('option', "--- #{l(:actionview_instancetag_blank_option)} ---")
394 options << project_tree_options_for_select(projects) do |p|
401 options << project_tree_options_for_select(projects) do |p|
395 {:disabled => principal.projects.to_a.include?(p)}
402 {:disabled => principal.projects.to_a.include?(p)}
396 end
403 end
397 options
404 options
398 end
405 end
399
406
400 def option_tag(name, text, value, selected=nil, options={})
407 def option_tag(name, text, value, selected=nil, options={})
401 content_tag 'option', value, options.merge(:value => value, :selected => (value == selected))
408 content_tag 'option', value, options.merge(:value => value, :selected => (value == selected))
402 end
409 end
403
410
404 # Truncates and returns the string as a single line
411 # Truncates and returns the string as a single line
405 def truncate_single_line(string, *args)
412 def truncate_single_line(string, *args)
406 ActiveSupport::Deprecation.warn(
413 ActiveSupport::Deprecation.warn(
407 "ApplicationHelper#truncate_single_line is deprecated and will be removed in Rails 4 poring")
414 "ApplicationHelper#truncate_single_line is deprecated and will be removed in Rails 4 poring")
408 # Rails 4 ActionView::Helpers::TextHelper#truncate escapes.
415 # Rails 4 ActionView::Helpers::TextHelper#truncate escapes.
409 # So, result is broken.
416 # So, result is broken.
410 truncate(string.to_s, *args).gsub(%r{[\r\n]+}m, ' ')
417 truncate(string.to_s, *args).gsub(%r{[\r\n]+}m, ' ')
411 end
418 end
412
419
413 def truncate_single_line_raw(string, length)
420 def truncate_single_line_raw(string, length)
414 string.truncate(length).gsub(%r{[\r\n]+}m, ' ')
421 string.truncate(length).gsub(%r{[\r\n]+}m, ' ')
415 end
422 end
416
423
417 # Truncates at line break after 250 characters or options[:length]
424 # Truncates at line break after 250 characters or options[:length]
418 def truncate_lines(string, options={})
425 def truncate_lines(string, options={})
419 length = options[:length] || 250
426 length = options[:length] || 250
420 if string.to_s =~ /\A(.{#{length}}.*?)$/m
427 if string.to_s =~ /\A(.{#{length}}.*?)$/m
421 "#{$1}..."
428 "#{$1}..."
422 else
429 else
423 string
430 string
424 end
431 end
425 end
432 end
426
433
427 def anchor(text)
434 def anchor(text)
428 text.to_s.gsub(' ', '_')
435 text.to_s.gsub(' ', '_')
429 end
436 end
430
437
431 def html_hours(text)
438 def html_hours(text)
432 text.gsub(%r{(\d+)\.(\d+)}, '<span class="hours hours-int">\1</span><span class="hours hours-dec">.\2</span>').html_safe
439 text.gsub(%r{(\d+)\.(\d+)}, '<span class="hours hours-int">\1</span><span class="hours hours-dec">.\2</span>').html_safe
433 end
440 end
434
441
435 def authoring(created, author, options={})
442 def authoring(created, author, options={})
436 l(options[:label] || :label_added_time_by, :author => link_to_user(author), :age => time_tag(created)).html_safe
443 l(options[:label] || :label_added_time_by, :author => link_to_user(author), :age => time_tag(created)).html_safe
437 end
444 end
438
445
439 def time_tag(time)
446 def time_tag(time)
440 text = distance_of_time_in_words(Time.now, time)
447 text = distance_of_time_in_words(Time.now, time)
441 if @project
448 if @project
442 link_to(text, {:controller => 'activities', :action => 'index', :id => @project, :from => User.current.time_to_date(time)}, :title => format_time(time))
449 link_to(text, {:controller => 'activities', :action => 'index', :id => @project, :from => User.current.time_to_date(time)}, :title => format_time(time))
443 else
450 else
444 content_tag('abbr', text, :title => format_time(time))
451 content_tag('abbr', text, :title => format_time(time))
445 end
452 end
446 end
453 end
447
454
448 def syntax_highlight_lines(name, content)
455 def syntax_highlight_lines(name, content)
449 lines = []
456 lines = []
450 syntax_highlight(name, content).each_line { |line| lines << line }
457 syntax_highlight(name, content).each_line { |line| lines << line }
451 lines
458 lines
452 end
459 end
453
460
454 def syntax_highlight(name, content)
461 def syntax_highlight(name, content)
455 Redmine::SyntaxHighlighting.highlight_by_filename(content, name)
462 Redmine::SyntaxHighlighting.highlight_by_filename(content, name)
456 end
463 end
457
464
458 def to_path_param(path)
465 def to_path_param(path)
459 str = path.to_s.split(%r{[/\\]}).select{|p| !p.blank?}.join("/")
466 str = path.to_s.split(%r{[/\\]}).select{|p| !p.blank?}.join("/")
460 str.blank? ? nil : str
467 str.blank? ? nil : str
461 end
468 end
462
469
463 def reorder_links(name, url, method = :post)
470 def reorder_links(name, url, method = :post)
464 link_to(image_tag('2uparrow.png', :alt => l(:label_sort_highest)),
471 link_to(image_tag('2uparrow.png', :alt => l(:label_sort_highest)),
465 url.merge({"#{name}[move_to]" => 'highest'}),
472 url.merge({"#{name}[move_to]" => 'highest'}),
466 :method => method, :title => l(:label_sort_highest)) +
473 :method => method, :title => l(:label_sort_highest)) +
467 link_to(image_tag('1uparrow.png', :alt => l(:label_sort_higher)),
474 link_to(image_tag('1uparrow.png', :alt => l(:label_sort_higher)),
468 url.merge({"#{name}[move_to]" => 'higher'}),
475 url.merge({"#{name}[move_to]" => 'higher'}),
469 :method => method, :title => l(:label_sort_higher)) +
476 :method => method, :title => l(:label_sort_higher)) +
470 link_to(image_tag('1downarrow.png', :alt => l(:label_sort_lower)),
477 link_to(image_tag('1downarrow.png', :alt => l(:label_sort_lower)),
471 url.merge({"#{name}[move_to]" => 'lower'}),
478 url.merge({"#{name}[move_to]" => 'lower'}),
472 :method => method, :title => l(:label_sort_lower)) +
479 :method => method, :title => l(:label_sort_lower)) +
473 link_to(image_tag('2downarrow.png', :alt => l(:label_sort_lowest)),
480 link_to(image_tag('2downarrow.png', :alt => l(:label_sort_lowest)),
474 url.merge({"#{name}[move_to]" => 'lowest'}),
481 url.merge({"#{name}[move_to]" => 'lowest'}),
475 :method => method, :title => l(:label_sort_lowest))
482 :method => method, :title => l(:label_sort_lowest))
476 end
483 end
477
484
478 def breadcrumb(*args)
485 def breadcrumb(*args)
479 elements = args.flatten
486 elements = args.flatten
480 elements.any? ? content_tag('p', (args.join(" \xc2\xbb ") + " \xc2\xbb ").html_safe, :class => 'breadcrumb') : nil
487 elements.any? ? content_tag('p', (args.join(" \xc2\xbb ") + " \xc2\xbb ").html_safe, :class => 'breadcrumb') : nil
481 end
488 end
482
489
483 def other_formats_links(&block)
490 def other_formats_links(&block)
484 concat('<p class="other-formats">'.html_safe + l(:label_export_to))
491 concat('<p class="other-formats">'.html_safe + l(:label_export_to))
485 yield Redmine::Views::OtherFormatsBuilder.new(self)
492 yield Redmine::Views::OtherFormatsBuilder.new(self)
486 concat('</p>'.html_safe)
493 concat('</p>'.html_safe)
487 end
494 end
488
495
489 def page_header_title
496 def page_header_title
490 if @project.nil? || @project.new_record?
497 if @project.nil? || @project.new_record?
491 h(Setting.app_title)
498 h(Setting.app_title)
492 else
499 else
493 b = []
500 b = []
494 ancestors = (@project.root? ? [] : @project.ancestors.visible.to_a)
501 ancestors = (@project.root? ? [] : @project.ancestors.visible.to_a)
495 if ancestors.any?
502 if ancestors.any?
496 root = ancestors.shift
503 root = ancestors.shift
497 b << link_to_project(root, {:jump => current_menu_item}, :class => 'root')
504 b << link_to_project(root, {:jump => current_menu_item}, :class => 'root')
498 if ancestors.size > 2
505 if ancestors.size > 2
499 b << "\xe2\x80\xa6"
506 b << "\xe2\x80\xa6"
500 ancestors = ancestors[-2, 2]
507 ancestors = ancestors[-2, 2]
501 end
508 end
502 b += ancestors.collect {|p| link_to_project(p, {:jump => current_menu_item}, :class => 'ancestor') }
509 b += ancestors.collect {|p| link_to_project(p, {:jump => current_menu_item}, :class => 'ancestor') }
503 end
510 end
504 b << h(@project)
511 b << h(@project)
505 b.join(" \xc2\xbb ").html_safe
512 b.join(" \xc2\xbb ").html_safe
506 end
513 end
507 end
514 end
508
515
509 # Returns a h2 tag and sets the html title with the given arguments
516 # Returns a h2 tag and sets the html title with the given arguments
510 def title(*args)
517 def title(*args)
511 strings = args.map do |arg|
518 strings = args.map do |arg|
512 if arg.is_a?(Array) && arg.size >= 2
519 if arg.is_a?(Array) && arg.size >= 2
513 link_to(*arg)
520 link_to(*arg)
514 else
521 else
515 h(arg.to_s)
522 h(arg.to_s)
516 end
523 end
517 end
524 end
518 html_title args.reverse.map {|s| (s.is_a?(Array) ? s.first : s).to_s}
525 html_title args.reverse.map {|s| (s.is_a?(Array) ? s.first : s).to_s}
519 content_tag('h2', strings.join(' &#187; ').html_safe)
526 content_tag('h2', strings.join(' &#187; ').html_safe)
520 end
527 end
521
528
522 # Sets the html title
529 # Sets the html title
523 # Returns the html title when called without arguments
530 # Returns the html title when called without arguments
524 # Current project name and app_title and automatically appended
531 # Current project name and app_title and automatically appended
525 # Exemples:
532 # Exemples:
526 # html_title 'Foo', 'Bar'
533 # html_title 'Foo', 'Bar'
527 # html_title # => 'Foo - Bar - My Project - Redmine'
534 # html_title # => 'Foo - Bar - My Project - Redmine'
528 def html_title(*args)
535 def html_title(*args)
529 if args.empty?
536 if args.empty?
530 title = @html_title || []
537 title = @html_title || []
531 title << @project.name if @project
538 title << @project.name if @project
532 title << Setting.app_title unless Setting.app_title == title.last
539 title << Setting.app_title unless Setting.app_title == title.last
533 title.reject(&:blank?).join(' - ')
540 title.reject(&:blank?).join(' - ')
534 else
541 else
535 @html_title ||= []
542 @html_title ||= []
536 @html_title += args
543 @html_title += args
537 end
544 end
538 end
545 end
539
546
540 # Returns the theme, controller name, and action as css classes for the
547 # Returns the theme, controller name, and action as css classes for the
541 # HTML body.
548 # HTML body.
542 def body_css_classes
549 def body_css_classes
543 css = []
550 css = []
544 if theme = Redmine::Themes.theme(Setting.ui_theme)
551 if theme = Redmine::Themes.theme(Setting.ui_theme)
545 css << 'theme-' + theme.name
552 css << 'theme-' + theme.name
546 end
553 end
547
554
548 css << 'project-' + @project.identifier if @project && @project.identifier.present?
555 css << 'project-' + @project.identifier if @project && @project.identifier.present?
549 css << 'controller-' + controller_name
556 css << 'controller-' + controller_name
550 css << 'action-' + action_name
557 css << 'action-' + action_name
551 css.join(' ')
558 css.join(' ')
552 end
559 end
553
560
554 def accesskey(s)
561 def accesskey(s)
555 @used_accesskeys ||= []
562 @used_accesskeys ||= []
556 key = Redmine::AccessKeys.key_for(s)
563 key = Redmine::AccessKeys.key_for(s)
557 return nil if @used_accesskeys.include?(key)
564 return nil if @used_accesskeys.include?(key)
558 @used_accesskeys << key
565 @used_accesskeys << key
559 key
566 key
560 end
567 end
561
568
562 # Formats text according to system settings.
569 # Formats text according to system settings.
563 # 2 ways to call this method:
570 # 2 ways to call this method:
564 # * with a String: textilizable(text, options)
571 # * with a String: textilizable(text, options)
565 # * with an object and one of its attribute: textilizable(issue, :description, options)
572 # * with an object and one of its attribute: textilizable(issue, :description, options)
566 def textilizable(*args)
573 def textilizable(*args)
567 options = args.last.is_a?(Hash) ? args.pop : {}
574 options = args.last.is_a?(Hash) ? args.pop : {}
568 case args.size
575 case args.size
569 when 1
576 when 1
570 obj = options[:object]
577 obj = options[:object]
571 text = args.shift
578 text = args.shift
572 when 2
579 when 2
573 obj = args.shift
580 obj = args.shift
574 attr = args.shift
581 attr = args.shift
575 text = obj.send(attr).to_s
582 text = obj.send(attr).to_s
576 else
583 else
577 raise ArgumentError, 'invalid arguments to textilizable'
584 raise ArgumentError, 'invalid arguments to textilizable'
578 end
585 end
579 return '' if text.blank?
586 return '' if text.blank?
580 project = options[:project] || @project || (obj && obj.respond_to?(:project) ? obj.project : nil)
587 project = options[:project] || @project || (obj && obj.respond_to?(:project) ? obj.project : nil)
581 @only_path = only_path = options.delete(:only_path) == false ? false : true
588 @only_path = only_path = options.delete(:only_path) == false ? false : true
582
589
583 text = text.dup
590 text = text.dup
584 macros = catch_macros(text)
591 macros = catch_macros(text)
585 text = Redmine::WikiFormatting.to_html(Setting.text_formatting, text, :object => obj, :attribute => attr)
592 text = Redmine::WikiFormatting.to_html(Setting.text_formatting, text, :object => obj, :attribute => attr)
586
593
587 @parsed_headings = []
594 @parsed_headings = []
588 @heading_anchors = {}
595 @heading_anchors = {}
589 @current_section = 0 if options[:edit_section_links]
596 @current_section = 0 if options[:edit_section_links]
590
597
591 parse_sections(text, project, obj, attr, only_path, options)
598 parse_sections(text, project, obj, attr, only_path, options)
592 text = parse_non_pre_blocks(text, obj, macros) do |text|
599 text = parse_non_pre_blocks(text, obj, macros) do |text|
593 [:parse_inline_attachments, :parse_wiki_links, :parse_redmine_links].each do |method_name|
600 [:parse_inline_attachments, :parse_wiki_links, :parse_redmine_links].each do |method_name|
594 send method_name, text, project, obj, attr, only_path, options
601 send method_name, text, project, obj, attr, only_path, options
595 end
602 end
596 end
603 end
597 parse_headings(text, project, obj, attr, only_path, options)
604 parse_headings(text, project, obj, attr, only_path, options)
598
605
599 if @parsed_headings.any?
606 if @parsed_headings.any?
600 replace_toc(text, @parsed_headings)
607 replace_toc(text, @parsed_headings)
601 end
608 end
602
609
603 text.html_safe
610 text.html_safe
604 end
611 end
605
612
606 def parse_non_pre_blocks(text, obj, macros)
613 def parse_non_pre_blocks(text, obj, macros)
607 s = StringScanner.new(text)
614 s = StringScanner.new(text)
608 tags = []
615 tags = []
609 parsed = ''
616 parsed = ''
610 while !s.eos?
617 while !s.eos?
611 s.scan(/(.*?)(<(\/)?(pre|code)(.*?)>|\z)/im)
618 s.scan(/(.*?)(<(\/)?(pre|code)(.*?)>|\z)/im)
612 text, full_tag, closing, tag = s[1], s[2], s[3], s[4]
619 text, full_tag, closing, tag = s[1], s[2], s[3], s[4]
613 if tags.empty?
620 if tags.empty?
614 yield text
621 yield text
615 inject_macros(text, obj, macros) if macros.any?
622 inject_macros(text, obj, macros) if macros.any?
616 else
623 else
617 inject_macros(text, obj, macros, false) if macros.any?
624 inject_macros(text, obj, macros, false) if macros.any?
618 end
625 end
619 parsed << text
626 parsed << text
620 if tag
627 if tag
621 if closing
628 if closing
622 if tags.last == tag.downcase
629 if tags.last == tag.downcase
623 tags.pop
630 tags.pop
624 end
631 end
625 else
632 else
626 tags << tag.downcase
633 tags << tag.downcase
627 end
634 end
628 parsed << full_tag
635 parsed << full_tag
629 end
636 end
630 end
637 end
631 # Close any non closing tags
638 # Close any non closing tags
632 while tag = tags.pop
639 while tag = tags.pop
633 parsed << "</#{tag}>"
640 parsed << "</#{tag}>"
634 end
641 end
635 parsed
642 parsed
636 end
643 end
637
644
638 def parse_inline_attachments(text, project, obj, attr, only_path, options)
645 def parse_inline_attachments(text, project, obj, attr, only_path, options)
639 # when using an image link, try to use an attachment, if possible
646 # when using an image link, try to use an attachment, if possible
640 attachments = options[:attachments] || []
647 attachments = options[:attachments] || []
641 attachments += obj.attachments if obj.respond_to?(:attachments)
648 attachments += obj.attachments if obj.respond_to?(:attachments)
642 if attachments.present?
649 if attachments.present?
643 text.gsub!(/src="([^\/"]+\.(bmp|gif|jpg|jpe|jpeg|png))"(\s+alt="([^"]*)")?/i) do |m|
650 text.gsub!(/src="([^\/"]+\.(bmp|gif|jpg|jpe|jpeg|png))"(\s+alt="([^"]*)")?/i) do |m|
644 filename, ext, alt, alttext = $1.downcase, $2, $3, $4
651 filename, ext, alt, alttext = $1.downcase, $2, $3, $4
645 # search for the picture in attachments
652 # search for the picture in attachments
646 if found = Attachment.latest_attach(attachments, filename)
653 if found = Attachment.latest_attach(attachments, filename)
647 image_url = download_named_attachment_path(found, found.filename, :only_path => only_path)
654 image_url = download_named_attachment_path(found, found.filename, :only_path => only_path)
648 desc = found.description.to_s.gsub('"', '')
655 desc = found.description.to_s.gsub('"', '')
649 if !desc.blank? && alttext.blank?
656 if !desc.blank? && alttext.blank?
650 alt = " title=\"#{desc}\" alt=\"#{desc}\""
657 alt = " title=\"#{desc}\" alt=\"#{desc}\""
651 end
658 end
652 "src=\"#{image_url}\"#{alt}"
659 "src=\"#{image_url}\"#{alt}"
653 else
660 else
654 m
661 m
655 end
662 end
656 end
663 end
657 end
664 end
658 end
665 end
659
666
660 # Wiki links
667 # Wiki links
661 #
668 #
662 # Examples:
669 # Examples:
663 # [[mypage]]
670 # [[mypage]]
664 # [[mypage|mytext]]
671 # [[mypage|mytext]]
665 # wiki links can refer other project wikis, using project name or identifier:
672 # wiki links can refer other project wikis, using project name or identifier:
666 # [[project:]] -> wiki starting page
673 # [[project:]] -> wiki starting page
667 # [[project:|mytext]]
674 # [[project:|mytext]]
668 # [[project:mypage]]
675 # [[project:mypage]]
669 # [[project:mypage|mytext]]
676 # [[project:mypage|mytext]]
670 def parse_wiki_links(text, project, obj, attr, only_path, options)
677 def parse_wiki_links(text, project, obj, attr, only_path, options)
671 text.gsub!(/(!)?(\[\[([^\]\n\|]+)(\|([^\]\n\|]+))?\]\])/) do |m|
678 text.gsub!(/(!)?(\[\[([^\]\n\|]+)(\|([^\]\n\|]+))?\]\])/) do |m|
672 link_project = project
679 link_project = project
673 esc, all, page, title = $1, $2, $3, $5
680 esc, all, page, title = $1, $2, $3, $5
674 if esc.nil?
681 if esc.nil?
675 if page =~ /^([^\:]+)\:(.*)$/
682 if page =~ /^([^\:]+)\:(.*)$/
676 identifier, page = $1, $2
683 identifier, page = $1, $2
677 link_project = Project.find_by_identifier(identifier) || Project.find_by_name(identifier)
684 link_project = Project.find_by_identifier(identifier) || Project.find_by_name(identifier)
678 title ||= identifier if page.blank?
685 title ||= identifier if page.blank?
679 end
686 end
680
687
681 if link_project && link_project.wiki
688 if link_project && link_project.wiki
682 # extract anchor
689 # extract anchor
683 anchor = nil
690 anchor = nil
684 if page =~ /^(.+?)\#(.+)$/
691 if page =~ /^(.+?)\#(.+)$/
685 page, anchor = $1, $2
692 page, anchor = $1, $2
686 end
693 end
687 anchor = sanitize_anchor_name(anchor) if anchor.present?
694 anchor = sanitize_anchor_name(anchor) if anchor.present?
688 # check if page exists
695 # check if page exists
689 wiki_page = link_project.wiki.find_page(page)
696 wiki_page = link_project.wiki.find_page(page)
690 url = if anchor.present? && wiki_page.present? && (obj.is_a?(WikiContent) || obj.is_a?(WikiContent::Version)) && obj.page == wiki_page
697 url = if anchor.present? && wiki_page.present? && (obj.is_a?(WikiContent) || obj.is_a?(WikiContent::Version)) && obj.page == wiki_page
691 "##{anchor}"
698 "##{anchor}"
692 else
699 else
693 case options[:wiki_links]
700 case options[:wiki_links]
694 when :local; "#{page.present? ? Wiki.titleize(page) : ''}.html" + (anchor.present? ? "##{anchor}" : '')
701 when :local; "#{page.present? ? Wiki.titleize(page) : ''}.html" + (anchor.present? ? "##{anchor}" : '')
695 when :anchor; "##{page.present? ? Wiki.titleize(page) : title}" + (anchor.present? ? "_#{anchor}" : '') # used for single-file wiki export
702 when :anchor; "##{page.present? ? Wiki.titleize(page) : title}" + (anchor.present? ? "_#{anchor}" : '') # used for single-file wiki export
696 else
703 else
697 wiki_page_id = page.present? ? Wiki.titleize(page) : nil
704 wiki_page_id = page.present? ? Wiki.titleize(page) : nil
698 parent = wiki_page.nil? && obj.is_a?(WikiContent) && obj.page && project == link_project ? obj.page.title : nil
705 parent = wiki_page.nil? && obj.is_a?(WikiContent) && obj.page && project == link_project ? obj.page.title : nil
699 url_for(:only_path => only_path, :controller => 'wiki', :action => 'show', :project_id => link_project,
706 url_for(:only_path => only_path, :controller => 'wiki', :action => 'show', :project_id => link_project,
700 :id => wiki_page_id, :version => nil, :anchor => anchor, :parent => parent)
707 :id => wiki_page_id, :version => nil, :anchor => anchor, :parent => parent)
701 end
708 end
702 end
709 end
703 link_to(title.present? ? title.html_safe : h(page), url, :class => ('wiki-page' + (wiki_page ? '' : ' new')))
710 link_to(title.present? ? title.html_safe : h(page), url, :class => ('wiki-page' + (wiki_page ? '' : ' new')))
704 else
711 else
705 # project or wiki doesn't exist
712 # project or wiki doesn't exist
706 all
713 all
707 end
714 end
708 else
715 else
709 all
716 all
710 end
717 end
711 end
718 end
712 end
719 end
713
720
714 # Redmine links
721 # Redmine links
715 #
722 #
716 # Examples:
723 # Examples:
717 # Issues:
724 # Issues:
718 # #52 -> Link to issue #52
725 # #52 -> Link to issue #52
719 # Changesets:
726 # Changesets:
720 # r52 -> Link to revision 52
727 # r52 -> Link to revision 52
721 # commit:a85130f -> Link to scmid starting with a85130f
728 # commit:a85130f -> Link to scmid starting with a85130f
722 # Documents:
729 # Documents:
723 # document#17 -> Link to document with id 17
730 # document#17 -> Link to document with id 17
724 # document:Greetings -> Link to the document with title "Greetings"
731 # document:Greetings -> Link to the document with title "Greetings"
725 # document:"Some document" -> Link to the document with title "Some document"
732 # document:"Some document" -> Link to the document with title "Some document"
726 # Versions:
733 # Versions:
727 # version#3 -> Link to version with id 3
734 # version#3 -> Link to version with id 3
728 # version:1.0.0 -> Link to version named "1.0.0"
735 # version:1.0.0 -> Link to version named "1.0.0"
729 # version:"1.0 beta 2" -> Link to version named "1.0 beta 2"
736 # version:"1.0 beta 2" -> Link to version named "1.0 beta 2"
730 # Attachments:
737 # Attachments:
731 # attachment:file.zip -> Link to the attachment of the current object named file.zip
738 # attachment:file.zip -> Link to the attachment of the current object named file.zip
732 # Source files:
739 # Source files:
733 # source:some/file -> Link to the file located at /some/file in the project's repository
740 # source:some/file -> Link to the file located at /some/file in the project's repository
734 # source:some/file@52 -> Link to the file's revision 52
741 # source:some/file@52 -> Link to the file's revision 52
735 # source:some/file#L120 -> Link to line 120 of the file
742 # source:some/file#L120 -> Link to line 120 of the file
736 # source:some/file@52#L120 -> Link to line 120 of the file's revision 52
743 # source:some/file@52#L120 -> Link to line 120 of the file's revision 52
737 # export:some/file -> Force the download of the file
744 # export:some/file -> Force the download of the file
738 # Forum messages:
745 # Forum messages:
739 # message#1218 -> Link to message with id 1218
746 # message#1218 -> Link to message with id 1218
740 # Projects:
747 # Projects:
741 # project:someproject -> Link to project named "someproject"
748 # project:someproject -> Link to project named "someproject"
742 # project#3 -> Link to project with id 3
749 # project#3 -> Link to project with id 3
743 #
750 #
744 # Links can refer other objects from other projects, using project identifier:
751 # Links can refer other objects from other projects, using project identifier:
745 # identifier:r52
752 # identifier:r52
746 # identifier:document:"Some document"
753 # identifier:document:"Some document"
747 # identifier:version:1.0.0
754 # identifier:version:1.0.0
748 # identifier:source:some/file
755 # identifier:source:some/file
749 def parse_redmine_links(text, default_project, obj, attr, only_path, options)
756 def parse_redmine_links(text, default_project, obj, attr, only_path, options)
750 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|
757 text.gsub!(%r{([\s\(,\-\[\>]|^)(!)?(([a-z0-9\-_]+):)?(attachment|document|version|forum|news|message|project|commit|source|export)?(((#)|((([a-z0-9\-_]+)\|)?(r)))((\d+)((#note)?-(\d+))?)|(:)([^"\s<>][^\s<>]*?|"[^"]+?"))(?=(?=[[:punct:]][^A-Za-z0-9_/])|,|\s|\]|<|$)}) do |m|
751 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
758 leading, esc, project_prefix, project_identifier, prefix, repo_prefix, repo_identifier, sep, identifier, comment_suffix, comment_id = $1, $2, $3, $4, $5, $10, $11, $8 || $12 || $18, $14 || $19, $15, $17
752 link = nil
759 link = nil
753 project = default_project
760 project = default_project
754 if project_identifier
761 if project_identifier
755 project = Project.visible.find_by_identifier(project_identifier)
762 project = Project.visible.find_by_identifier(project_identifier)
756 end
763 end
757 if esc.nil?
764 if esc.nil?
758 if prefix.nil? && sep == 'r'
765 if prefix.nil? && sep == 'r'
759 if project
766 if project
760 repository = nil
767 repository = nil
761 if repo_identifier
768 if repo_identifier
762 repository = project.repositories.detect {|repo| repo.identifier == repo_identifier}
769 repository = project.repositories.detect {|repo| repo.identifier == repo_identifier}
763 else
770 else
764 repository = project.repository
771 repository = project.repository
765 end
772 end
766 # project.changesets.visible raises an SQL error because of a double join on repositories
773 # project.changesets.visible raises an SQL error because of a double join on repositories
767 if repository &&
774 if repository &&
768 (changeset = Changeset.visible.
775 (changeset = Changeset.visible.
769 find_by_repository_id_and_revision(repository.id, identifier))
776 find_by_repository_id_and_revision(repository.id, identifier))
770 link = link_to(h("#{project_prefix}#{repo_prefix}r#{identifier}"),
777 link = link_to(h("#{project_prefix}#{repo_prefix}r#{identifier}"),
771 {:only_path => only_path, :controller => 'repositories',
778 {:only_path => only_path, :controller => 'repositories',
772 :action => 'revision', :id => project,
779 :action => 'revision', :id => project,
773 :repository_id => repository.identifier_param,
780 :repository_id => repository.identifier_param,
774 :rev => changeset.revision},
781 :rev => changeset.revision},
775 :class => 'changeset',
782 :class => 'changeset',
776 :title => truncate_single_line_raw(changeset.comments, 100))
783 :title => truncate_single_line_raw(changeset.comments, 100))
777 end
784 end
778 end
785 end
779 elsif sep == '#'
786 elsif sep == '#'
780 oid = identifier.to_i
787 oid = identifier.to_i
781 case prefix
788 case prefix
782 when nil
789 when nil
783 if oid.to_s == identifier &&
790 if oid.to_s == identifier &&
784 issue = Issue.visible.includes(:status).find_by_id(oid)
791 issue = Issue.visible.includes(:status).find_by_id(oid)
785 anchor = comment_id ? "note-#{comment_id}" : nil
792 anchor = comment_id ? "note-#{comment_id}" : nil
786 link = link_to(h("##{oid}#{comment_suffix}"),
793 link = link_to(h("##{oid}#{comment_suffix}"),
787 {:only_path => only_path, :controller => 'issues',
794 {:only_path => only_path, :controller => 'issues',
788 :action => 'show', :id => oid, :anchor => anchor},
795 :action => 'show', :id => oid, :anchor => anchor},
789 :class => issue.css_classes,
796 :class => issue.css_classes,
790 :title => "#{issue.subject.truncate(100)} (#{issue.status.name})")
797 :title => "#{issue.subject.truncate(100)} (#{issue.status.name})")
791 end
798 end
792 when 'document'
799 when 'document'
793 if document = Document.visible.find_by_id(oid)
800 if document = Document.visible.find_by_id(oid)
794 link = link_to h(document.title), {:only_path => only_path, :controller => 'documents', :action => 'show', :id => document},
801 link = link_to h(document.title), {:only_path => only_path, :controller => 'documents', :action => 'show', :id => document},
795 :class => 'document'
802 :class => 'document'
796 end
803 end
797 when 'version'
804 when 'version'
798 if version = Version.visible.find_by_id(oid)
805 if version = Version.visible.find_by_id(oid)
799 link = link_to h(version.name), {:only_path => only_path, :controller => 'versions', :action => 'show', :id => version},
806 link = link_to h(version.name), {:only_path => only_path, :controller => 'versions', :action => 'show', :id => version},
800 :class => 'version'
807 :class => 'version'
801 end
808 end
802 when 'message'
809 when 'message'
803 if message = Message.visible.includes(:parent).find_by_id(oid)
810 if message = Message.visible.includes(:parent).find_by_id(oid)
804 link = link_to_message(message, {:only_path => only_path}, :class => 'message')
811 link = link_to_message(message, {:only_path => only_path}, :class => 'message')
805 end
812 end
806 when 'forum'
813 when 'forum'
807 if board = Board.visible.find_by_id(oid)
814 if board = Board.visible.find_by_id(oid)
808 link = link_to h(board.name), {:only_path => only_path, :controller => 'boards', :action => 'show', :id => board, :project_id => board.project},
815 link = link_to h(board.name), {:only_path => only_path, :controller => 'boards', :action => 'show', :id => board, :project_id => board.project},
809 :class => 'board'
816 :class => 'board'
810 end
817 end
811 when 'news'
818 when 'news'
812 if news = News.visible.find_by_id(oid)
819 if news = News.visible.find_by_id(oid)
813 link = link_to h(news.title), {:only_path => only_path, :controller => 'news', :action => 'show', :id => news},
820 link = link_to h(news.title), {:only_path => only_path, :controller => 'news', :action => 'show', :id => news},
814 :class => 'news'
821 :class => 'news'
815 end
822 end
816 when 'project'
823 when 'project'
817 if p = Project.visible.find_by_id(oid)
824 if p = Project.visible.find_by_id(oid)
818 link = link_to_project(p, {:only_path => only_path}, :class => 'project')
825 link = link_to_project(p, {:only_path => only_path}, :class => 'project')
819 end
826 end
820 end
827 end
821 elsif sep == ':'
828 elsif sep == ':'
822 # removes the double quotes if any
829 # removes the double quotes if any
823 name = identifier.gsub(%r{^"(.*)"$}, "\\1")
830 name = identifier.gsub(%r{^"(.*)"$}, "\\1")
824 name = CGI.unescapeHTML(name)
831 name = CGI.unescapeHTML(name)
825 case prefix
832 case prefix
826 when 'document'
833 when 'document'
827 if project && document = project.documents.visible.find_by_title(name)
834 if project && document = project.documents.visible.find_by_title(name)
828 link = link_to h(document.title), {:only_path => only_path, :controller => 'documents', :action => 'show', :id => document},
835 link = link_to h(document.title), {:only_path => only_path, :controller => 'documents', :action => 'show', :id => document},
829 :class => 'document'
836 :class => 'document'
830 end
837 end
831 when 'version'
838 when 'version'
832 if project && version = project.versions.visible.find_by_name(name)
839 if project && version = project.versions.visible.find_by_name(name)
833 link = link_to h(version.name), {:only_path => only_path, :controller => 'versions', :action => 'show', :id => version},
840 link = link_to h(version.name), {:only_path => only_path, :controller => 'versions', :action => 'show', :id => version},
834 :class => 'version'
841 :class => 'version'
835 end
842 end
836 when 'forum'
843 when 'forum'
837 if project && board = project.boards.visible.find_by_name(name)
844 if project && board = project.boards.visible.find_by_name(name)
838 link = link_to h(board.name), {:only_path => only_path, :controller => 'boards', :action => 'show', :id => board, :project_id => board.project},
845 link = link_to h(board.name), {:only_path => only_path, :controller => 'boards', :action => 'show', :id => board, :project_id => board.project},
839 :class => 'board'
846 :class => 'board'
840 end
847 end
841 when 'news'
848 when 'news'
842 if project && news = project.news.visible.find_by_title(name)
849 if project && news = project.news.visible.find_by_title(name)
843 link = link_to h(news.title), {:only_path => only_path, :controller => 'news', :action => 'show', :id => news},
850 link = link_to h(news.title), {:only_path => only_path, :controller => 'news', :action => 'show', :id => news},
844 :class => 'news'
851 :class => 'news'
845 end
852 end
846 when 'commit', 'source', 'export'
853 when 'commit', 'source', 'export'
847 if project
854 if project
848 repository = nil
855 repository = nil
849 if name =~ %r{^(([a-z0-9\-_]+)\|)(.+)$}
856 if name =~ %r{^(([a-z0-9\-_]+)\|)(.+)$}
850 repo_prefix, repo_identifier, name = $1, $2, $3
857 repo_prefix, repo_identifier, name = $1, $2, $3
851 repository = project.repositories.detect {|repo| repo.identifier == repo_identifier}
858 repository = project.repositories.detect {|repo| repo.identifier == repo_identifier}
852 else
859 else
853 repository = project.repository
860 repository = project.repository
854 end
861 end
855 if prefix == 'commit'
862 if prefix == 'commit'
856 if repository && (changeset = Changeset.visible.where("repository_id = ? AND scmid LIKE ?", repository.id, "#{name}%").first)
863 if repository && (changeset = Changeset.visible.where("repository_id = ? AND scmid LIKE ?", repository.id, "#{name}%").first)
857 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},
864 link = link_to h("#{project_prefix}#{repo_prefix}#{name}"), {:only_path => only_path, :controller => 'repositories', :action => 'revision', :id => project, :repository_id => repository.identifier_param, :rev => changeset.identifier},
858 :class => 'changeset',
865 :class => 'changeset',
859 :title => truncate_single_line_raw(changeset.comments, 100)
866 :title => truncate_single_line_raw(changeset.comments, 100)
860 end
867 end
861 else
868 else
862 if repository && User.current.allowed_to?(:browse_repository, project)
869 if repository && User.current.allowed_to?(:browse_repository, project)
863 name =~ %r{^[/\\]*(.*?)(@([^/\\@]+?))?(#(L\d+))?$}
870 name =~ %r{^[/\\]*(.*?)(@([^/\\@]+?))?(#(L\d+))?$}
864 path, rev, anchor = $1, $3, $5
871 path, rev, anchor = $1, $3, $5
865 link = link_to h("#{project_prefix}#{prefix}:#{repo_prefix}#{name}"), {:only_path => only_path, :controller => 'repositories', :action => (prefix == 'export' ? 'raw' : 'entry'), :id => project, :repository_id => repository.identifier_param,
872 link = link_to h("#{project_prefix}#{prefix}:#{repo_prefix}#{name}"), {:only_path => only_path, :controller => 'repositories', :action => (prefix == 'export' ? 'raw' : 'entry'), :id => project, :repository_id => repository.identifier_param,
866 :path => to_path_param(path),
873 :path => to_path_param(path),
867 :rev => rev,
874 :rev => rev,
868 :anchor => anchor},
875 :anchor => anchor},
869 :class => (prefix == 'export' ? 'source download' : 'source')
876 :class => (prefix == 'export' ? 'source download' : 'source')
870 end
877 end
871 end
878 end
872 repo_prefix = nil
879 repo_prefix = nil
873 end
880 end
874 when 'attachment'
881 when 'attachment'
875 attachments = options[:attachments] || []
882 attachments = options[:attachments] || []
876 attachments += obj.attachments if obj.respond_to?(:attachments)
883 attachments += obj.attachments if obj.respond_to?(:attachments)
877 if attachments && attachment = Attachment.latest_attach(attachments, name)
884 if attachments && attachment = Attachment.latest_attach(attachments, name)
878 link = link_to_attachment(attachment, :only_path => only_path, :download => true, :class => 'attachment')
885 link = link_to_attachment(attachment, :only_path => only_path, :download => true, :class => 'attachment')
879 end
886 end
880 when 'project'
887 when 'project'
881 if p = Project.visible.where("identifier = :s OR LOWER(name) = :s", :s => name.downcase).first
888 if p = Project.visible.where("identifier = :s OR LOWER(name) = :s", :s => name.downcase).first
882 link = link_to_project(p, {:only_path => only_path}, :class => 'project')
889 link = link_to_project(p, {:only_path => only_path}, :class => 'project')
883 end
890 end
884 end
891 end
885 end
892 end
886 end
893 end
887 (leading + (link || "#{project_prefix}#{prefix}#{repo_prefix}#{sep}#{identifier}#{comment_suffix}"))
894 (leading + (link || "#{project_prefix}#{prefix}#{repo_prefix}#{sep}#{identifier}#{comment_suffix}"))
888 end
895 end
889 end
896 end
890
897
891 HEADING_RE = /(<h(\d)( [^>]+)?>(.+?)<\/h(\d)>)/i unless const_defined?(:HEADING_RE)
898 HEADING_RE = /(<h(\d)( [^>]+)?>(.+?)<\/h(\d)>)/i unless const_defined?(:HEADING_RE)
892
899
893 def parse_sections(text, project, obj, attr, only_path, options)
900 def parse_sections(text, project, obj, attr, only_path, options)
894 return unless options[:edit_section_links]
901 return unless options[:edit_section_links]
895 text.gsub!(HEADING_RE) do
902 text.gsub!(HEADING_RE) do
896 heading = $1
903 heading = $1
897 @current_section += 1
904 @current_section += 1
898 if @current_section > 1
905 if @current_section > 1
899 content_tag('div',
906 content_tag('div',
900 link_to(image_tag('edit.png'), options[:edit_section_links].merge(:section => @current_section)),
907 link_to(image_tag('edit.png'), options[:edit_section_links].merge(:section => @current_section)),
901 :class => 'contextual',
908 :class => 'contextual',
902 :title => l(:button_edit_section),
909 :title => l(:button_edit_section),
903 :id => "section-#{@current_section}") + heading.html_safe
910 :id => "section-#{@current_section}") + heading.html_safe
904 else
911 else
905 heading
912 heading
906 end
913 end
907 end
914 end
908 end
915 end
909
916
910 # Headings and TOC
917 # Headings and TOC
911 # Adds ids and links to headings unless options[:headings] is set to false
918 # Adds ids and links to headings unless options[:headings] is set to false
912 def parse_headings(text, project, obj, attr, only_path, options)
919 def parse_headings(text, project, obj, attr, only_path, options)
913 return if options[:headings] == false
920 return if options[:headings] == false
914
921
915 text.gsub!(HEADING_RE) do
922 text.gsub!(HEADING_RE) do
916 level, attrs, content = $2.to_i, $3, $4
923 level, attrs, content = $2.to_i, $3, $4
917 item = strip_tags(content).strip
924 item = strip_tags(content).strip
918 anchor = sanitize_anchor_name(item)
925 anchor = sanitize_anchor_name(item)
919 # used for single-file wiki export
926 # used for single-file wiki export
920 anchor = "#{obj.page.title}_#{anchor}" if options[:wiki_links] == :anchor && (obj.is_a?(WikiContent) || obj.is_a?(WikiContent::Version))
927 anchor = "#{obj.page.title}_#{anchor}" if options[:wiki_links] == :anchor && (obj.is_a?(WikiContent) || obj.is_a?(WikiContent::Version))
921 @heading_anchors[anchor] ||= 0
928 @heading_anchors[anchor] ||= 0
922 idx = (@heading_anchors[anchor] += 1)
929 idx = (@heading_anchors[anchor] += 1)
923 if idx > 1
930 if idx > 1
924 anchor = "#{anchor}-#{idx}"
931 anchor = "#{anchor}-#{idx}"
925 end
932 end
926 @parsed_headings << [level, anchor, item]
933 @parsed_headings << [level, anchor, item]
927 "<a name=\"#{anchor}\"></a>\n<h#{level} #{attrs}>#{content}<a href=\"##{anchor}\" class=\"wiki-anchor\">&para;</a></h#{level}>"
934 "<a name=\"#{anchor}\"></a>\n<h#{level} #{attrs}>#{content}<a href=\"##{anchor}\" class=\"wiki-anchor\">&para;</a></h#{level}>"
928 end
935 end
929 end
936 end
930
937
931 MACROS_RE = /(
938 MACROS_RE = /(
932 (!)? # escaping
939 (!)? # escaping
933 (
940 (
934 \{\{ # opening tag
941 \{\{ # opening tag
935 ([\w]+) # macro name
942 ([\w]+) # macro name
936 (\(([^\n\r]*?)\))? # optional arguments
943 (\(([^\n\r]*?)\))? # optional arguments
937 ([\n\r].*?[\n\r])? # optional block of text
944 ([\n\r].*?[\n\r])? # optional block of text
938 \}\} # closing tag
945 \}\} # closing tag
939 )
946 )
940 )/mx unless const_defined?(:MACROS_RE)
947 )/mx unless const_defined?(:MACROS_RE)
941
948
942 MACRO_SUB_RE = /(
949 MACRO_SUB_RE = /(
943 \{\{
950 \{\{
944 macro\((\d+)\)
951 macro\((\d+)\)
945 \}\}
952 \}\}
946 )/x unless const_defined?(:MACRO_SUB_RE)
953 )/x unless const_defined?(:MACRO_SUB_RE)
947
954
948 # Extracts macros from text
955 # Extracts macros from text
949 def catch_macros(text)
956 def catch_macros(text)
950 macros = {}
957 macros = {}
951 text.gsub!(MACROS_RE) do
958 text.gsub!(MACROS_RE) do
952 all, macro = $1, $4.downcase
959 all, macro = $1, $4.downcase
953 if macro_exists?(macro) || all =~ MACRO_SUB_RE
960 if macro_exists?(macro) || all =~ MACRO_SUB_RE
954 index = macros.size
961 index = macros.size
955 macros[index] = all
962 macros[index] = all
956 "{{macro(#{index})}}"
963 "{{macro(#{index})}}"
957 else
964 else
958 all
965 all
959 end
966 end
960 end
967 end
961 macros
968 macros
962 end
969 end
963
970
964 # Executes and replaces macros in text
971 # Executes and replaces macros in text
965 def inject_macros(text, obj, macros, execute=true)
972 def inject_macros(text, obj, macros, execute=true)
966 text.gsub!(MACRO_SUB_RE) do
973 text.gsub!(MACRO_SUB_RE) do
967 all, index = $1, $2.to_i
974 all, index = $1, $2.to_i
968 orig = macros.delete(index)
975 orig = macros.delete(index)
969 if execute && orig && orig =~ MACROS_RE
976 if execute && orig && orig =~ MACROS_RE
970 esc, all, macro, args, block = $2, $3, $4.downcase, $6.to_s, $7.try(:strip)
977 esc, all, macro, args, block = $2, $3, $4.downcase, $6.to_s, $7.try(:strip)
971 if esc.nil?
978 if esc.nil?
972 h(exec_macro(macro, obj, args, block) || all)
979 h(exec_macro(macro, obj, args, block) || all)
973 else
980 else
974 h(all)
981 h(all)
975 end
982 end
976 elsif orig
983 elsif orig
977 h(orig)
984 h(orig)
978 else
985 else
979 h(all)
986 h(all)
980 end
987 end
981 end
988 end
982 end
989 end
983
990
984 TOC_RE = /<p>\{\{((<|&lt;)|(>|&gt;))?toc\}\}<\/p>/i unless const_defined?(:TOC_RE)
991 TOC_RE = /<p>\{\{((<|&lt;)|(>|&gt;))?toc\}\}<\/p>/i unless const_defined?(:TOC_RE)
985
992
986 # Renders the TOC with given headings
993 # Renders the TOC with given headings
987 def replace_toc(text, headings)
994 def replace_toc(text, headings)
988 text.gsub!(TOC_RE) do
995 text.gsub!(TOC_RE) do
989 left_align, right_align = $2, $3
996 left_align, right_align = $2, $3
990 # Keep only the 4 first levels
997 # Keep only the 4 first levels
991 headings = headings.select{|level, anchor, item| level <= 4}
998 headings = headings.select{|level, anchor, item| level <= 4}
992 if headings.empty?
999 if headings.empty?
993 ''
1000 ''
994 else
1001 else
995 div_class = 'toc'
1002 div_class = 'toc'
996 div_class << ' right' if right_align
1003 div_class << ' right' if right_align
997 div_class << ' left' if left_align
1004 div_class << ' left' if left_align
998 out = "<ul class=\"#{div_class}\"><li>"
1005 out = "<ul class=\"#{div_class}\"><li>"
999 root = headings.map(&:first).min
1006 root = headings.map(&:first).min
1000 current = root
1007 current = root
1001 started = false
1008 started = false
1002 headings.each do |level, anchor, item|
1009 headings.each do |level, anchor, item|
1003 if level > current
1010 if level > current
1004 out << '<ul><li>' * (level - current)
1011 out << '<ul><li>' * (level - current)
1005 elsif level < current
1012 elsif level < current
1006 out << "</li></ul>\n" * (current - level) + "</li><li>"
1013 out << "</li></ul>\n" * (current - level) + "</li><li>"
1007 elsif started
1014 elsif started
1008 out << '</li><li>'
1015 out << '</li><li>'
1009 end
1016 end
1010 out << "<a href=\"##{anchor}\">#{item}</a>"
1017 out << "<a href=\"##{anchor}\">#{item}</a>"
1011 current = level
1018 current = level
1012 started = true
1019 started = true
1013 end
1020 end
1014 out << '</li></ul>' * (current - root)
1021 out << '</li></ul>' * (current - root)
1015 out << '</li></ul>'
1022 out << '</li></ul>'
1016 end
1023 end
1017 end
1024 end
1018 end
1025 end
1019
1026
1020 # Same as Rails' simple_format helper without using paragraphs
1027 # Same as Rails' simple_format helper without using paragraphs
1021 def simple_format_without_paragraph(text)
1028 def simple_format_without_paragraph(text)
1022 text.to_s.
1029 text.to_s.
1023 gsub(/\r\n?/, "\n"). # \r\n and \r -> \n
1030 gsub(/\r\n?/, "\n"). # \r\n and \r -> \n
1024 gsub(/\n\n+/, "<br /><br />"). # 2+ newline -> 2 br
1031 gsub(/\n\n+/, "<br /><br />"). # 2+ newline -> 2 br
1025 gsub(/([^\n]\n)(?=[^\n])/, '\1<br />'). # 1 newline -> br
1032 gsub(/([^\n]\n)(?=[^\n])/, '\1<br />'). # 1 newline -> br
1026 html_safe
1033 html_safe
1027 end
1034 end
1028
1035
1029 def lang_options_for_select(blank=true)
1036 def lang_options_for_select(blank=true)
1030 (blank ? [["(auto)", ""]] : []) + languages_options
1037 (blank ? [["(auto)", ""]] : []) + languages_options
1031 end
1038 end
1032
1039
1033 def label_tag_for(name, option_tags = nil, options = {})
1040 def label_tag_for(name, option_tags = nil, options = {})
1034 label_text = l(("field_"+field.to_s.gsub(/\_id$/, "")).to_sym) + (options.delete(:required) ? @template.content_tag("span", " *", :class => "required"): "")
1041 label_text = l(("field_"+field.to_s.gsub(/\_id$/, "")).to_sym) + (options.delete(:required) ? @template.content_tag("span", " *", :class => "required"): "")
1035 content_tag("label", label_text)
1042 content_tag("label", label_text)
1036 end
1043 end
1037
1044
1038 def labelled_form_for(*args, &proc)
1045 def labelled_form_for(*args, &proc)
1039 args << {} unless args.last.is_a?(Hash)
1046 args << {} unless args.last.is_a?(Hash)
1040 options = args.last
1047 options = args.last
1041 if args.first.is_a?(Symbol)
1048 if args.first.is_a?(Symbol)
1042 options.merge!(:as => args.shift)
1049 options.merge!(:as => args.shift)
1043 end
1050 end
1044 options.merge!({:builder => Redmine::Views::LabelledFormBuilder})
1051 options.merge!({:builder => Redmine::Views::LabelledFormBuilder})
1045 form_for(*args, &proc)
1052 form_for(*args, &proc)
1046 end
1053 end
1047
1054
1048 def labelled_fields_for(*args, &proc)
1055 def labelled_fields_for(*args, &proc)
1049 args << {} unless args.last.is_a?(Hash)
1056 args << {} unless args.last.is_a?(Hash)
1050 options = args.last
1057 options = args.last
1051 options.merge!({:builder => Redmine::Views::LabelledFormBuilder})
1058 options.merge!({:builder => Redmine::Views::LabelledFormBuilder})
1052 fields_for(*args, &proc)
1059 fields_for(*args, &proc)
1053 end
1060 end
1054
1061
1055 def error_messages_for(*objects)
1062 def error_messages_for(*objects)
1056 html = ""
1063 html = ""
1057 objects = objects.map {|o| o.is_a?(String) ? instance_variable_get("@#{o}") : o}.compact
1064 objects = objects.map {|o| o.is_a?(String) ? instance_variable_get("@#{o}") : o}.compact
1058 errors = objects.map {|o| o.errors.full_messages}.flatten
1065 errors = objects.map {|o| o.errors.full_messages}.flatten
1059 if errors.any?
1066 if errors.any?
1060 html << "<div id='errorExplanation'><ul>\n"
1067 html << "<div id='errorExplanation'><ul>\n"
1061 errors.each do |error|
1068 errors.each do |error|
1062 html << "<li>#{h error}</li>\n"
1069 html << "<li>#{h error}</li>\n"
1063 end
1070 end
1064 html << "</ul></div>\n"
1071 html << "</ul></div>\n"
1065 end
1072 end
1066 html.html_safe
1073 html.html_safe
1067 end
1074 end
1068
1075
1069 def delete_link(url, options={})
1076 def delete_link(url, options={})
1070 options = {
1077 options = {
1071 :method => :delete,
1078 :method => :delete,
1072 :data => {:confirm => l(:text_are_you_sure)},
1079 :data => {:confirm => l(:text_are_you_sure)},
1073 :class => 'icon icon-del'
1080 :class => 'icon icon-del'
1074 }.merge(options)
1081 }.merge(options)
1075
1082
1076 link_to l(:button_delete), url, options
1083 link_to l(:button_delete), url, options
1077 end
1084 end
1078
1085
1079 def preview_link(url, form, target='preview', options={})
1086 def preview_link(url, form, target='preview', options={})
1080 content_tag 'a', l(:label_preview), {
1087 content_tag 'a', l(:label_preview), {
1081 :href => "#",
1088 :href => "#",
1082 :onclick => %|submitPreview("#{escape_javascript url_for(url)}", "#{escape_javascript form}", "#{escape_javascript target}"); return false;|,
1089 :onclick => %|submitPreview("#{escape_javascript url_for(url)}", "#{escape_javascript form}", "#{escape_javascript target}"); return false;|,
1083 :accesskey => accesskey(:preview)
1090 :accesskey => accesskey(:preview)
1084 }.merge(options)
1091 }.merge(options)
1085 end
1092 end
1086
1093
1087 def link_to_function(name, function, html_options={})
1094 def link_to_function(name, function, html_options={})
1088 content_tag(:a, name, {:href => '#', :onclick => "#{function}; return false;"}.merge(html_options))
1095 content_tag(:a, name, {:href => '#', :onclick => "#{function}; return false;"}.merge(html_options))
1089 end
1096 end
1090
1097
1091 # Helper to render JSON in views
1098 # Helper to render JSON in views
1092 def raw_json(arg)
1099 def raw_json(arg)
1093 arg.to_json.to_s.gsub('/', '\/').html_safe
1100 arg.to_json.to_s.gsub('/', '\/').html_safe
1094 end
1101 end
1095
1102
1096 def back_url
1103 def back_url
1097 url = params[:back_url]
1104 url = params[:back_url]
1098 if url.nil? && referer = request.env['HTTP_REFERER']
1105 if url.nil? && referer = request.env['HTTP_REFERER']
1099 url = CGI.unescape(referer.to_s)
1106 url = CGI.unescape(referer.to_s)
1100 end
1107 end
1101 url
1108 url
1102 end
1109 end
1103
1110
1104 def back_url_hidden_field_tag
1111 def back_url_hidden_field_tag
1105 url = back_url
1112 url = back_url
1106 hidden_field_tag('back_url', url, :id => nil) unless url.blank?
1113 hidden_field_tag('back_url', url, :id => nil) unless url.blank?
1107 end
1114 end
1108
1115
1109 def check_all_links(form_name)
1116 def check_all_links(form_name)
1110 link_to_function(l(:button_check_all), "checkAll('#{form_name}', true)") +
1117 link_to_function(l(:button_check_all), "checkAll('#{form_name}', true)") +
1111 " | ".html_safe +
1118 " | ".html_safe +
1112 link_to_function(l(:button_uncheck_all), "checkAll('#{form_name}', false)")
1119 link_to_function(l(:button_uncheck_all), "checkAll('#{form_name}', false)")
1113 end
1120 end
1114
1121
1115 def progress_bar(pcts, options={})
1122 def progress_bar(pcts, options={})
1116 pcts = [pcts, pcts] unless pcts.is_a?(Array)
1123 pcts = [pcts, pcts] unless pcts.is_a?(Array)
1117 pcts = pcts.collect(&:round)
1124 pcts = pcts.collect(&:round)
1118 pcts[1] = pcts[1] - pcts[0]
1125 pcts[1] = pcts[1] - pcts[0]
1119 pcts << (100 - pcts[1] - pcts[0])
1126 pcts << (100 - pcts[1] - pcts[0])
1120 width = options[:width] || '100px;'
1127 width = options[:width] || '100px;'
1121 legend = options[:legend] || ''
1128 legend = options[:legend] || ''
1122 content_tag('table',
1129 content_tag('table',
1123 content_tag('tr',
1130 content_tag('tr',
1124 (pcts[0] > 0 ? content_tag('td', '', :style => "width: #{pcts[0]}%;", :class => 'closed') : ''.html_safe) +
1131 (pcts[0] > 0 ? content_tag('td', '', :style => "width: #{pcts[0]}%;", :class => 'closed') : ''.html_safe) +
1125 (pcts[1] > 0 ? content_tag('td', '', :style => "width: #{pcts[1]}%;", :class => 'done') : ''.html_safe) +
1132 (pcts[1] > 0 ? content_tag('td', '', :style => "width: #{pcts[1]}%;", :class => 'done') : ''.html_safe) +
1126 (pcts[2] > 0 ? content_tag('td', '', :style => "width: #{pcts[2]}%;", :class => 'todo') : ''.html_safe)
1133 (pcts[2] > 0 ? content_tag('td', '', :style => "width: #{pcts[2]}%;", :class => 'todo') : ''.html_safe)
1127 ), :class => "progress progress-#{pcts[0]}", :style => "width: #{width};").html_safe +
1134 ), :class => "progress progress-#{pcts[0]}", :style => "width: #{width};").html_safe +
1128 content_tag('p', legend, :class => 'percent').html_safe
1135 content_tag('p', legend, :class => 'percent').html_safe
1129 end
1136 end
1130
1137
1131 def checked_image(checked=true)
1138 def checked_image(checked=true)
1132 if checked
1139 if checked
1133 image_tag 'toggle_check.png'
1140 image_tag 'toggle_check.png'
1134 end
1141 end
1135 end
1142 end
1136
1143
1137 def context_menu(url)
1144 def context_menu(url)
1138 unless @context_menu_included
1145 unless @context_menu_included
1139 content_for :header_tags do
1146 content_for :header_tags do
1140 javascript_include_tag('context_menu') +
1147 javascript_include_tag('context_menu') +
1141 stylesheet_link_tag('context_menu')
1148 stylesheet_link_tag('context_menu')
1142 end
1149 end
1143 if l(:direction) == 'rtl'
1150 if l(:direction) == 'rtl'
1144 content_for :header_tags do
1151 content_for :header_tags do
1145 stylesheet_link_tag('context_menu_rtl')
1152 stylesheet_link_tag('context_menu_rtl')
1146 end
1153 end
1147 end
1154 end
1148 @context_menu_included = true
1155 @context_menu_included = true
1149 end
1156 end
1150 javascript_tag "contextMenuInit('#{ url_for(url) }')"
1157 javascript_tag "contextMenuInit('#{ url_for(url) }')"
1151 end
1158 end
1152
1159
1153 def calendar_for(field_id)
1160 def calendar_for(field_id)
1154 include_calendar_headers_tags
1161 include_calendar_headers_tags
1155 javascript_tag("$(function() { $('##{field_id}').datepicker(datepickerOptions); });")
1162 javascript_tag("$(function() { $('##{field_id}').datepicker(datepickerOptions); });")
1156 end
1163 end
1157
1164
1158 def include_calendar_headers_tags
1165 def include_calendar_headers_tags
1159 unless @calendar_headers_tags_included
1166 unless @calendar_headers_tags_included
1160 tags = javascript_include_tag("datepicker")
1167 tags = javascript_include_tag("datepicker")
1161 @calendar_headers_tags_included = true
1168 @calendar_headers_tags_included = true
1162 content_for :header_tags do
1169 content_for :header_tags do
1163 start_of_week = Setting.start_of_week
1170 start_of_week = Setting.start_of_week
1164 start_of_week = l(:general_first_day_of_week, :default => '1') if start_of_week.blank?
1171 start_of_week = l(:general_first_day_of_week, :default => '1') if start_of_week.blank?
1165 # Redmine uses 1..7 (monday..sunday) in settings and locales
1172 # Redmine uses 1..7 (monday..sunday) in settings and locales
1166 # JQuery uses 0..6 (sunday..saturday), 7 needs to be changed to 0
1173 # JQuery uses 0..6 (sunday..saturday), 7 needs to be changed to 0
1167 start_of_week = start_of_week.to_i % 7
1174 start_of_week = start_of_week.to_i % 7
1168 tags << javascript_tag(
1175 tags << javascript_tag(
1169 "var datepickerOptions={dateFormat: 'yy-mm-dd', firstDay: #{start_of_week}, " +
1176 "var datepickerOptions={dateFormat: 'yy-mm-dd', firstDay: #{start_of_week}, " +
1170 "showOn: 'button', buttonImageOnly: true, buttonImage: '" +
1177 "showOn: 'button', buttonImageOnly: true, buttonImage: '" +
1171 path_to_image('/images/calendar.png') +
1178 path_to_image('/images/calendar.png') +
1172 "', showButtonPanel: true, showWeek: true, showOtherMonths: true, " +
1179 "', showButtonPanel: true, showWeek: true, showOtherMonths: true, " +
1173 "selectOtherMonths: true, changeMonth: true, changeYear: true, " +
1180 "selectOtherMonths: true, changeMonth: true, changeYear: true, " +
1174 "beforeShow: beforeShowDatePicker};")
1181 "beforeShow: beforeShowDatePicker};")
1175 jquery_locale = l('jquery.locale', :default => current_language.to_s)
1182 jquery_locale = l('jquery.locale', :default => current_language.to_s)
1176 unless jquery_locale == 'en'
1183 unless jquery_locale == 'en'
1177 tags << javascript_include_tag("i18n/datepicker-#{jquery_locale}.js")
1184 tags << javascript_include_tag("i18n/datepicker-#{jquery_locale}.js")
1178 end
1185 end
1179 tags
1186 tags
1180 end
1187 end
1181 end
1188 end
1182 end
1189 end
1183
1190
1184 # Overrides Rails' stylesheet_link_tag with themes and plugins support.
1191 # Overrides Rails' stylesheet_link_tag with themes and plugins support.
1185 # Examples:
1192 # Examples:
1186 # stylesheet_link_tag('styles') # => picks styles.css from the current theme or defaults
1193 # stylesheet_link_tag('styles') # => picks styles.css from the current theme or defaults
1187 # stylesheet_link_tag('styles', :plugin => 'foo) # => picks styles.css from plugin's assets
1194 # stylesheet_link_tag('styles', :plugin => 'foo) # => picks styles.css from plugin's assets
1188 #
1195 #
1189 def stylesheet_link_tag(*sources)
1196 def stylesheet_link_tag(*sources)
1190 options = sources.last.is_a?(Hash) ? sources.pop : {}
1197 options = sources.last.is_a?(Hash) ? sources.pop : {}
1191 plugin = options.delete(:plugin)
1198 plugin = options.delete(:plugin)
1192 sources = sources.map do |source|
1199 sources = sources.map do |source|
1193 if plugin
1200 if plugin
1194 "/plugin_assets/#{plugin}/stylesheets/#{source}"
1201 "/plugin_assets/#{plugin}/stylesheets/#{source}"
1195 elsif current_theme && current_theme.stylesheets.include?(source)
1202 elsif current_theme && current_theme.stylesheets.include?(source)
1196 current_theme.stylesheet_path(source)
1203 current_theme.stylesheet_path(source)
1197 else
1204 else
1198 source
1205 source
1199 end
1206 end
1200 end
1207 end
1201 super *sources, options
1208 super *sources, options
1202 end
1209 end
1203
1210
1204 # Overrides Rails' image_tag with themes and plugins support.
1211 # Overrides Rails' image_tag with themes and plugins support.
1205 # Examples:
1212 # Examples:
1206 # image_tag('image.png') # => picks image.png from the current theme or defaults
1213 # image_tag('image.png') # => picks image.png from the current theme or defaults
1207 # image_tag('image.png', :plugin => 'foo) # => picks image.png from plugin's assets
1214 # image_tag('image.png', :plugin => 'foo) # => picks image.png from plugin's assets
1208 #
1215 #
1209 def image_tag(source, options={})
1216 def image_tag(source, options={})
1210 if plugin = options.delete(:plugin)
1217 if plugin = options.delete(:plugin)
1211 source = "/plugin_assets/#{plugin}/images/#{source}"
1218 source = "/plugin_assets/#{plugin}/images/#{source}"
1212 elsif current_theme && current_theme.images.include?(source)
1219 elsif current_theme && current_theme.images.include?(source)
1213 source = current_theme.image_path(source)
1220 source = current_theme.image_path(source)
1214 end
1221 end
1215 super source, options
1222 super source, options
1216 end
1223 end
1217
1224
1218 # Overrides Rails' javascript_include_tag with plugins support
1225 # Overrides Rails' javascript_include_tag with plugins support
1219 # Examples:
1226 # Examples:
1220 # javascript_include_tag('scripts') # => picks scripts.js from defaults
1227 # javascript_include_tag('scripts') # => picks scripts.js from defaults
1221 # javascript_include_tag('scripts', :plugin => 'foo) # => picks scripts.js from plugin's assets
1228 # javascript_include_tag('scripts', :plugin => 'foo) # => picks scripts.js from plugin's assets
1222 #
1229 #
1223 def javascript_include_tag(*sources)
1230 def javascript_include_tag(*sources)
1224 options = sources.last.is_a?(Hash) ? sources.pop : {}
1231 options = sources.last.is_a?(Hash) ? sources.pop : {}
1225 if plugin = options.delete(:plugin)
1232 if plugin = options.delete(:plugin)
1226 sources = sources.map do |source|
1233 sources = sources.map do |source|
1227 if plugin
1234 if plugin
1228 "/plugin_assets/#{plugin}/javascripts/#{source}"
1235 "/plugin_assets/#{plugin}/javascripts/#{source}"
1229 else
1236 else
1230 source
1237 source
1231 end
1238 end
1232 end
1239 end
1233 end
1240 end
1234 super *sources, options
1241 super *sources, options
1235 end
1242 end
1236
1243
1237 # TODO: remove this in 2.5.0
1244 # TODO: remove this in 2.5.0
1238 def has_content?(name)
1245 def has_content?(name)
1239 content_for?(name)
1246 content_for?(name)
1240 end
1247 end
1241
1248
1242 def sidebar_content?
1249 def sidebar_content?
1243 content_for?(:sidebar) || view_layouts_base_sidebar_hook_response.present?
1250 content_for?(:sidebar) || view_layouts_base_sidebar_hook_response.present?
1244 end
1251 end
1245
1252
1246 def view_layouts_base_sidebar_hook_response
1253 def view_layouts_base_sidebar_hook_response
1247 @view_layouts_base_sidebar_hook_response ||= call_hook(:view_layouts_base_sidebar)
1254 @view_layouts_base_sidebar_hook_response ||= call_hook(:view_layouts_base_sidebar)
1248 end
1255 end
1249
1256
1250 def email_delivery_enabled?
1257 def email_delivery_enabled?
1251 !!ActionMailer::Base.perform_deliveries
1258 !!ActionMailer::Base.perform_deliveries
1252 end
1259 end
1253
1260
1254 # Returns the avatar image tag for the given +user+ if avatars are enabled
1261 # Returns the avatar image tag for the given +user+ if avatars are enabled
1255 # +user+ can be a User or a string that will be scanned for an email address (eg. 'joe <joe@foo.bar>')
1262 # +user+ can be a User or a string that will be scanned for an email address (eg. 'joe <joe@foo.bar>')
1256 def avatar(user, options = { })
1263 def avatar(user, options = { })
1257 if Setting.gravatar_enabled?
1264 if Setting.gravatar_enabled?
1258 options.merge!({:ssl => (request && request.ssl?), :default => Setting.gravatar_default})
1265 options.merge!({:ssl => (request && request.ssl?), :default => Setting.gravatar_default})
1259 email = nil
1266 email = nil
1260 if user.respond_to?(:mail)
1267 if user.respond_to?(:mail)
1261 email = user.mail
1268 email = user.mail
1262 elsif user.to_s =~ %r{<(.+?)>}
1269 elsif user.to_s =~ %r{<(.+?)>}
1263 email = $1
1270 email = $1
1264 end
1271 end
1265 return gravatar(email.to_s.downcase, options) unless email.blank? rescue nil
1272 return gravatar(email.to_s.downcase, options) unless email.blank? rescue nil
1266 else
1273 else
1267 ''
1274 ''
1268 end
1275 end
1269 end
1276 end
1270
1277
1271 def sanitize_anchor_name(anchor)
1278 def sanitize_anchor_name(anchor)
1272 anchor.gsub(%r{[^\s\-\p{Word}]}, '').gsub(%r{\s+(\-+\s*)?}, '-')
1279 anchor.gsub(%r{[^\s\-\p{Word}]}, '').gsub(%r{\s+(\-+\s*)?}, '-')
1273 end
1280 end
1274
1281
1275 # Returns the javascript tags that are included in the html layout head
1282 # Returns the javascript tags that are included in the html layout head
1276 def javascript_heads
1283 def javascript_heads
1277 tags = javascript_include_tag('jquery-1.11.1-ui-1.11.0-ujs-3.1.1', 'application')
1284 tags = javascript_include_tag('jquery-1.11.1-ui-1.11.0-ujs-3.1.1', 'application')
1278 unless User.current.pref.warn_on_leaving_unsaved == '0'
1285 unless User.current.pref.warn_on_leaving_unsaved == '0'
1279 tags << "\n".html_safe + javascript_tag("$(window).load(function(){ warnLeavingUnsaved('#{escape_javascript l(:text_warn_on_leaving_unsaved)}'); });")
1286 tags << "\n".html_safe + javascript_tag("$(window).load(function(){ warnLeavingUnsaved('#{escape_javascript l(:text_warn_on_leaving_unsaved)}'); });")
1280 end
1287 end
1281 tags
1288 tags
1282 end
1289 end
1283
1290
1284 def favicon
1291 def favicon
1285 "<link rel='shortcut icon' href='#{favicon_path}' />".html_safe
1292 "<link rel='shortcut icon' href='#{favicon_path}' />".html_safe
1286 end
1293 end
1287
1294
1288 # Returns the path to the favicon
1295 # Returns the path to the favicon
1289 def favicon_path
1296 def favicon_path
1290 icon = (current_theme && current_theme.favicon?) ? current_theme.favicon_path : '/favicon.ico'
1297 icon = (current_theme && current_theme.favicon?) ? current_theme.favicon_path : '/favicon.ico'
1291 image_path(icon)
1298 image_path(icon)
1292 end
1299 end
1293
1300
1294 # Returns the full URL to the favicon
1301 # Returns the full URL to the favicon
1295 def favicon_url
1302 def favicon_url
1296 # TODO: use #image_url introduced in Rails4
1303 # TODO: use #image_url introduced in Rails4
1297 path = favicon_path
1304 path = favicon_path
1298 base = url_for(:controller => 'welcome', :action => 'index', :only_path => false)
1305 base = url_for(:controller => 'welcome', :action => 'index', :only_path => false)
1299 base.sub(%r{/+$},'') + '/' + path.sub(%r{^/+},'')
1306 base.sub(%r{/+$},'') + '/' + path.sub(%r{^/+},'')
1300 end
1307 end
1301
1308
1302 def robot_exclusion_tag
1309 def robot_exclusion_tag
1303 '<meta name="robots" content="noindex,follow,noarchive" />'.html_safe
1310 '<meta name="robots" content="noindex,follow,noarchive" />'.html_safe
1304 end
1311 end
1305
1312
1306 # Returns true if arg is expected in the API response
1313 # Returns true if arg is expected in the API response
1307 def include_in_api_response?(arg)
1314 def include_in_api_response?(arg)
1308 unless @included_in_api_response
1315 unless @included_in_api_response
1309 param = params[:include]
1316 param = params[:include]
1310 @included_in_api_response = param.is_a?(Array) ? param.collect(&:to_s) : param.to_s.split(',')
1317 @included_in_api_response = param.is_a?(Array) ? param.collect(&:to_s) : param.to_s.split(',')
1311 @included_in_api_response.collect!(&:strip)
1318 @included_in_api_response.collect!(&:strip)
1312 end
1319 end
1313 @included_in_api_response.include?(arg.to_s)
1320 @included_in_api_response.include?(arg.to_s)
1314 end
1321 end
1315
1322
1316 # Returns options or nil if nometa param or X-Redmine-Nometa header
1323 # Returns options or nil if nometa param or X-Redmine-Nometa header
1317 # was set in the request
1324 # was set in the request
1318 def api_meta(options)
1325 def api_meta(options)
1319 if params[:nometa].present? || request.headers['X-Redmine-Nometa']
1326 if params[:nometa].present? || request.headers['X-Redmine-Nometa']
1320 # compatibility mode for activeresource clients that raise
1327 # compatibility mode for activeresource clients that raise
1321 # an error when deserializing an array with attributes
1328 # an error when deserializing an array with attributes
1322 nil
1329 nil
1323 else
1330 else
1324 options
1331 options
1325 end
1332 end
1326 end
1333 end
1327
1334
1328 private
1335 private
1329
1336
1330 def wiki_helper
1337 def wiki_helper
1331 helper = Redmine::WikiFormatting.helper_for(Setting.text_formatting)
1338 helper = Redmine::WikiFormatting.helper_for(Setting.text_formatting)
1332 extend helper
1339 extend helper
1333 return self
1340 return self
1334 end
1341 end
1335
1342
1336 def link_to_content_update(text, url_params = {}, html_options = {})
1343 def link_to_content_update(text, url_params = {}, html_options = {})
1337 link_to(text, url_params, html_options)
1344 link_to(text, url_params, html_options)
1338 end
1345 end
1339 end
1346 end
@@ -1,120 +1,115
1 # encoding: utf-8
1 # encoding: utf-8
2 #
2 #
3 # Redmine - project management software
3 # Redmine - project management software
4 # Copyright (C) 2006-2014 Jean-Philippe Lang
4 # Copyright (C) 2006-2014 Jean-Philippe Lang
5 #
5 #
6 # This program is free software; you can redistribute it and/or
6 # This program is free software; you can redistribute it and/or
7 # modify it under the terms of the GNU General Public License
7 # modify it under the terms of the GNU General Public License
8 # as published by the Free Software Foundation; either version 2
8 # as published by the Free Software Foundation; either version 2
9 # of the License, or (at your option) any later version.
9 # of the License, or (at your option) any later version.
10 #
10 #
11 # This program is distributed in the hope that it will be useful,
11 # This program is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU General Public License for more details.
14 # GNU General Public License for more details.
15 #
15 #
16 # You should have received a copy of the GNU General Public License
16 # You should have received a copy of the GNU General Public License
17 # along with this program; if not, write to the Free Software
17 # along with this program; if not, write to the Free Software
18 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19
19
20 module ProjectsHelper
20 module ProjectsHelper
21 def link_to_version(version, options = {})
22 return '' unless version && version.is_a?(Version)
23 link_to_if version.visible?, format_version_name(version), version_path(version), options
24 end
25
26 def project_settings_tabs
21 def project_settings_tabs
27 tabs = [{:name => 'info', :action => :edit_project, :partial => 'projects/edit', :label => :label_information_plural},
22 tabs = [{:name => 'info', :action => :edit_project, :partial => 'projects/edit', :label => :label_information_plural},
28 {:name => 'modules', :action => :select_project_modules, :partial => 'projects/settings/modules', :label => :label_module_plural},
23 {:name => 'modules', :action => :select_project_modules, :partial => 'projects/settings/modules', :label => :label_module_plural},
29 {:name => 'members', :action => :manage_members, :partial => 'projects/settings/members', :label => :label_member_plural},
24 {:name => 'members', :action => :manage_members, :partial => 'projects/settings/members', :label => :label_member_plural},
30 {:name => 'versions', :action => :manage_versions, :partial => 'projects/settings/versions', :label => :label_version_plural},
25 {:name => 'versions', :action => :manage_versions, :partial => 'projects/settings/versions', :label => :label_version_plural},
31 {:name => 'categories', :action => :manage_categories, :partial => 'projects/settings/issue_categories', :label => :label_issue_category_plural},
26 {:name => 'categories', :action => :manage_categories, :partial => 'projects/settings/issue_categories', :label => :label_issue_category_plural},
32 {:name => 'wiki', :action => :manage_wiki, :partial => 'projects/settings/wiki', :label => :label_wiki},
27 {:name => 'wiki', :action => :manage_wiki, :partial => 'projects/settings/wiki', :label => :label_wiki},
33 {:name => 'repositories', :action => :manage_repository, :partial => 'projects/settings/repositories', :label => :label_repository_plural},
28 {:name => 'repositories', :action => :manage_repository, :partial => 'projects/settings/repositories', :label => :label_repository_plural},
34 {:name => 'boards', :action => :manage_boards, :partial => 'projects/settings/boards', :label => :label_board_plural},
29 {:name => 'boards', :action => :manage_boards, :partial => 'projects/settings/boards', :label => :label_board_plural},
35 {:name => 'activities', :action => :manage_project_activities, :partial => 'projects/settings/activities', :label => :enumeration_activities}
30 {:name => 'activities', :action => :manage_project_activities, :partial => 'projects/settings/activities', :label => :enumeration_activities}
36 ]
31 ]
37 tabs.select {|tab| User.current.allowed_to?(tab[:action], @project)}
32 tabs.select {|tab| User.current.allowed_to?(tab[:action], @project)}
38 end
33 end
39
34
40 def parent_project_select_tag(project)
35 def parent_project_select_tag(project)
41 selected = project.parent
36 selected = project.parent
42 # retrieve the requested parent project
37 # retrieve the requested parent project
43 parent_id = (params[:project] && params[:project][:parent_id]) || params[:parent_id]
38 parent_id = (params[:project] && params[:project][:parent_id]) || params[:parent_id]
44 if parent_id
39 if parent_id
45 selected = (parent_id.blank? ? nil : Project.find(parent_id))
40 selected = (parent_id.blank? ? nil : Project.find(parent_id))
46 end
41 end
47
42
48 options = ''
43 options = ''
49 options << "<option value=''>&nbsp;</option>" if project.allowed_parents.include?(nil)
44 options << "<option value=''>&nbsp;</option>" if project.allowed_parents.include?(nil)
50 options << project_tree_options_for_select(project.allowed_parents.compact, :selected => selected)
45 options << project_tree_options_for_select(project.allowed_parents.compact, :selected => selected)
51 content_tag('select', options.html_safe, :name => 'project[parent_id]', :id => 'project_parent_id')
46 content_tag('select', options.html_safe, :name => 'project[parent_id]', :id => 'project_parent_id')
52 end
47 end
53
48
54 def render_project_action_links
49 def render_project_action_links
55 links = []
50 links = []
56 if User.current.allowed_to?(:add_project, nil, :global => true)
51 if User.current.allowed_to?(:add_project, nil, :global => true)
57 links << link_to(l(:label_project_new), new_project_path, :class => 'icon icon-add')
52 links << link_to(l(:label_project_new), new_project_path, :class => 'icon icon-add')
58 end
53 end
59 if User.current.allowed_to?(:view_issues, nil, :global => true)
54 if User.current.allowed_to?(:view_issues, nil, :global => true)
60 links << link_to(l(:label_issue_view_all), issues_path)
55 links << link_to(l(:label_issue_view_all), issues_path)
61 end
56 end
62 if User.current.allowed_to?(:view_time_entries, nil, :global => true)
57 if User.current.allowed_to?(:view_time_entries, nil, :global => true)
63 links << link_to(l(:label_overall_spent_time), time_entries_path)
58 links << link_to(l(:label_overall_spent_time), time_entries_path)
64 end
59 end
65 links << link_to(l(:label_overall_activity), activity_path)
60 links << link_to(l(:label_overall_activity), activity_path)
66 links.join(" | ").html_safe
61 links.join(" | ").html_safe
67 end
62 end
68
63
69 # Renders the projects index
64 # Renders the projects index
70 def render_project_hierarchy(projects)
65 def render_project_hierarchy(projects)
71 render_project_nested_lists(projects) do |project|
66 render_project_nested_lists(projects) do |project|
72 s = link_to_project(project, {}, :class => "#{project.css_classes} #{User.current.member_of?(project) ? 'my-project' : nil}")
67 s = link_to_project(project, {}, :class => "#{project.css_classes} #{User.current.member_of?(project) ? 'my-project' : nil}")
73 if project.description.present?
68 if project.description.present?
74 s << content_tag('div', textilizable(project.short_description, :project => project), :class => 'wiki description')
69 s << content_tag('div', textilizable(project.short_description, :project => project), :class => 'wiki description')
75 end
70 end
76 s
71 s
77 end
72 end
78 end
73 end
79
74
80 # Returns a set of options for a select field, grouped by project.
75 # Returns a set of options for a select field, grouped by project.
81 def version_options_for_select(versions, selected=nil)
76 def version_options_for_select(versions, selected=nil)
82 grouped = Hash.new {|h,k| h[k] = []}
77 grouped = Hash.new {|h,k| h[k] = []}
83 versions.each do |version|
78 versions.each do |version|
84 grouped[version.project.name] << [version.name, version.id]
79 grouped[version.project.name] << [version.name, version.id]
85 end
80 end
86
81
87 selected = selected.is_a?(Version) ? selected.id : selected
82 selected = selected.is_a?(Version) ? selected.id : selected
88 if grouped.keys.size > 1
83 if grouped.keys.size > 1
89 grouped_options_for_select(grouped, selected)
84 grouped_options_for_select(grouped, selected)
90 else
85 else
91 options_for_select((grouped.values.first || []), selected)
86 options_for_select((grouped.values.first || []), selected)
92 end
87 end
93 end
88 end
94
89
95 def format_version_sharing(sharing)
90 def format_version_sharing(sharing)
96 sharing = 'none' unless Version::VERSION_SHARINGS.include?(sharing)
91 sharing = 'none' unless Version::VERSION_SHARINGS.include?(sharing)
97 l("label_version_sharing_#{sharing}")
92 l("label_version_sharing_#{sharing}")
98 end
93 end
99
94
100 def render_api_includes(project, api)
95 def render_api_includes(project, api)
101 api.array :trackers do
96 api.array :trackers do
102 project.trackers.each do |tracker|
97 project.trackers.each do |tracker|
103 api.tracker(:id => tracker.id, :name => tracker.name)
98 api.tracker(:id => tracker.id, :name => tracker.name)
104 end
99 end
105 end if include_in_api_response?('trackers')
100 end if include_in_api_response?('trackers')
106
101
107 api.array :issue_categories do
102 api.array :issue_categories do
108 project.issue_categories.each do |category|
103 project.issue_categories.each do |category|
109 api.issue_category(:id => category.id, :name => category.name)
104 api.issue_category(:id => category.id, :name => category.name)
110 end
105 end
111 end if include_in_api_response?('issue_categories')
106 end if include_in_api_response?('issue_categories')
112
107
113 api.array :enabled_modules do
108 api.array :enabled_modules do
114 project.enabled_modules.each do |enabled_module|
109 project.enabled_modules.each do |enabled_module|
115 api.enabled_module(:id => enabled_module.id, :name => enabled_module.name)
110 api.enabled_module(:id => enabled_module.id, :name => enabled_module.name)
116 end
111 end
117 end if include_in_api_response?('enabled_modules')
112 end if include_in_api_response?('enabled_modules')
118
113
119 end
114 end
120 end
115 end
@@ -1,1592 +1,1591
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2014 Jean-Philippe Lang
2 # Copyright (C) 2006-2014 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class Issue < ActiveRecord::Base
18 class Issue < ActiveRecord::Base
19 include Redmine::SafeAttributes
19 include Redmine::SafeAttributes
20 include Redmine::Utils::DateCalculation
20 include Redmine::Utils::DateCalculation
21 include Redmine::I18n
21 include Redmine::I18n
22
22
23 belongs_to :project
23 belongs_to :project
24 belongs_to :tracker
24 belongs_to :tracker
25 belongs_to :status, :class_name => 'IssueStatus'
25 belongs_to :status, :class_name => 'IssueStatus'
26 belongs_to :author, :class_name => 'User'
26 belongs_to :author, :class_name => 'User'
27 belongs_to :assigned_to, :class_name => 'Principal'
27 belongs_to :assigned_to, :class_name => 'Principal'
28 belongs_to :fixed_version, :class_name => 'Version'
28 belongs_to :fixed_version, :class_name => 'Version'
29 belongs_to :priority, :class_name => 'IssuePriority'
29 belongs_to :priority, :class_name => 'IssuePriority'
30 belongs_to :category, :class_name => 'IssueCategory'
30 belongs_to :category, :class_name => 'IssueCategory'
31
31
32 has_many :journals, :as => :journalized, :dependent => :destroy
32 has_many :journals, :as => :journalized, :dependent => :destroy
33 has_many :visible_journals,
33 has_many :visible_journals,
34 lambda {where(["(#{Journal.table_name}.private_notes = ? OR (#{Project.allowed_to_condition(User.current, :view_private_notes)}))", false])},
34 lambda {where(["(#{Journal.table_name}.private_notes = ? OR (#{Project.allowed_to_condition(User.current, :view_private_notes)}))", false])},
35 :class_name => 'Journal',
35 :class_name => 'Journal',
36 :as => :journalized
36 :as => :journalized
37
37
38 has_many :time_entries, :dependent => :destroy
38 has_many :time_entries, :dependent => :destroy
39 has_and_belongs_to_many :changesets, lambda {order("#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC")}
39 has_and_belongs_to_many :changesets, lambda {order("#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC")}
40
40
41 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
41 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
42 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
42 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
43
43
44 acts_as_nested_set :scope => 'root_id', :dependent => :destroy
44 acts_as_nested_set :scope => 'root_id', :dependent => :destroy
45 acts_as_attachable :after_add => :attachment_added, :after_remove => :attachment_removed
45 acts_as_attachable :after_add => :attachment_added, :after_remove => :attachment_removed
46 acts_as_customizable
46 acts_as_customizable
47 acts_as_watchable
47 acts_as_watchable
48 acts_as_searchable :columns => ['subject', "#{table_name}.description", "#{Journal.table_name}.notes"],
48 acts_as_searchable :columns => ['subject', "#{table_name}.description", "#{Journal.table_name}.notes"],
49 # sort by id so that limited eager loading doesn't break with postgresql
49 # sort by id so that limited eager loading doesn't break with postgresql
50 :order_column => "#{table_name}.id",
50 :order_column => "#{table_name}.id",
51 :scope => lambda { joins(:project).
51 :scope => lambda { joins(:project).
52 joins("LEFT OUTER JOIN #{Journal.table_name} ON #{Journal.table_name}.journalized_type='Issue'" +
52 joins("LEFT OUTER JOIN #{Journal.table_name} ON #{Journal.table_name}.journalized_type='Issue'" +
53 " AND #{Journal.table_name}.journalized_id = #{Issue.table_name}.id" +
53 " AND #{Journal.table_name}.journalized_id = #{Issue.table_name}.id" +
54 " AND (#{Journal.table_name}.private_notes = #{connection.quoted_false}" +
54 " AND (#{Journal.table_name}.private_notes = #{connection.quoted_false}" +
55 " OR (#{Project.allowed_to_condition(User.current, :view_private_notes)}))") }
55 " OR (#{Project.allowed_to_condition(User.current, :view_private_notes)}))") }
56
56
57 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
57 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
58 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
58 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
59 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
59 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
60
60
61 acts_as_activity_provider :scope => preload(:project, :author, :tracker),
61 acts_as_activity_provider :scope => preload(:project, :author, :tracker),
62 :author_key => :author_id
62 :author_key => :author_id
63
63
64 DONE_RATIO_OPTIONS = %w(issue_field issue_status)
64 DONE_RATIO_OPTIONS = %w(issue_field issue_status)
65
65
66 attr_reader :current_journal
66 attr_reader :current_journal
67 delegate :notes, :notes=, :private_notes, :private_notes=, :to => :current_journal, :allow_nil => true
67 delegate :notes, :notes=, :private_notes, :private_notes=, :to => :current_journal, :allow_nil => true
68
68
69 validates_presence_of :subject, :priority, :project, :tracker, :author, :status
69 validates_presence_of :subject, :priority, :project, :tracker, :author, :status
70
70
71 validates_length_of :subject, :maximum => 255
71 validates_length_of :subject, :maximum => 255
72 validates_inclusion_of :done_ratio, :in => 0..100
72 validates_inclusion_of :done_ratio, :in => 0..100
73 validates :estimated_hours, :numericality => {:greater_than_or_equal_to => 0, :allow_nil => true, :message => :invalid}
73 validates :estimated_hours, :numericality => {:greater_than_or_equal_to => 0, :allow_nil => true, :message => :invalid}
74 validates :start_date, :date => true
74 validates :start_date, :date => true
75 validates :due_date, :date => true
75 validates :due_date, :date => true
76 validate :validate_issue, :validate_required_fields
76 validate :validate_issue, :validate_required_fields
77 attr_protected :id
77 attr_protected :id
78
78
79 scope :visible, lambda {|*args|
79 scope :visible, lambda {|*args|
80 includes(:project).
80 includes(:project).
81 references(:project).
81 references(:project).
82 where(Issue.visible_condition(args.shift || User.current, *args))
82 where(Issue.visible_condition(args.shift || User.current, *args))
83 }
83 }
84
84
85 scope :open, lambda {|*args|
85 scope :open, lambda {|*args|
86 is_closed = args.size > 0 ? !args.first : false
86 is_closed = args.size > 0 ? !args.first : false
87 joins(:status).
87 joins(:status).
88 where("#{IssueStatus.table_name}.is_closed = ?", is_closed)
88 where("#{IssueStatus.table_name}.is_closed = ?", is_closed)
89 }
89 }
90
90
91 scope :recently_updated, lambda { order("#{Issue.table_name}.updated_on DESC") }
91 scope :recently_updated, lambda { order("#{Issue.table_name}.updated_on DESC") }
92 scope :on_active_project, lambda {
92 scope :on_active_project, lambda {
93 joins(:project).
93 joins(:project).
94 where("#{Project.table_name}.status = ?", Project::STATUS_ACTIVE)
94 where("#{Project.table_name}.status = ?", Project::STATUS_ACTIVE)
95 }
95 }
96 scope :fixed_version, lambda {|versions|
96 scope :fixed_version, lambda {|versions|
97 ids = [versions].flatten.compact.map {|v| v.is_a?(Version) ? v.id : v}
97 ids = [versions].flatten.compact.map {|v| v.is_a?(Version) ? v.id : v}
98 ids.any? ? where(:fixed_version_id => ids) : where('1=0')
98 ids.any? ? where(:fixed_version_id => ids) : where('1=0')
99 }
99 }
100
100
101 before_create :default_assign
101 before_create :default_assign
102 before_save :close_duplicates, :update_done_ratio_from_issue_status,
102 before_save :close_duplicates, :update_done_ratio_from_issue_status,
103 :force_updated_on_change, :update_closed_on, :set_assigned_to_was
103 :force_updated_on_change, :update_closed_on, :set_assigned_to_was
104 after_save {|issue| issue.send :after_project_change if !issue.id_changed? && issue.project_id_changed?}
104 after_save {|issue| issue.send :after_project_change if !issue.id_changed? && issue.project_id_changed?}
105 after_save :reschedule_following_issues, :update_nested_set_attributes,
105 after_save :reschedule_following_issues, :update_nested_set_attributes,
106 :update_parent_attributes, :create_journal
106 :update_parent_attributes, :create_journal
107 # Should be after_create but would be called before previous after_save callbacks
107 # Should be after_create but would be called before previous after_save callbacks
108 after_save :after_create_from_copy
108 after_save :after_create_from_copy
109 after_destroy :update_parent_attributes
109 after_destroy :update_parent_attributes
110 after_create :send_notification
110 after_create :send_notification
111 # Keep it at the end of after_save callbacks
111 # Keep it at the end of after_save callbacks
112 after_save :clear_assigned_to_was
112 after_save :clear_assigned_to_was
113
113
114 # Returns a SQL conditions string used to find all issues visible by the specified user
114 # Returns a SQL conditions string used to find all issues visible by the specified user
115 def self.visible_condition(user, options={})
115 def self.visible_condition(user, options={})
116 Project.allowed_to_condition(user, :view_issues, options) do |role, user|
116 Project.allowed_to_condition(user, :view_issues, options) do |role, user|
117 if user.id && user.logged?
117 if user.id && user.logged?
118 case role.issues_visibility
118 case role.issues_visibility
119 when 'all'
119 when 'all'
120 nil
120 nil
121 when 'default'
121 when 'default'
122 user_ids = [user.id] + user.groups.map(&:id).compact
122 user_ids = [user.id] + user.groups.map(&:id).compact
123 "(#{table_name}.is_private = #{connection.quoted_false} OR #{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
123 "(#{table_name}.is_private = #{connection.quoted_false} OR #{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
124 when 'own'
124 when 'own'
125 user_ids = [user.id] + user.groups.map(&:id).compact
125 user_ids = [user.id] + user.groups.map(&:id).compact
126 "(#{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
126 "(#{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
127 else
127 else
128 '1=0'
128 '1=0'
129 end
129 end
130 else
130 else
131 "(#{table_name}.is_private = #{connection.quoted_false})"
131 "(#{table_name}.is_private = #{connection.quoted_false})"
132 end
132 end
133 end
133 end
134 end
134 end
135
135
136 # Returns true if usr or current user is allowed to view the issue
136 # Returns true if usr or current user is allowed to view the issue
137 def visible?(usr=nil)
137 def visible?(usr=nil)
138 (usr || User.current).allowed_to?(:view_issues, self.project) do |role, user|
138 (usr || User.current).allowed_to?(:view_issues, self.project) do |role, user|
139 if user.logged?
139 if user.logged?
140 case role.issues_visibility
140 case role.issues_visibility
141 when 'all'
141 when 'all'
142 true
142 true
143 when 'default'
143 when 'default'
144 !self.is_private? || (self.author == user || user.is_or_belongs_to?(assigned_to))
144 !self.is_private? || (self.author == user || user.is_or_belongs_to?(assigned_to))
145 when 'own'
145 when 'own'
146 self.author == user || user.is_or_belongs_to?(assigned_to)
146 self.author == user || user.is_or_belongs_to?(assigned_to)
147 else
147 else
148 false
148 false
149 end
149 end
150 else
150 else
151 !self.is_private?
151 !self.is_private?
152 end
152 end
153 end
153 end
154 end
154 end
155
155
156 # Returns true if user or current user is allowed to edit or add a note to the issue
156 # Returns true if user or current user is allowed to edit or add a note to the issue
157 def editable?(user=User.current)
157 def editable?(user=User.current)
158 user.allowed_to?(:edit_issues, project) || user.allowed_to?(:add_issue_notes, project)
158 user.allowed_to?(:edit_issues, project) || user.allowed_to?(:add_issue_notes, project)
159 end
159 end
160
160
161 def initialize(attributes=nil, *args)
161 def initialize(attributes=nil, *args)
162 super
162 super
163 if new_record?
163 if new_record?
164 # set default values for new records only
164 # set default values for new records only
165 self.status ||= IssueStatus.default
165 self.status ||= IssueStatus.default
166 self.priority ||= IssuePriority.default
166 self.priority ||= IssuePriority.default
167 self.watcher_user_ids = []
167 self.watcher_user_ids = []
168 end
168 end
169 end
169 end
170
170
171 def create_or_update
171 def create_or_update
172 super
172 super
173 ensure
173 ensure
174 @status_was = nil
174 @status_was = nil
175 end
175 end
176 private :create_or_update
176 private :create_or_update
177
177
178 # AR#Persistence#destroy would raise and RecordNotFound exception
178 # AR#Persistence#destroy would raise and RecordNotFound exception
179 # if the issue was already deleted or updated (non matching lock_version).
179 # if the issue was already deleted or updated (non matching lock_version).
180 # This is a problem when bulk deleting issues or deleting a project
180 # This is a problem when bulk deleting issues or deleting a project
181 # (because an issue may already be deleted if its parent was deleted
181 # (because an issue may already be deleted if its parent was deleted
182 # first).
182 # first).
183 # The issue is reloaded by the nested_set before being deleted so
183 # The issue is reloaded by the nested_set before being deleted so
184 # the lock_version condition should not be an issue but we handle it.
184 # the lock_version condition should not be an issue but we handle it.
185 def destroy
185 def destroy
186 super
186 super
187 rescue ActiveRecord::RecordNotFound
187 rescue ActiveRecord::RecordNotFound
188 # Stale or already deleted
188 # Stale or already deleted
189 begin
189 begin
190 reload
190 reload
191 rescue ActiveRecord::RecordNotFound
191 rescue ActiveRecord::RecordNotFound
192 # The issue was actually already deleted
192 # The issue was actually already deleted
193 @destroyed = true
193 @destroyed = true
194 return freeze
194 return freeze
195 end
195 end
196 # The issue was stale, retry to destroy
196 # The issue was stale, retry to destroy
197 super
197 super
198 end
198 end
199
199
200 alias :base_reload :reload
200 alias :base_reload :reload
201 def reload(*args)
201 def reload(*args)
202 @workflow_rule_by_attribute = nil
202 @workflow_rule_by_attribute = nil
203 @assignable_versions = nil
203 @assignable_versions = nil
204 @relations = nil
204 @relations = nil
205 base_reload(*args)
205 base_reload(*args)
206 end
206 end
207
207
208 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
208 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
209 def available_custom_fields
209 def available_custom_fields
210 (project && tracker) ? (project.all_issue_custom_fields & tracker.custom_fields) : []
210 (project && tracker) ? (project.all_issue_custom_fields & tracker.custom_fields) : []
211 end
211 end
212
212
213 def visible_custom_field_values(user=nil)
213 def visible_custom_field_values(user=nil)
214 user_real = user || User.current
214 user_real = user || User.current
215 custom_field_values.select do |value|
215 custom_field_values.select do |value|
216 value.custom_field.visible_by?(project, user_real)
216 value.custom_field.visible_by?(project, user_real)
217 end
217 end
218 end
218 end
219
219
220 # Copies attributes from another issue, arg can be an id or an Issue
220 # Copies attributes from another issue, arg can be an id or an Issue
221 def copy_from(arg, options={})
221 def copy_from(arg, options={})
222 issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg)
222 issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg)
223 self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
223 self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
224 self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
224 self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
225 self.status = issue.status
225 self.status = issue.status
226 self.author = User.current
226 self.author = User.current
227 unless options[:attachments] == false
227 unless options[:attachments] == false
228 self.attachments = issue.attachments.map do |attachement|
228 self.attachments = issue.attachments.map do |attachement|
229 attachement.copy(:container => self)
229 attachement.copy(:container => self)
230 end
230 end
231 end
231 end
232 @copied_from = issue
232 @copied_from = issue
233 @copy_options = options
233 @copy_options = options
234 self
234 self
235 end
235 end
236
236
237 # Returns an unsaved copy of the issue
237 # Returns an unsaved copy of the issue
238 def copy(attributes=nil, copy_options={})
238 def copy(attributes=nil, copy_options={})
239 copy = self.class.new.copy_from(self, copy_options)
239 copy = self.class.new.copy_from(self, copy_options)
240 copy.attributes = attributes if attributes
240 copy.attributes = attributes if attributes
241 copy
241 copy
242 end
242 end
243
243
244 # Returns true if the issue is a copy
244 # Returns true if the issue is a copy
245 def copy?
245 def copy?
246 @copied_from.present?
246 @copied_from.present?
247 end
247 end
248
248
249 # Moves/copies an issue to a new project and tracker
249 # Moves/copies an issue to a new project and tracker
250 # Returns the moved/copied issue on success, false on failure
250 # Returns the moved/copied issue on success, false on failure
251 def move_to_project(new_project, new_tracker=nil, options={})
251 def move_to_project(new_project, new_tracker=nil, options={})
252 ActiveSupport::Deprecation.warn "Issue#move_to_project is deprecated, use #project= instead."
252 ActiveSupport::Deprecation.warn "Issue#move_to_project is deprecated, use #project= instead."
253
253
254 if options[:copy]
254 if options[:copy]
255 issue = self.copy
255 issue = self.copy
256 else
256 else
257 issue = self
257 issue = self
258 end
258 end
259
259
260 issue.init_journal(User.current, options[:notes])
260 issue.init_journal(User.current, options[:notes])
261
261
262 # Preserve previous behaviour
262 # Preserve previous behaviour
263 # #move_to_project doesn't change tracker automatically
263 # #move_to_project doesn't change tracker automatically
264 issue.send :project=, new_project, true
264 issue.send :project=, new_project, true
265 if new_tracker
265 if new_tracker
266 issue.tracker = new_tracker
266 issue.tracker = new_tracker
267 end
267 end
268 # Allow bulk setting of attributes on the issue
268 # Allow bulk setting of attributes on the issue
269 if options[:attributes]
269 if options[:attributes]
270 issue.attributes = options[:attributes]
270 issue.attributes = options[:attributes]
271 end
271 end
272
272
273 issue.save ? issue : false
273 issue.save ? issue : false
274 end
274 end
275
275
276 def status_id=(sid)
276 def status_id=(sid)
277 self.status = nil
277 self.status = nil
278 result = write_attribute(:status_id, sid)
278 result = write_attribute(:status_id, sid)
279 @workflow_rule_by_attribute = nil
279 @workflow_rule_by_attribute = nil
280 result
280 result
281 end
281 end
282
282
283 def priority_id=(pid)
283 def priority_id=(pid)
284 self.priority = nil
284 self.priority = nil
285 write_attribute(:priority_id, pid)
285 write_attribute(:priority_id, pid)
286 end
286 end
287
287
288 def category_id=(cid)
288 def category_id=(cid)
289 self.category = nil
289 self.category = nil
290 write_attribute(:category_id, cid)
290 write_attribute(:category_id, cid)
291 end
291 end
292
292
293 def fixed_version_id=(vid)
293 def fixed_version_id=(vid)
294 self.fixed_version = nil
294 self.fixed_version = nil
295 write_attribute(:fixed_version_id, vid)
295 write_attribute(:fixed_version_id, vid)
296 end
296 end
297
297
298 def tracker_id=(tid)
298 def tracker_id=(tid)
299 self.tracker = nil
299 self.tracker = nil
300 result = write_attribute(:tracker_id, tid)
300 result = write_attribute(:tracker_id, tid)
301 @custom_field_values = nil
301 @custom_field_values = nil
302 @workflow_rule_by_attribute = nil
302 @workflow_rule_by_attribute = nil
303 result
303 result
304 end
304 end
305
305
306 def project_id=(project_id)
306 def project_id=(project_id)
307 if project_id.to_s != self.project_id.to_s
307 if project_id.to_s != self.project_id.to_s
308 self.project = (project_id.present? ? Project.find_by_id(project_id) : nil)
308 self.project = (project_id.present? ? Project.find_by_id(project_id) : nil)
309 end
309 end
310 end
310 end
311
311
312 def project=(project, keep_tracker=false)
312 def project=(project, keep_tracker=false)
313 project_was = self.project
313 project_was = self.project
314 write_attribute(:project_id, project ? project.id : nil)
314 write_attribute(:project_id, project ? project.id : nil)
315 association_instance_set('project', project)
315 association_instance_set('project', project)
316 if project_was && project && project_was != project
316 if project_was && project && project_was != project
317 @assignable_versions = nil
317 @assignable_versions = nil
318
318
319 unless keep_tracker || project.trackers.include?(tracker)
319 unless keep_tracker || project.trackers.include?(tracker)
320 self.tracker = project.trackers.first
320 self.tracker = project.trackers.first
321 end
321 end
322 # Reassign to the category with same name if any
322 # Reassign to the category with same name if any
323 if category
323 if category
324 self.category = project.issue_categories.find_by_name(category.name)
324 self.category = project.issue_categories.find_by_name(category.name)
325 end
325 end
326 # Keep the fixed_version if it's still valid in the new_project
326 # Keep the fixed_version if it's still valid in the new_project
327 if fixed_version && fixed_version.project != project && !project.shared_versions.include?(fixed_version)
327 if fixed_version && fixed_version.project != project && !project.shared_versions.include?(fixed_version)
328 self.fixed_version = nil
328 self.fixed_version = nil
329 end
329 end
330 # Clear the parent task if it's no longer valid
330 # Clear the parent task if it's no longer valid
331 unless valid_parent_project?
331 unless valid_parent_project?
332 self.parent_issue_id = nil
332 self.parent_issue_id = nil
333 end
333 end
334 @custom_field_values = nil
334 @custom_field_values = nil
335 end
335 end
336 end
336 end
337
337
338 def description=(arg)
338 def description=(arg)
339 if arg.is_a?(String)
339 if arg.is_a?(String)
340 arg = arg.gsub(/(\r\n|\n|\r)/, "\r\n")
340 arg = arg.gsub(/(\r\n|\n|\r)/, "\r\n")
341 end
341 end
342 write_attribute(:description, arg)
342 write_attribute(:description, arg)
343 end
343 end
344
344
345 # Overrides assign_attributes so that project and tracker get assigned first
345 # Overrides assign_attributes so that project and tracker get assigned first
346 def assign_attributes_with_project_and_tracker_first(new_attributes, *args)
346 def assign_attributes_with_project_and_tracker_first(new_attributes, *args)
347 return if new_attributes.nil?
347 return if new_attributes.nil?
348 attrs = new_attributes.dup
348 attrs = new_attributes.dup
349 attrs.stringify_keys!
349 attrs.stringify_keys!
350
350
351 %w(project project_id tracker tracker_id).each do |attr|
351 %w(project project_id tracker tracker_id).each do |attr|
352 if attrs.has_key?(attr)
352 if attrs.has_key?(attr)
353 send "#{attr}=", attrs.delete(attr)
353 send "#{attr}=", attrs.delete(attr)
354 end
354 end
355 end
355 end
356 send :assign_attributes_without_project_and_tracker_first, attrs, *args
356 send :assign_attributes_without_project_and_tracker_first, attrs, *args
357 end
357 end
358 # Do not redefine alias chain on reload (see #4838)
358 # Do not redefine alias chain on reload (see #4838)
359 alias_method_chain(:assign_attributes, :project_and_tracker_first) unless method_defined?(:assign_attributes_without_project_and_tracker_first)
359 alias_method_chain(:assign_attributes, :project_and_tracker_first) unless method_defined?(:assign_attributes_without_project_and_tracker_first)
360
360
361 def attributes=(new_attributes)
361 def attributes=(new_attributes)
362 assign_attributes new_attributes
362 assign_attributes new_attributes
363 end
363 end
364
364
365 def estimated_hours=(h)
365 def estimated_hours=(h)
366 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
366 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
367 end
367 end
368
368
369 safe_attributes 'project_id',
369 safe_attributes 'project_id',
370 :if => lambda {|issue, user|
370 :if => lambda {|issue, user|
371 if issue.new_record?
371 if issue.new_record?
372 issue.copy?
372 issue.copy?
373 elsif user.allowed_to?(:move_issues, issue.project)
373 elsif user.allowed_to?(:move_issues, issue.project)
374 Issue.allowed_target_projects_on_move.count > 1
374 Issue.allowed_target_projects_on_move.count > 1
375 end
375 end
376 }
376 }
377
377
378 safe_attributes 'tracker_id',
378 safe_attributes 'tracker_id',
379 'status_id',
379 'status_id',
380 'category_id',
380 'category_id',
381 'assigned_to_id',
381 'assigned_to_id',
382 'priority_id',
382 'priority_id',
383 'fixed_version_id',
383 'fixed_version_id',
384 'subject',
384 'subject',
385 'description',
385 'description',
386 'start_date',
386 'start_date',
387 'due_date',
387 'due_date',
388 'done_ratio',
388 'done_ratio',
389 'estimated_hours',
389 'estimated_hours',
390 'custom_field_values',
390 'custom_field_values',
391 'custom_fields',
391 'custom_fields',
392 'lock_version',
392 'lock_version',
393 'notes',
393 'notes',
394 :if => lambda {|issue, user| issue.new_record? || user.allowed_to?(:edit_issues, issue.project) }
394 :if => lambda {|issue, user| issue.new_record? || user.allowed_to?(:edit_issues, issue.project) }
395
395
396 safe_attributes 'status_id',
396 safe_attributes 'status_id',
397 'assigned_to_id',
397 'assigned_to_id',
398 'fixed_version_id',
398 'fixed_version_id',
399 'done_ratio',
399 'done_ratio',
400 'lock_version',
400 'lock_version',
401 'notes',
401 'notes',
402 :if => lambda {|issue, user| issue.new_statuses_allowed_to(user).any? }
402 :if => lambda {|issue, user| issue.new_statuses_allowed_to(user).any? }
403
403
404 safe_attributes 'notes',
404 safe_attributes 'notes',
405 :if => lambda {|issue, user| user.allowed_to?(:add_issue_notes, issue.project)}
405 :if => lambda {|issue, user| user.allowed_to?(:add_issue_notes, issue.project)}
406
406
407 safe_attributes 'private_notes',
407 safe_attributes 'private_notes',
408 :if => lambda {|issue, user| !issue.new_record? && user.allowed_to?(:set_notes_private, issue.project)}
408 :if => lambda {|issue, user| !issue.new_record? && user.allowed_to?(:set_notes_private, issue.project)}
409
409
410 safe_attributes 'watcher_user_ids',
410 safe_attributes 'watcher_user_ids',
411 :if => lambda {|issue, user| issue.new_record? && user.allowed_to?(:add_issue_watchers, issue.project)}
411 :if => lambda {|issue, user| issue.new_record? && user.allowed_to?(:add_issue_watchers, issue.project)}
412
412
413 safe_attributes 'is_private',
413 safe_attributes 'is_private',
414 :if => lambda {|issue, user|
414 :if => lambda {|issue, user|
415 user.allowed_to?(:set_issues_private, issue.project) ||
415 user.allowed_to?(:set_issues_private, issue.project) ||
416 (issue.author == user && user.allowed_to?(:set_own_issues_private, issue.project))
416 (issue.author == user && user.allowed_to?(:set_own_issues_private, issue.project))
417 }
417 }
418
418
419 safe_attributes 'parent_issue_id',
419 safe_attributes 'parent_issue_id',
420 :if => lambda {|issue, user| (issue.new_record? || user.allowed_to?(:edit_issues, issue.project)) &&
420 :if => lambda {|issue, user| (issue.new_record? || user.allowed_to?(:edit_issues, issue.project)) &&
421 user.allowed_to?(:manage_subtasks, issue.project)}
421 user.allowed_to?(:manage_subtasks, issue.project)}
422
422
423 def safe_attribute_names(user=nil)
423 def safe_attribute_names(user=nil)
424 names = super
424 names = super
425 names -= disabled_core_fields
425 names -= disabled_core_fields
426 names -= read_only_attribute_names(user)
426 names -= read_only_attribute_names(user)
427 names
427 names
428 end
428 end
429
429
430 # Safely sets attributes
430 # Safely sets attributes
431 # Should be called from controllers instead of #attributes=
431 # Should be called from controllers instead of #attributes=
432 # attr_accessible is too rough because we still want things like
432 # attr_accessible is too rough because we still want things like
433 # Issue.new(:project => foo) to work
433 # Issue.new(:project => foo) to work
434 def safe_attributes=(attrs, user=User.current)
434 def safe_attributes=(attrs, user=User.current)
435 return unless attrs.is_a?(Hash)
435 return unless attrs.is_a?(Hash)
436
436
437 attrs = attrs.deep_dup
437 attrs = attrs.deep_dup
438
438
439 # Project and Tracker must be set before since new_statuses_allowed_to depends on it.
439 # Project and Tracker must be set before since new_statuses_allowed_to depends on it.
440 if (p = attrs.delete('project_id')) && safe_attribute?('project_id')
440 if (p = attrs.delete('project_id')) && safe_attribute?('project_id')
441 if allowed_target_projects(user).where(:id => p.to_i).exists?
441 if allowed_target_projects(user).where(:id => p.to_i).exists?
442 self.project_id = p
442 self.project_id = p
443 end
443 end
444 end
444 end
445
445
446 if (t = attrs.delete('tracker_id')) && safe_attribute?('tracker_id')
446 if (t = attrs.delete('tracker_id')) && safe_attribute?('tracker_id')
447 self.tracker_id = t
447 self.tracker_id = t
448 end
448 end
449
449
450 if (s = attrs.delete('status_id')) && safe_attribute?('status_id')
450 if (s = attrs.delete('status_id')) && safe_attribute?('status_id')
451 if new_statuses_allowed_to(user).collect(&:id).include?(s.to_i)
451 if new_statuses_allowed_to(user).collect(&:id).include?(s.to_i)
452 self.status_id = s
452 self.status_id = s
453 end
453 end
454 end
454 end
455
455
456 attrs = delete_unsafe_attributes(attrs, user)
456 attrs = delete_unsafe_attributes(attrs, user)
457 return if attrs.empty?
457 return if attrs.empty?
458
458
459 unless leaf?
459 unless leaf?
460 attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
460 attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
461 end
461 end
462
462
463 if attrs['parent_issue_id'].present?
463 if attrs['parent_issue_id'].present?
464 s = attrs['parent_issue_id'].to_s
464 s = attrs['parent_issue_id'].to_s
465 unless (m = s.match(%r{\A#?(\d+)\z})) && (m[1] == parent_id.to_s || Issue.visible(user).exists?(m[1]))
465 unless (m = s.match(%r{\A#?(\d+)\z})) && (m[1] == parent_id.to_s || Issue.visible(user).exists?(m[1]))
466 @invalid_parent_issue_id = attrs.delete('parent_issue_id')
466 @invalid_parent_issue_id = attrs.delete('parent_issue_id')
467 end
467 end
468 end
468 end
469
469
470 if attrs['custom_field_values'].present?
470 if attrs['custom_field_values'].present?
471 editable_custom_field_ids = editable_custom_field_values(user).map {|v| v.custom_field_id.to_s}
471 editable_custom_field_ids = editable_custom_field_values(user).map {|v| v.custom_field_id.to_s}
472 attrs['custom_field_values'].select! {|k, v| editable_custom_field_ids.include?(k.to_s)}
472 attrs['custom_field_values'].select! {|k, v| editable_custom_field_ids.include?(k.to_s)}
473 end
473 end
474
474
475 if attrs['custom_fields'].present?
475 if attrs['custom_fields'].present?
476 editable_custom_field_ids = editable_custom_field_values(user).map {|v| v.custom_field_id.to_s}
476 editable_custom_field_ids = editable_custom_field_values(user).map {|v| v.custom_field_id.to_s}
477 attrs['custom_fields'].select! {|c| editable_custom_field_ids.include?(c['id'].to_s)}
477 attrs['custom_fields'].select! {|c| editable_custom_field_ids.include?(c['id'].to_s)}
478 end
478 end
479
479
480 # mass-assignment security bypass
480 # mass-assignment security bypass
481 assign_attributes attrs, :without_protection => true
481 assign_attributes attrs, :without_protection => true
482 end
482 end
483
483
484 def disabled_core_fields
484 def disabled_core_fields
485 tracker ? tracker.disabled_core_fields : []
485 tracker ? tracker.disabled_core_fields : []
486 end
486 end
487
487
488 # Returns the custom_field_values that can be edited by the given user
488 # Returns the custom_field_values that can be edited by the given user
489 def editable_custom_field_values(user=nil)
489 def editable_custom_field_values(user=nil)
490 visible_custom_field_values(user).reject do |value|
490 visible_custom_field_values(user).reject do |value|
491 read_only_attribute_names(user).include?(value.custom_field_id.to_s)
491 read_only_attribute_names(user).include?(value.custom_field_id.to_s)
492 end
492 end
493 end
493 end
494
494
495 # Returns the custom fields that can be edited by the given user
495 # Returns the custom fields that can be edited by the given user
496 def editable_custom_fields(user=nil)
496 def editable_custom_fields(user=nil)
497 editable_custom_field_values(user).map(&:custom_field).uniq
497 editable_custom_field_values(user).map(&:custom_field).uniq
498 end
498 end
499
499
500 # Returns the names of attributes that are read-only for user or the current user
500 # Returns the names of attributes that are read-only for user or the current user
501 # For users with multiple roles, the read-only fields are the intersection of
501 # For users with multiple roles, the read-only fields are the intersection of
502 # read-only fields of each role
502 # read-only fields of each role
503 # The result is an array of strings where sustom fields are represented with their ids
503 # The result is an array of strings where sustom fields are represented with their ids
504 #
504 #
505 # Examples:
505 # Examples:
506 # issue.read_only_attribute_names # => ['due_date', '2']
506 # issue.read_only_attribute_names # => ['due_date', '2']
507 # issue.read_only_attribute_names(user) # => []
507 # issue.read_only_attribute_names(user) # => []
508 def read_only_attribute_names(user=nil)
508 def read_only_attribute_names(user=nil)
509 workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'readonly'}.keys
509 workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'readonly'}.keys
510 end
510 end
511
511
512 # Returns the names of required attributes for user or the current user
512 # Returns the names of required attributes for user or the current user
513 # For users with multiple roles, the required fields are the intersection of
513 # For users with multiple roles, the required fields are the intersection of
514 # required fields of each role
514 # required fields of each role
515 # The result is an array of strings where sustom fields are represented with their ids
515 # The result is an array of strings where sustom fields are represented with their ids
516 #
516 #
517 # Examples:
517 # Examples:
518 # issue.required_attribute_names # => ['due_date', '2']
518 # issue.required_attribute_names # => ['due_date', '2']
519 # issue.required_attribute_names(user) # => []
519 # issue.required_attribute_names(user) # => []
520 def required_attribute_names(user=nil)
520 def required_attribute_names(user=nil)
521 workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'required'}.keys
521 workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'required'}.keys
522 end
522 end
523
523
524 # Returns true if the attribute is required for user
524 # Returns true if the attribute is required for user
525 def required_attribute?(name, user=nil)
525 def required_attribute?(name, user=nil)
526 required_attribute_names(user).include?(name.to_s)
526 required_attribute_names(user).include?(name.to_s)
527 end
527 end
528
528
529 # Returns a hash of the workflow rule by attribute for the given user
529 # Returns a hash of the workflow rule by attribute for the given user
530 #
530 #
531 # Examples:
531 # Examples:
532 # issue.workflow_rule_by_attribute # => {'due_date' => 'required', 'start_date' => 'readonly'}
532 # issue.workflow_rule_by_attribute # => {'due_date' => 'required', 'start_date' => 'readonly'}
533 def workflow_rule_by_attribute(user=nil)
533 def workflow_rule_by_attribute(user=nil)
534 return @workflow_rule_by_attribute if @workflow_rule_by_attribute && user.nil?
534 return @workflow_rule_by_attribute if @workflow_rule_by_attribute && user.nil?
535
535
536 user_real = user || User.current
536 user_real = user || User.current
537 roles = user_real.admin ? Role.all : user_real.roles_for_project(project)
537 roles = user_real.admin ? Role.all : user_real.roles_for_project(project)
538 return {} if roles.empty?
538 return {} if roles.empty?
539
539
540 result = {}
540 result = {}
541 workflow_permissions = WorkflowPermission.where(:tracker_id => tracker_id, :old_status_id => status_id, :role_id => roles.map(&:id))
541 workflow_permissions = WorkflowPermission.where(:tracker_id => tracker_id, :old_status_id => status_id, :role_id => roles.map(&:id))
542 if workflow_permissions.any?
542 if workflow_permissions.any?
543 workflow_rules = workflow_permissions.inject({}) do |h, wp|
543 workflow_rules = workflow_permissions.inject({}) do |h, wp|
544 h[wp.field_name] ||= []
544 h[wp.field_name] ||= []
545 h[wp.field_name] << wp.rule
545 h[wp.field_name] << wp.rule
546 h
546 h
547 end
547 end
548 workflow_rules.each do |attr, rules|
548 workflow_rules.each do |attr, rules|
549 next if rules.size < roles.size
549 next if rules.size < roles.size
550 uniq_rules = rules.uniq
550 uniq_rules = rules.uniq
551 if uniq_rules.size == 1
551 if uniq_rules.size == 1
552 result[attr] = uniq_rules.first
552 result[attr] = uniq_rules.first
553 else
553 else
554 result[attr] = 'required'
554 result[attr] = 'required'
555 end
555 end
556 end
556 end
557 end
557 end
558 @workflow_rule_by_attribute = result if user.nil?
558 @workflow_rule_by_attribute = result if user.nil?
559 result
559 result
560 end
560 end
561 private :workflow_rule_by_attribute
561 private :workflow_rule_by_attribute
562
562
563 def done_ratio
563 def done_ratio
564 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
564 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
565 status.default_done_ratio
565 status.default_done_ratio
566 else
566 else
567 read_attribute(:done_ratio)
567 read_attribute(:done_ratio)
568 end
568 end
569 end
569 end
570
570
571 def self.use_status_for_done_ratio?
571 def self.use_status_for_done_ratio?
572 Setting.issue_done_ratio == 'issue_status'
572 Setting.issue_done_ratio == 'issue_status'
573 end
573 end
574
574
575 def self.use_field_for_done_ratio?
575 def self.use_field_for_done_ratio?
576 Setting.issue_done_ratio == 'issue_field'
576 Setting.issue_done_ratio == 'issue_field'
577 end
577 end
578
578
579 def validate_issue
579 def validate_issue
580 if due_date && start_date && (start_date_changed? || due_date_changed?) && due_date < start_date
580 if due_date && start_date && (start_date_changed? || due_date_changed?) && due_date < start_date
581 errors.add :due_date, :greater_than_start_date
581 errors.add :due_date, :greater_than_start_date
582 end
582 end
583
583
584 if start_date && start_date_changed? && soonest_start && start_date < soonest_start
584 if start_date && start_date_changed? && soonest_start && start_date < soonest_start
585 errors.add :start_date, :earlier_than_minimum_start_date, :date => format_date(soonest_start)
585 errors.add :start_date, :earlier_than_minimum_start_date, :date => format_date(soonest_start)
586 end
586 end
587
587
588 if fixed_version
588 if fixed_version
589 if !assignable_versions.include?(fixed_version)
589 if !assignable_versions.include?(fixed_version)
590 errors.add :fixed_version_id, :inclusion
590 errors.add :fixed_version_id, :inclusion
591 elsif reopened? && fixed_version.closed?
591 elsif reopened? && fixed_version.closed?
592 errors.add :base, I18n.t(:error_can_not_reopen_issue_on_closed_version)
592 errors.add :base, I18n.t(:error_can_not_reopen_issue_on_closed_version)
593 end
593 end
594 end
594 end
595
595
596 # Checks that the issue can not be added/moved to a disabled tracker
596 # Checks that the issue can not be added/moved to a disabled tracker
597 if project && (tracker_id_changed? || project_id_changed?)
597 if project && (tracker_id_changed? || project_id_changed?)
598 unless project.trackers.include?(tracker)
598 unless project.trackers.include?(tracker)
599 errors.add :tracker_id, :inclusion
599 errors.add :tracker_id, :inclusion
600 end
600 end
601 end
601 end
602
602
603 # Checks parent issue assignment
603 # Checks parent issue assignment
604 if @invalid_parent_issue_id.present?
604 if @invalid_parent_issue_id.present?
605 errors.add :parent_issue_id, :invalid
605 errors.add :parent_issue_id, :invalid
606 elsif @parent_issue
606 elsif @parent_issue
607 if !valid_parent_project?(@parent_issue)
607 if !valid_parent_project?(@parent_issue)
608 errors.add :parent_issue_id, :invalid
608 errors.add :parent_issue_id, :invalid
609 elsif (@parent_issue != parent) && (all_dependent_issues.include?(@parent_issue) || @parent_issue.all_dependent_issues.include?(self))
609 elsif (@parent_issue != parent) && (all_dependent_issues.include?(@parent_issue) || @parent_issue.all_dependent_issues.include?(self))
610 errors.add :parent_issue_id, :invalid
610 errors.add :parent_issue_id, :invalid
611 elsif !new_record?
611 elsif !new_record?
612 # moving an existing issue
612 # moving an existing issue
613 if @parent_issue.root_id != root_id
613 if @parent_issue.root_id != root_id
614 # we can always move to another tree
614 # we can always move to another tree
615 elsif move_possible?(@parent_issue)
615 elsif move_possible?(@parent_issue)
616 # move accepted inside tree
616 # move accepted inside tree
617 else
617 else
618 errors.add :parent_issue_id, :invalid
618 errors.add :parent_issue_id, :invalid
619 end
619 end
620 end
620 end
621 end
621 end
622 end
622 end
623
623
624 # Validates the issue against additional workflow requirements
624 # Validates the issue against additional workflow requirements
625 def validate_required_fields
625 def validate_required_fields
626 user = new_record? ? author : current_journal.try(:user)
626 user = new_record? ? author : current_journal.try(:user)
627
627
628 required_attribute_names(user).each do |attribute|
628 required_attribute_names(user).each do |attribute|
629 if attribute =~ /^\d+$/
629 if attribute =~ /^\d+$/
630 attribute = attribute.to_i
630 attribute = attribute.to_i
631 v = custom_field_values.detect {|v| v.custom_field_id == attribute }
631 v = custom_field_values.detect {|v| v.custom_field_id == attribute }
632 if v && v.value.blank?
632 if v && v.value.blank?
633 errors.add :base, v.custom_field.name + ' ' + l('activerecord.errors.messages.blank')
633 errors.add :base, v.custom_field.name + ' ' + l('activerecord.errors.messages.blank')
634 end
634 end
635 else
635 else
636 if respond_to?(attribute) && send(attribute).blank?
636 if respond_to?(attribute) && send(attribute).blank?
637 errors.add attribute, :blank
637 errors.add attribute, :blank
638 end
638 end
639 end
639 end
640 end
640 end
641 end
641 end
642
642
643 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
643 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
644 # even if the user turns off the setting later
644 # even if the user turns off the setting later
645 def update_done_ratio_from_issue_status
645 def update_done_ratio_from_issue_status
646 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
646 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
647 self.done_ratio = status.default_done_ratio
647 self.done_ratio = status.default_done_ratio
648 end
648 end
649 end
649 end
650
650
651 def init_journal(user, notes = "")
651 def init_journal(user, notes = "")
652 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
652 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
653 if new_record?
653 if new_record?
654 @current_journal.notify = false
654 @current_journal.notify = false
655 else
655 else
656 @attributes_before_change = attributes.dup
656 @attributes_before_change = attributes.dup
657 @custom_values_before_change = {}
657 @custom_values_before_change = {}
658 self.custom_field_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
658 self.custom_field_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
659 end
659 end
660 @current_journal
660 @current_journal
661 end
661 end
662
662
663 # Returns the id of the last journal or nil
663 # Returns the id of the last journal or nil
664 def last_journal_id
664 def last_journal_id
665 if new_record?
665 if new_record?
666 nil
666 nil
667 else
667 else
668 journals.maximum(:id)
668 journals.maximum(:id)
669 end
669 end
670 end
670 end
671
671
672 # Returns a scope for journals that have an id greater than journal_id
672 # Returns a scope for journals that have an id greater than journal_id
673 def journals_after(journal_id)
673 def journals_after(journal_id)
674 scope = journals.reorder("#{Journal.table_name}.id ASC")
674 scope = journals.reorder("#{Journal.table_name}.id ASC")
675 if journal_id.present?
675 if journal_id.present?
676 scope = scope.where("#{Journal.table_name}.id > ?", journal_id.to_i)
676 scope = scope.where("#{Journal.table_name}.id > ?", journal_id.to_i)
677 end
677 end
678 scope
678 scope
679 end
679 end
680
680
681 # Returns the initial status of the issue
681 # Returns the initial status of the issue
682 # Returns nil for a new issue
682 # Returns nil for a new issue
683 def status_was
683 def status_was
684 if status_id_was && status_id_was.to_i > 0
684 if status_id_was && status_id_was.to_i > 0
685 @status_was ||= IssueStatus.find_by_id(status_id_was)
685 @status_was ||= IssueStatus.find_by_id(status_id_was)
686 end
686 end
687 end
687 end
688
688
689 # Return true if the issue is closed, otherwise false
689 # Return true if the issue is closed, otherwise false
690 def closed?
690 def closed?
691 self.status.is_closed?
691 self.status.is_closed?
692 end
692 end
693
693
694 # Return true if the issue is being reopened
694 # Return true if the issue is being reopened
695 def reopened?
695 def reopened?
696 if !new_record? && status_id_changed?
696 if !new_record? && status_id_changed?
697 status_was = IssueStatus.find_by_id(status_id_was)
697 status_was = IssueStatus.find_by_id(status_id_was)
698 status_new = IssueStatus.find_by_id(status_id)
698 status_new = IssueStatus.find_by_id(status_id)
699 if status_was && status_new && status_was.is_closed? && !status_new.is_closed?
699 if status_was && status_new && status_was.is_closed? && !status_new.is_closed?
700 return true
700 return true
701 end
701 end
702 end
702 end
703 false
703 false
704 end
704 end
705
705
706 # Return true if the issue is being closed
706 # Return true if the issue is being closed
707 def closing?
707 def closing?
708 if !new_record? && status_id_changed?
708 if !new_record? && status_id_changed?
709 if status_was && status && !status_was.is_closed? && status.is_closed?
709 if status_was && status && !status_was.is_closed? && status.is_closed?
710 return true
710 return true
711 end
711 end
712 end
712 end
713 false
713 false
714 end
714 end
715
715
716 # Returns true if the issue is overdue
716 # Returns true if the issue is overdue
717 def overdue?
717 def overdue?
718 !due_date.nil? && (due_date < Date.today) && !status.is_closed?
718 !due_date.nil? && (due_date < Date.today) && !status.is_closed?
719 end
719 end
720
720
721 # Is the amount of work done less than it should for the due date
721 # Is the amount of work done less than it should for the due date
722 def behind_schedule?
722 def behind_schedule?
723 return false if start_date.nil? || due_date.nil?
723 return false if start_date.nil? || due_date.nil?
724 done_date = start_date + ((due_date - start_date + 1) * done_ratio / 100).floor
724 done_date = start_date + ((due_date - start_date + 1) * done_ratio / 100).floor
725 return done_date <= Date.today
725 return done_date <= Date.today
726 end
726 end
727
727
728 # Does this issue have children?
728 # Does this issue have children?
729 def children?
729 def children?
730 !leaf?
730 !leaf?
731 end
731 end
732
732
733 # Users the issue can be assigned to
733 # Users the issue can be assigned to
734 def assignable_users
734 def assignable_users
735 users = project.assignable_users
735 users = project.assignable_users
736 users << author if author
736 users << author if author
737 users << assigned_to if assigned_to
737 users << assigned_to if assigned_to
738 users.uniq.sort
738 users.uniq.sort
739 end
739 end
740
740
741 # Versions that the issue can be assigned to
741 # Versions that the issue can be assigned to
742 def assignable_versions
742 def assignable_versions
743 return @assignable_versions if @assignable_versions
743 return @assignable_versions if @assignable_versions
744
744
745 versions = project.shared_versions.open.to_a
745 versions = project.shared_versions.open.to_a
746 if fixed_version
746 if fixed_version
747 if fixed_version_id_changed?
747 if fixed_version_id_changed?
748 # nothing to do
748 # nothing to do
749 elsif project_id_changed?
749 elsif project_id_changed?
750 if project.shared_versions.include?(fixed_version)
750 if project.shared_versions.include?(fixed_version)
751 versions << fixed_version
751 versions << fixed_version
752 end
752 end
753 else
753 else
754 versions << fixed_version
754 versions << fixed_version
755 end
755 end
756 end
756 end
757 @assignable_versions = versions.uniq.sort
757 @assignable_versions = versions.uniq.sort
758 end
758 end
759
759
760 # Returns true if this issue is blocked by another issue that is still open
760 # Returns true if this issue is blocked by another issue that is still open
761 def blocked?
761 def blocked?
762 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
762 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
763 end
763 end
764
764
765 # Returns an array of statuses that user is able to apply
765 # Returns an array of statuses that user is able to apply
766 def new_statuses_allowed_to(user=User.current, include_default=false)
766 def new_statuses_allowed_to(user=User.current, include_default=false)
767 if new_record? && @copied_from
767 if new_record? && @copied_from
768 [IssueStatus.default, @copied_from.status].compact.uniq.sort
768 [IssueStatus.default, @copied_from.status].compact.uniq.sort
769 else
769 else
770 initial_status = nil
770 initial_status = nil
771 if new_record?
771 if new_record?
772 initial_status = IssueStatus.default
772 initial_status = IssueStatus.default
773 elsif status_id_was
773 elsif status_id_was
774 initial_status = IssueStatus.find_by_id(status_id_was)
774 initial_status = IssueStatus.find_by_id(status_id_was)
775 end
775 end
776 initial_status ||= status
776 initial_status ||= status
777
777
778 initial_assigned_to_id = assigned_to_id_changed? ? assigned_to_id_was : assigned_to_id
778 initial_assigned_to_id = assigned_to_id_changed? ? assigned_to_id_was : assigned_to_id
779 assignee_transitions_allowed = initial_assigned_to_id.present? &&
779 assignee_transitions_allowed = initial_assigned_to_id.present? &&
780 (user.id == initial_assigned_to_id || user.group_ids.include?(initial_assigned_to_id))
780 (user.id == initial_assigned_to_id || user.group_ids.include?(initial_assigned_to_id))
781
781
782 statuses = initial_status.find_new_statuses_allowed_to(
782 statuses = initial_status.find_new_statuses_allowed_to(
783 user.admin ? Role.all : user.roles_for_project(project),
783 user.admin ? Role.all : user.roles_for_project(project),
784 tracker,
784 tracker,
785 author == user,
785 author == user,
786 assignee_transitions_allowed
786 assignee_transitions_allowed
787 )
787 )
788 statuses << initial_status unless statuses.empty?
788 statuses << initial_status unless statuses.empty?
789 statuses << IssueStatus.default if include_default
789 statuses << IssueStatus.default if include_default
790 statuses = statuses.compact.uniq.sort
790 statuses = statuses.compact.uniq.sort
791 blocked? ? statuses.reject {|s| s.is_closed?} : statuses
791 blocked? ? statuses.reject {|s| s.is_closed?} : statuses
792 end
792 end
793 end
793 end
794
794
795 # Returns the previous assignee if changed
795 # Returns the previous assignee if changed
796 def assigned_to_was
796 def assigned_to_was
797 # assigned_to_id_was is reset before after_save callbacks
797 # assigned_to_id_was is reset before after_save callbacks
798 user_id = @previous_assigned_to_id || assigned_to_id_was
798 user_id = @previous_assigned_to_id || assigned_to_id_was
799 if user_id && user_id != assigned_to_id
799 if user_id && user_id != assigned_to_id
800 @assigned_to_was ||= User.find_by_id(user_id)
800 @assigned_to_was ||= User.find_by_id(user_id)
801 end
801 end
802 end
802 end
803
803
804 # Returns the users that should be notified
804 # Returns the users that should be notified
805 def notified_users
805 def notified_users
806 notified = []
806 notified = []
807 # Author and assignee are always notified unless they have been
807 # Author and assignee are always notified unless they have been
808 # locked or don't want to be notified
808 # locked or don't want to be notified
809 notified << author if author
809 notified << author if author
810 if assigned_to
810 if assigned_to
811 notified += (assigned_to.is_a?(Group) ? assigned_to.users : [assigned_to])
811 notified += (assigned_to.is_a?(Group) ? assigned_to.users : [assigned_to])
812 end
812 end
813 if assigned_to_was
813 if assigned_to_was
814 notified += (assigned_to_was.is_a?(Group) ? assigned_to_was.users : [assigned_to_was])
814 notified += (assigned_to_was.is_a?(Group) ? assigned_to_was.users : [assigned_to_was])
815 end
815 end
816 notified = notified.select {|u| u.active? && u.notify_about?(self)}
816 notified = notified.select {|u| u.active? && u.notify_about?(self)}
817
817
818 notified += project.notified_users
818 notified += project.notified_users
819 notified.uniq!
819 notified.uniq!
820 # Remove users that can not view the issue
820 # Remove users that can not view the issue
821 notified.reject! {|user| !visible?(user)}
821 notified.reject! {|user| !visible?(user)}
822 notified
822 notified
823 end
823 end
824
824
825 # Returns the email addresses that should be notified
825 # Returns the email addresses that should be notified
826 def recipients
826 def recipients
827 notified_users.collect(&:mail)
827 notified_users.collect(&:mail)
828 end
828 end
829
829
830 def each_notification(users, &block)
830 def each_notification(users, &block)
831 if users.any?
831 if users.any?
832 if custom_field_values.detect {|value| !value.custom_field.visible?}
832 if custom_field_values.detect {|value| !value.custom_field.visible?}
833 users_by_custom_field_visibility = users.group_by do |user|
833 users_by_custom_field_visibility = users.group_by do |user|
834 visible_custom_field_values(user).map(&:custom_field_id).sort
834 visible_custom_field_values(user).map(&:custom_field_id).sort
835 end
835 end
836 users_by_custom_field_visibility.values.each do |users|
836 users_by_custom_field_visibility.values.each do |users|
837 yield(users)
837 yield(users)
838 end
838 end
839 else
839 else
840 yield(users)
840 yield(users)
841 end
841 end
842 end
842 end
843 end
843 end
844
844
845 # Returns the number of hours spent on this issue
845 # Returns the number of hours spent on this issue
846 def spent_hours
846 def spent_hours
847 @spent_hours ||= time_entries.sum(:hours) || 0
847 @spent_hours ||= time_entries.sum(:hours) || 0
848 end
848 end
849
849
850 # Returns the total number of hours spent on this issue and its descendants
850 # Returns the total number of hours spent on this issue and its descendants
851 #
851 #
852 # Example:
852 # Example:
853 # spent_hours => 0.0
853 # spent_hours => 0.0
854 # spent_hours => 50.2
854 # spent_hours => 50.2
855 def total_spent_hours
855 def total_spent_hours
856 @total_spent_hours ||=
856 @total_spent_hours ||=
857 self_and_descendants.
857 self_and_descendants.
858 joins("LEFT JOIN #{TimeEntry.table_name} ON #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id").
858 joins("LEFT JOIN #{TimeEntry.table_name} ON #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id").
859 sum("#{TimeEntry.table_name}.hours").to_f || 0.0
859 sum("#{TimeEntry.table_name}.hours").to_f || 0.0
860 end
860 end
861
861
862 def relations
862 def relations
863 @relations ||= IssueRelation::Relations.new(self, (relations_from + relations_to).sort)
863 @relations ||= IssueRelation::Relations.new(self, (relations_from + relations_to).sort)
864 end
864 end
865
865
866 # Preloads relations for a collection of issues
866 # Preloads relations for a collection of issues
867 def self.load_relations(issues)
867 def self.load_relations(issues)
868 if issues.any?
868 if issues.any?
869 relations = IssueRelation.where("issue_from_id IN (:ids) OR issue_to_id IN (:ids)", :ids => issues.map(&:id)).all
869 relations = IssueRelation.where("issue_from_id IN (:ids) OR issue_to_id IN (:ids)", :ids => issues.map(&:id)).all
870 issues.each do |issue|
870 issues.each do |issue|
871 issue.instance_variable_set "@relations", relations.select {|r| r.issue_from_id == issue.id || r.issue_to_id == issue.id}
871 issue.instance_variable_set "@relations", relations.select {|r| r.issue_from_id == issue.id || r.issue_to_id == issue.id}
872 end
872 end
873 end
873 end
874 end
874 end
875
875
876 # Preloads visible spent time for a collection of issues
876 # Preloads visible spent time for a collection of issues
877 def self.load_visible_spent_hours(issues, user=User.current)
877 def self.load_visible_spent_hours(issues, user=User.current)
878 if issues.any?
878 if issues.any?
879 hours_by_issue_id = TimeEntry.visible(user).group(:issue_id).sum(:hours)
879 hours_by_issue_id = TimeEntry.visible(user).group(:issue_id).sum(:hours)
880 issues.each do |issue|
880 issues.each do |issue|
881 issue.instance_variable_set "@spent_hours", (hours_by_issue_id[issue.id] || 0)
881 issue.instance_variable_set "@spent_hours", (hours_by_issue_id[issue.id] || 0)
882 end
882 end
883 end
883 end
884 end
884 end
885
885
886 # Preloads visible relations for a collection of issues
886 # Preloads visible relations for a collection of issues
887 def self.load_visible_relations(issues, user=User.current)
887 def self.load_visible_relations(issues, user=User.current)
888 if issues.any?
888 if issues.any?
889 issue_ids = issues.map(&:id)
889 issue_ids = issues.map(&:id)
890 # Relations with issue_from in given issues and visible issue_to
890 # Relations with issue_from in given issues and visible issue_to
891 relations_from = IssueRelation.joins(:issue_to => :project).
891 relations_from = IssueRelation.joins(:issue_to => :project).
892 where(visible_condition(user)).where(:issue_from_id => issue_ids).to_a
892 where(visible_condition(user)).where(:issue_from_id => issue_ids).to_a
893 # Relations with issue_to in given issues and visible issue_from
893 # Relations with issue_to in given issues and visible issue_from
894 relations_to = IssueRelation.joins(:issue_from => :project).
894 relations_to = IssueRelation.joins(:issue_from => :project).
895 where(visible_condition(user)).
895 where(visible_condition(user)).
896 where(:issue_to_id => issue_ids).to_a
896 where(:issue_to_id => issue_ids).to_a
897 issues.each do |issue|
897 issues.each do |issue|
898 relations =
898 relations =
899 relations_from.select {|relation| relation.issue_from_id == issue.id} +
899 relations_from.select {|relation| relation.issue_from_id == issue.id} +
900 relations_to.select {|relation| relation.issue_to_id == issue.id}
900 relations_to.select {|relation| relation.issue_to_id == issue.id}
901
901
902 issue.instance_variable_set "@relations", IssueRelation::Relations.new(issue, relations.sort)
902 issue.instance_variable_set "@relations", IssueRelation::Relations.new(issue, relations.sort)
903 end
903 end
904 end
904 end
905 end
905 end
906
906
907 # Finds an issue relation given its id.
907 # Finds an issue relation given its id.
908 def find_relation(relation_id)
908 def find_relation(relation_id)
909 IssueRelation.where("issue_to_id = ? OR issue_from_id = ?", id, id).find(relation_id)
909 IssueRelation.where("issue_to_id = ? OR issue_from_id = ?", id, id).find(relation_id)
910 end
910 end
911
911
912 # Returns all the other issues that depend on the issue
912 # Returns all the other issues that depend on the issue
913 # The algorithm is a modified breadth first search (bfs)
913 # The algorithm is a modified breadth first search (bfs)
914 def all_dependent_issues(except=[])
914 def all_dependent_issues(except=[])
915 # The found dependencies
915 # The found dependencies
916 dependencies = []
916 dependencies = []
917
917
918 # The visited flag for every node (issue) used by the breadth first search
918 # The visited flag for every node (issue) used by the breadth first search
919 eNOT_DISCOVERED = 0 # The issue is "new" to the algorithm, it has not seen it before.
919 eNOT_DISCOVERED = 0 # The issue is "new" to the algorithm, it has not seen it before.
920
920
921 ePROCESS_ALL = 1 # The issue is added to the queue. Process both children and relations of
921 ePROCESS_ALL = 1 # The issue is added to the queue. Process both children and relations of
922 # the issue when it is processed.
922 # the issue when it is processed.
923
923
924 ePROCESS_RELATIONS_ONLY = 2 # The issue was added to the queue and will be output as dependent issue,
924 ePROCESS_RELATIONS_ONLY = 2 # The issue was added to the queue and will be output as dependent issue,
925 # but its children will not be added to the queue when it is processed.
925 # but its children will not be added to the queue when it is processed.
926
926
927 eRELATIONS_PROCESSED = 3 # The related issues, the parent issue and the issue itself have been added to
927 eRELATIONS_PROCESSED = 3 # The related issues, the parent issue and the issue itself have been added to
928 # the queue, but its children have not been added.
928 # the queue, but its children have not been added.
929
929
930 ePROCESS_CHILDREN_ONLY = 4 # The relations and the parent of the issue have been added to the queue, but
930 ePROCESS_CHILDREN_ONLY = 4 # The relations and the parent of the issue have been added to the queue, but
931 # the children still need to be processed.
931 # the children still need to be processed.
932
932
933 eALL_PROCESSED = 5 # The issue and all its children, its parent and its related issues have been
933 eALL_PROCESSED = 5 # The issue and all its children, its parent and its related issues have been
934 # added as dependent issues. It needs no further processing.
934 # added as dependent issues. It needs no further processing.
935
935
936 issue_status = Hash.new(eNOT_DISCOVERED)
936 issue_status = Hash.new(eNOT_DISCOVERED)
937
937
938 # The queue
938 # The queue
939 queue = []
939 queue = []
940
940
941 # Initialize the bfs, add start node (self) to the queue
941 # Initialize the bfs, add start node (self) to the queue
942 queue << self
942 queue << self
943 issue_status[self] = ePROCESS_ALL
943 issue_status[self] = ePROCESS_ALL
944
944
945 while (!queue.empty?) do
945 while (!queue.empty?) do
946 current_issue = queue.shift
946 current_issue = queue.shift
947 current_issue_status = issue_status[current_issue]
947 current_issue_status = issue_status[current_issue]
948 dependencies << current_issue
948 dependencies << current_issue
949
949
950 # Add parent to queue, if not already in it.
950 # Add parent to queue, if not already in it.
951 parent = current_issue.parent
951 parent = current_issue.parent
952 parent_status = issue_status[parent]
952 parent_status = issue_status[parent]
953
953
954 if parent && (parent_status == eNOT_DISCOVERED) && !except.include?(parent)
954 if parent && (parent_status == eNOT_DISCOVERED) && !except.include?(parent)
955 queue << parent
955 queue << parent
956 issue_status[parent] = ePROCESS_RELATIONS_ONLY
956 issue_status[parent] = ePROCESS_RELATIONS_ONLY
957 end
957 end
958
958
959 # Add children to queue, but only if they are not already in it and
959 # Add children to queue, but only if they are not already in it and
960 # the children of the current node need to be processed.
960 # the children of the current node need to be processed.
961 if (current_issue_status == ePROCESS_CHILDREN_ONLY || current_issue_status == ePROCESS_ALL)
961 if (current_issue_status == ePROCESS_CHILDREN_ONLY || current_issue_status == ePROCESS_ALL)
962 current_issue.children.each do |child|
962 current_issue.children.each do |child|
963 next if except.include?(child)
963 next if except.include?(child)
964
964
965 if (issue_status[child] == eNOT_DISCOVERED)
965 if (issue_status[child] == eNOT_DISCOVERED)
966 queue << child
966 queue << child
967 issue_status[child] = ePROCESS_ALL
967 issue_status[child] = ePROCESS_ALL
968 elsif (issue_status[child] == eRELATIONS_PROCESSED)
968 elsif (issue_status[child] == eRELATIONS_PROCESSED)
969 queue << child
969 queue << child
970 issue_status[child] = ePROCESS_CHILDREN_ONLY
970 issue_status[child] = ePROCESS_CHILDREN_ONLY
971 elsif (issue_status[child] == ePROCESS_RELATIONS_ONLY)
971 elsif (issue_status[child] == ePROCESS_RELATIONS_ONLY)
972 queue << child
972 queue << child
973 issue_status[child] = ePROCESS_ALL
973 issue_status[child] = ePROCESS_ALL
974 end
974 end
975 end
975 end
976 end
976 end
977
977
978 # Add related issues to the queue, if they are not already in it.
978 # Add related issues to the queue, if they are not already in it.
979 current_issue.relations_from.map(&:issue_to).each do |related_issue|
979 current_issue.relations_from.map(&:issue_to).each do |related_issue|
980 next if except.include?(related_issue)
980 next if except.include?(related_issue)
981
981
982 if (issue_status[related_issue] == eNOT_DISCOVERED)
982 if (issue_status[related_issue] == eNOT_DISCOVERED)
983 queue << related_issue
983 queue << related_issue
984 issue_status[related_issue] = ePROCESS_ALL
984 issue_status[related_issue] = ePROCESS_ALL
985 elsif (issue_status[related_issue] == eRELATIONS_PROCESSED)
985 elsif (issue_status[related_issue] == eRELATIONS_PROCESSED)
986 queue << related_issue
986 queue << related_issue
987 issue_status[related_issue] = ePROCESS_CHILDREN_ONLY
987 issue_status[related_issue] = ePROCESS_CHILDREN_ONLY
988 elsif (issue_status[related_issue] == ePROCESS_RELATIONS_ONLY)
988 elsif (issue_status[related_issue] == ePROCESS_RELATIONS_ONLY)
989 queue << related_issue
989 queue << related_issue
990 issue_status[related_issue] = ePROCESS_ALL
990 issue_status[related_issue] = ePROCESS_ALL
991 end
991 end
992 end
992 end
993
993
994 # Set new status for current issue
994 # Set new status for current issue
995 if (current_issue_status == ePROCESS_ALL) || (current_issue_status == ePROCESS_CHILDREN_ONLY)
995 if (current_issue_status == ePROCESS_ALL) || (current_issue_status == ePROCESS_CHILDREN_ONLY)
996 issue_status[current_issue] = eALL_PROCESSED
996 issue_status[current_issue] = eALL_PROCESSED
997 elsif (current_issue_status == ePROCESS_RELATIONS_ONLY)
997 elsif (current_issue_status == ePROCESS_RELATIONS_ONLY)
998 issue_status[current_issue] = eRELATIONS_PROCESSED
998 issue_status[current_issue] = eRELATIONS_PROCESSED
999 end
999 end
1000 end # while
1000 end # while
1001
1001
1002 # Remove the issues from the "except" parameter from the result array
1002 # Remove the issues from the "except" parameter from the result array
1003 dependencies -= except
1003 dependencies -= except
1004 dependencies.delete(self)
1004 dependencies.delete(self)
1005
1005
1006 dependencies
1006 dependencies
1007 end
1007 end
1008
1008
1009 # Returns an array of issues that duplicate this one
1009 # Returns an array of issues that duplicate this one
1010 def duplicates
1010 def duplicates
1011 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
1011 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
1012 end
1012 end
1013
1013
1014 # Returns the due date or the target due date if any
1014 # Returns the due date or the target due date if any
1015 # Used on gantt chart
1015 # Used on gantt chart
1016 def due_before
1016 def due_before
1017 due_date || (fixed_version ? fixed_version.effective_date : nil)
1017 due_date || (fixed_version ? fixed_version.effective_date : nil)
1018 end
1018 end
1019
1019
1020 # Returns the time scheduled for this issue.
1020 # Returns the time scheduled for this issue.
1021 #
1021 #
1022 # Example:
1022 # Example:
1023 # Start Date: 2/26/09, End Date: 3/04/09
1023 # Start Date: 2/26/09, End Date: 3/04/09
1024 # duration => 6
1024 # duration => 6
1025 def duration
1025 def duration
1026 (start_date && due_date) ? due_date - start_date : 0
1026 (start_date && due_date) ? due_date - start_date : 0
1027 end
1027 end
1028
1028
1029 # Returns the duration in working days
1029 # Returns the duration in working days
1030 def working_duration
1030 def working_duration
1031 (start_date && due_date) ? working_days(start_date, due_date) : 0
1031 (start_date && due_date) ? working_days(start_date, due_date) : 0
1032 end
1032 end
1033
1033
1034 def soonest_start(reload=false)
1034 def soonest_start(reload=false)
1035 @soonest_start = nil if reload
1035 @soonest_start = nil if reload
1036 @soonest_start ||= (
1036 @soonest_start ||= (
1037 relations_to(reload).collect{|relation| relation.successor_soonest_start} +
1037 relations_to(reload).collect{|relation| relation.successor_soonest_start} +
1038 [(@parent_issue || parent).try(:soonest_start)]
1038 [(@parent_issue || parent).try(:soonest_start)]
1039 ).compact.max
1039 ).compact.max
1040 end
1040 end
1041
1041
1042 # Sets start_date on the given date or the next working day
1042 # Sets start_date on the given date or the next working day
1043 # and changes due_date to keep the same working duration.
1043 # and changes due_date to keep the same working duration.
1044 def reschedule_on(date)
1044 def reschedule_on(date)
1045 wd = working_duration
1045 wd = working_duration
1046 date = next_working_date(date)
1046 date = next_working_date(date)
1047 self.start_date = date
1047 self.start_date = date
1048 self.due_date = add_working_days(date, wd)
1048 self.due_date = add_working_days(date, wd)
1049 end
1049 end
1050
1050
1051 # Reschedules the issue on the given date or the next working day and saves the record.
1051 # Reschedules the issue on the given date or the next working day and saves the record.
1052 # If the issue is a parent task, this is done by rescheduling its subtasks.
1052 # If the issue is a parent task, this is done by rescheduling its subtasks.
1053 def reschedule_on!(date)
1053 def reschedule_on!(date)
1054 return if date.nil?
1054 return if date.nil?
1055 if leaf?
1055 if leaf?
1056 if start_date.nil? || start_date != date
1056 if start_date.nil? || start_date != date
1057 if start_date && start_date > date
1057 if start_date && start_date > date
1058 # Issue can not be moved earlier than its soonest start date
1058 # Issue can not be moved earlier than its soonest start date
1059 date = [soonest_start(true), date].compact.max
1059 date = [soonest_start(true), date].compact.max
1060 end
1060 end
1061 reschedule_on(date)
1061 reschedule_on(date)
1062 begin
1062 begin
1063 save
1063 save
1064 rescue ActiveRecord::StaleObjectError
1064 rescue ActiveRecord::StaleObjectError
1065 reload
1065 reload
1066 reschedule_on(date)
1066 reschedule_on(date)
1067 save
1067 save
1068 end
1068 end
1069 end
1069 end
1070 else
1070 else
1071 leaves.each do |leaf|
1071 leaves.each do |leaf|
1072 if leaf.start_date
1072 if leaf.start_date
1073 # Only move subtask if it starts at the same date as the parent
1073 # Only move subtask if it starts at the same date as the parent
1074 # or if it starts before the given date
1074 # or if it starts before the given date
1075 if start_date == leaf.start_date || date > leaf.start_date
1075 if start_date == leaf.start_date || date > leaf.start_date
1076 leaf.reschedule_on!(date)
1076 leaf.reschedule_on!(date)
1077 end
1077 end
1078 else
1078 else
1079 leaf.reschedule_on!(date)
1079 leaf.reschedule_on!(date)
1080 end
1080 end
1081 end
1081 end
1082 end
1082 end
1083 end
1083 end
1084
1084
1085 def <=>(issue)
1085 def <=>(issue)
1086 if issue.nil?
1086 if issue.nil?
1087 -1
1087 -1
1088 elsif root_id != issue.root_id
1088 elsif root_id != issue.root_id
1089 (root_id || 0) <=> (issue.root_id || 0)
1089 (root_id || 0) <=> (issue.root_id || 0)
1090 else
1090 else
1091 (lft || 0) <=> (issue.lft || 0)
1091 (lft || 0) <=> (issue.lft || 0)
1092 end
1092 end
1093 end
1093 end
1094
1094
1095 def to_s
1095 def to_s
1096 "#{tracker} ##{id}: #{subject}"
1096 "#{tracker} ##{id}: #{subject}"
1097 end
1097 end
1098
1098
1099 # Returns a string of css classes that apply to the issue
1099 # Returns a string of css classes that apply to the issue
1100 def css_classes(user=User.current)
1100 def css_classes(user=User.current)
1101 s = "issue tracker-#{tracker_id} status-#{status_id} #{priority.try(:css_classes)}"
1101 s = "issue tracker-#{tracker_id} status-#{status_id} #{priority.try(:css_classes)}"
1102 s << ' closed' if closed?
1102 s << ' closed' if closed?
1103 s << ' overdue' if overdue?
1103 s << ' overdue' if overdue?
1104 s << ' child' if child?
1104 s << ' child' if child?
1105 s << ' parent' unless leaf?
1105 s << ' parent' unless leaf?
1106 s << ' private' if is_private?
1106 s << ' private' if is_private?
1107 if user.logged?
1107 if user.logged?
1108 s << ' created-by-me' if author_id == user.id
1108 s << ' created-by-me' if author_id == user.id
1109 s << ' assigned-to-me' if assigned_to_id == user.id
1109 s << ' assigned-to-me' if assigned_to_id == user.id
1110 s << ' assigned-to-my-group' if user.groups.any? {|g| g.id == assigned_to_id}
1110 s << ' assigned-to-my-group' if user.groups.any? {|g| g.id == assigned_to_id}
1111 end
1111 end
1112 s
1112 s
1113 end
1113 end
1114
1114
1115 # Unassigns issues from +version+ if it's no longer shared with issue's project
1115 # Unassigns issues from +version+ if it's no longer shared with issue's project
1116 def self.update_versions_from_sharing_change(version)
1116 def self.update_versions_from_sharing_change(version)
1117 # Update issues assigned to the version
1117 # Update issues assigned to the version
1118 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
1118 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
1119 end
1119 end
1120
1120
1121 # Unassigns issues from versions that are no longer shared
1121 # Unassigns issues from versions that are no longer shared
1122 # after +project+ was moved
1122 # after +project+ was moved
1123 def self.update_versions_from_hierarchy_change(project)
1123 def self.update_versions_from_hierarchy_change(project)
1124 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
1124 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
1125 # Update issues of the moved projects and issues assigned to a version of a moved project
1125 # Update issues of the moved projects and issues assigned to a version of a moved project
1126 Issue.update_versions(
1126 Issue.update_versions(
1127 ["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)",
1127 ["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)",
1128 moved_project_ids, moved_project_ids]
1128 moved_project_ids, moved_project_ids]
1129 )
1129 )
1130 end
1130 end
1131
1131
1132 def parent_issue_id=(arg)
1132 def parent_issue_id=(arg)
1133 s = arg.to_s.strip.presence
1133 s = arg.to_s.strip.presence
1134 if s && (m = s.match(%r{\A#?(\d+)\z})) && (@parent_issue = Issue.find_by_id(m[1]))
1134 if s && (m = s.match(%r{\A#?(\d+)\z})) && (@parent_issue = Issue.find_by_id(m[1]))
1135 @parent_issue.id
1136 @invalid_parent_issue_id = nil
1135 @invalid_parent_issue_id = nil
1137 elsif s.blank?
1136 elsif s.blank?
1138 @parent_issue = nil
1137 @parent_issue = nil
1139 @invalid_parent_issue_id = nil
1138 @invalid_parent_issue_id = nil
1140 else
1139 else
1141 @parent_issue = nil
1140 @parent_issue = nil
1142 @invalid_parent_issue_id = arg
1141 @invalid_parent_issue_id = arg
1143 end
1142 end
1144 end
1143 end
1145
1144
1146 def parent_issue_id
1145 def parent_issue_id
1147 if @invalid_parent_issue_id
1146 if @invalid_parent_issue_id
1148 @invalid_parent_issue_id
1147 @invalid_parent_issue_id
1149 elsif instance_variable_defined? :@parent_issue
1148 elsif instance_variable_defined? :@parent_issue
1150 @parent_issue.nil? ? nil : @parent_issue.id
1149 @parent_issue.nil? ? nil : @parent_issue.id
1151 else
1150 else
1152 parent_id
1151 parent_id
1153 end
1152 end
1154 end
1153 end
1155
1154
1156 # Returns true if issue's project is a valid
1155 # Returns true if issue's project is a valid
1157 # parent issue project
1156 # parent issue project
1158 def valid_parent_project?(issue=parent)
1157 def valid_parent_project?(issue=parent)
1159 return true if issue.nil? || issue.project_id == project_id
1158 return true if issue.nil? || issue.project_id == project_id
1160
1159
1161 case Setting.cross_project_subtasks
1160 case Setting.cross_project_subtasks
1162 when 'system'
1161 when 'system'
1163 true
1162 true
1164 when 'tree'
1163 when 'tree'
1165 issue.project.root == project.root
1164 issue.project.root == project.root
1166 when 'hierarchy'
1165 when 'hierarchy'
1167 issue.project.is_or_is_ancestor_of?(project) || issue.project.is_descendant_of?(project)
1166 issue.project.is_or_is_ancestor_of?(project) || issue.project.is_descendant_of?(project)
1168 when 'descendants'
1167 when 'descendants'
1169 issue.project.is_or_is_ancestor_of?(project)
1168 issue.project.is_or_is_ancestor_of?(project)
1170 else
1169 else
1171 false
1170 false
1172 end
1171 end
1173 end
1172 end
1174
1173
1175 # Returns an issue scope based on project and scope
1174 # Returns an issue scope based on project and scope
1176 def self.cross_project_scope(project, scope=nil)
1175 def self.cross_project_scope(project, scope=nil)
1177 if project.nil?
1176 if project.nil?
1178 return Issue
1177 return Issue
1179 end
1178 end
1180 case scope
1179 case scope
1181 when 'all', 'system'
1180 when 'all', 'system'
1182 Issue
1181 Issue
1183 when 'tree'
1182 when 'tree'
1184 Issue.joins(:project).where("(#{Project.table_name}.lft >= :lft AND #{Project.table_name}.rgt <= :rgt)",
1183 Issue.joins(:project).where("(#{Project.table_name}.lft >= :lft AND #{Project.table_name}.rgt <= :rgt)",
1185 :lft => project.root.lft, :rgt => project.root.rgt)
1184 :lft => project.root.lft, :rgt => project.root.rgt)
1186 when 'hierarchy'
1185 when 'hierarchy'
1187 Issue.joins(:project).where("(#{Project.table_name}.lft >= :lft AND #{Project.table_name}.rgt <= :rgt) OR (#{Project.table_name}.lft < :lft AND #{Project.table_name}.rgt > :rgt)",
1186 Issue.joins(:project).where("(#{Project.table_name}.lft >= :lft AND #{Project.table_name}.rgt <= :rgt) OR (#{Project.table_name}.lft < :lft AND #{Project.table_name}.rgt > :rgt)",
1188 :lft => project.lft, :rgt => project.rgt)
1187 :lft => project.lft, :rgt => project.rgt)
1189 when 'descendants'
1188 when 'descendants'
1190 Issue.joins(:project).where("(#{Project.table_name}.lft >= :lft AND #{Project.table_name}.rgt <= :rgt)",
1189 Issue.joins(:project).where("(#{Project.table_name}.lft >= :lft AND #{Project.table_name}.rgt <= :rgt)",
1191 :lft => project.lft, :rgt => project.rgt)
1190 :lft => project.lft, :rgt => project.rgt)
1192 else
1191 else
1193 Issue.where(:project_id => project.id)
1192 Issue.where(:project_id => project.id)
1194 end
1193 end
1195 end
1194 end
1196
1195
1197 def self.by_tracker(project)
1196 def self.by_tracker(project)
1198 count_and_group_by(:project => project, :association => :tracker)
1197 count_and_group_by(:project => project, :association => :tracker)
1199 end
1198 end
1200
1199
1201 def self.by_version(project)
1200 def self.by_version(project)
1202 count_and_group_by(:project => project, :association => :fixed_version)
1201 count_and_group_by(:project => project, :association => :fixed_version)
1203 end
1202 end
1204
1203
1205 def self.by_priority(project)
1204 def self.by_priority(project)
1206 count_and_group_by(:project => project, :association => :priority)
1205 count_and_group_by(:project => project, :association => :priority)
1207 end
1206 end
1208
1207
1209 def self.by_category(project)
1208 def self.by_category(project)
1210 count_and_group_by(:project => project, :association => :category)
1209 count_and_group_by(:project => project, :association => :category)
1211 end
1210 end
1212
1211
1213 def self.by_assigned_to(project)
1212 def self.by_assigned_to(project)
1214 count_and_group_by(:project => project, :association => :assigned_to)
1213 count_and_group_by(:project => project, :association => :assigned_to)
1215 end
1214 end
1216
1215
1217 def self.by_author(project)
1216 def self.by_author(project)
1218 count_and_group_by(:project => project, :association => :author)
1217 count_and_group_by(:project => project, :association => :author)
1219 end
1218 end
1220
1219
1221 def self.by_subproject(project)
1220 def self.by_subproject(project)
1222 r = count_and_group_by(:project => project, :with_subprojects => true, :association => :project)
1221 r = count_and_group_by(:project => project, :with_subprojects => true, :association => :project)
1223 r.reject {|r| r["project_id"] == project.id.to_s}
1222 r.reject {|r| r["project_id"] == project.id.to_s}
1224 end
1223 end
1225
1224
1226 # Query generator for selecting groups of issue counts for a project
1225 # Query generator for selecting groups of issue counts for a project
1227 # based on specific criteria
1226 # based on specific criteria
1228 #
1227 #
1229 # Options
1228 # Options
1230 # * project - Project to search in.
1229 # * project - Project to search in.
1231 # * with_subprojects - Includes subprojects issues if set to true.
1230 # * with_subprojects - Includes subprojects issues if set to true.
1232 # * association - Symbol. Association for grouping.
1231 # * association - Symbol. Association for grouping.
1233 def self.count_and_group_by(options)
1232 def self.count_and_group_by(options)
1234 assoc = reflect_on_association(options[:association])
1233 assoc = reflect_on_association(options[:association])
1235 select_field = assoc.foreign_key
1234 select_field = assoc.foreign_key
1236
1235
1237 Issue.
1236 Issue.
1238 visible(User.current, :project => options[:project], :with_subprojects => options[:with_subprojects]).
1237 visible(User.current, :project => options[:project], :with_subprojects => options[:with_subprojects]).
1239 joins(:status, assoc.name).
1238 joins(:status, assoc.name).
1240 group(:status_id, :is_closed, select_field).
1239 group(:status_id, :is_closed, select_field).
1241 count.
1240 count.
1242 map do |columns, total|
1241 map do |columns, total|
1243 status_id, is_closed, field_value = columns
1242 status_id, is_closed, field_value = columns
1244 is_closed = ['t', 'true', '1'].include?(is_closed.to_s)
1243 is_closed = ['t', 'true', '1'].include?(is_closed.to_s)
1245 {
1244 {
1246 "status_id" => status_id.to_s,
1245 "status_id" => status_id.to_s,
1247 "closed" => is_closed,
1246 "closed" => is_closed,
1248 select_field => field_value.to_s,
1247 select_field => field_value.to_s,
1249 "total" => total.to_s
1248 "total" => total.to_s
1250 }
1249 }
1251 end
1250 end
1252 end
1251 end
1253
1252
1254 # Returns a scope of projects that user can assign the issue to
1253 # Returns a scope of projects that user can assign the issue to
1255 def allowed_target_projects(user=User.current)
1254 def allowed_target_projects(user=User.current)
1256 if new_record?
1255 if new_record?
1257 Project.where(Project.allowed_to_condition(user, :add_issues))
1256 Project.where(Project.allowed_to_condition(user, :add_issues))
1258 else
1257 else
1259 self.class.allowed_target_projects_on_move(user)
1258 self.class.allowed_target_projects_on_move(user)
1260 end
1259 end
1261 end
1260 end
1262
1261
1263 # Returns a scope of projects that user can move issues to
1262 # Returns a scope of projects that user can move issues to
1264 def self.allowed_target_projects_on_move(user=User.current)
1263 def self.allowed_target_projects_on_move(user=User.current)
1265 Project.where(Project.allowed_to_condition(user, :move_issues))
1264 Project.where(Project.allowed_to_condition(user, :move_issues))
1266 end
1265 end
1267
1266
1268 private
1267 private
1269
1268
1270 def after_project_change
1269 def after_project_change
1271 # Update project_id on related time entries
1270 # Update project_id on related time entries
1272 TimeEntry.where({:issue_id => id}).update_all(["project_id = ?", project_id])
1271 TimeEntry.where({:issue_id => id}).update_all(["project_id = ?", project_id])
1273
1272
1274 # Delete issue relations
1273 # Delete issue relations
1275 unless Setting.cross_project_issue_relations?
1274 unless Setting.cross_project_issue_relations?
1276 relations_from.clear
1275 relations_from.clear
1277 relations_to.clear
1276 relations_to.clear
1278 end
1277 end
1279
1278
1280 # Move subtasks that were in the same project
1279 # Move subtasks that were in the same project
1281 children.each do |child|
1280 children.each do |child|
1282 next unless child.project_id == project_id_was
1281 next unless child.project_id == project_id_was
1283 # Change project and keep project
1282 # Change project and keep project
1284 child.send :project=, project, true
1283 child.send :project=, project, true
1285 unless child.save
1284 unless child.save
1286 raise ActiveRecord::Rollback
1285 raise ActiveRecord::Rollback
1287 end
1286 end
1288 end
1287 end
1289 end
1288 end
1290
1289
1291 # Callback for after the creation of an issue by copy
1290 # Callback for after the creation of an issue by copy
1292 # * adds a "copied to" relation with the copied issue
1291 # * adds a "copied to" relation with the copied issue
1293 # * copies subtasks from the copied issue
1292 # * copies subtasks from the copied issue
1294 def after_create_from_copy
1293 def after_create_from_copy
1295 return unless copy? && !@after_create_from_copy_handled
1294 return unless copy? && !@after_create_from_copy_handled
1296
1295
1297 if (@copied_from.project_id == project_id || Setting.cross_project_issue_relations?) && @copy_options[:link] != false
1296 if (@copied_from.project_id == project_id || Setting.cross_project_issue_relations?) && @copy_options[:link] != false
1298 relation = IssueRelation.new(:issue_from => @copied_from, :issue_to => self, :relation_type => IssueRelation::TYPE_COPIED_TO)
1297 relation = IssueRelation.new(:issue_from => @copied_from, :issue_to => self, :relation_type => IssueRelation::TYPE_COPIED_TO)
1299 unless relation.save
1298 unless relation.save
1300 logger.error "Could not create relation while copying ##{@copied_from.id} to ##{id} due to validation errors: #{relation.errors.full_messages.join(', ')}" if logger
1299 logger.error "Could not create relation while copying ##{@copied_from.id} to ##{id} due to validation errors: #{relation.errors.full_messages.join(', ')}" if logger
1301 end
1300 end
1302 end
1301 end
1303
1302
1304 unless @copied_from.leaf? || @copy_options[:subtasks] == false
1303 unless @copied_from.leaf? || @copy_options[:subtasks] == false
1305 copy_options = (@copy_options || {}).merge(:subtasks => false)
1304 copy_options = (@copy_options || {}).merge(:subtasks => false)
1306 copied_issue_ids = {@copied_from.id => self.id}
1305 copied_issue_ids = {@copied_from.id => self.id}
1307 @copied_from.reload.descendants.reorder("#{Issue.table_name}.lft").each do |child|
1306 @copied_from.reload.descendants.reorder("#{Issue.table_name}.lft").each do |child|
1308 # Do not copy self when copying an issue as a descendant of the copied issue
1307 # Do not copy self when copying an issue as a descendant of the copied issue
1309 next if child == self
1308 next if child == self
1310 # Do not copy subtasks of issues that were not copied
1309 # Do not copy subtasks of issues that were not copied
1311 next unless copied_issue_ids[child.parent_id]
1310 next unless copied_issue_ids[child.parent_id]
1312 # Do not copy subtasks that are not visible to avoid potential disclosure of private data
1311 # Do not copy subtasks that are not visible to avoid potential disclosure of private data
1313 unless child.visible?
1312 unless child.visible?
1314 logger.error "Subtask ##{child.id} was not copied during ##{@copied_from.id} copy because it is not visible to the current user" if logger
1313 logger.error "Subtask ##{child.id} was not copied during ##{@copied_from.id} copy because it is not visible to the current user" if logger
1315 next
1314 next
1316 end
1315 end
1317 copy = Issue.new.copy_from(child, copy_options)
1316 copy = Issue.new.copy_from(child, copy_options)
1318 copy.author = author
1317 copy.author = author
1319 copy.project = project
1318 copy.project = project
1320 copy.parent_issue_id = copied_issue_ids[child.parent_id]
1319 copy.parent_issue_id = copied_issue_ids[child.parent_id]
1321 unless copy.save
1320 unless copy.save
1322 logger.error "Could not copy subtask ##{child.id} while copying ##{@copied_from.id} to ##{id} due to validation errors: #{copy.errors.full_messages.join(', ')}" if logger
1321 logger.error "Could not copy subtask ##{child.id} while copying ##{@copied_from.id} to ##{id} due to validation errors: #{copy.errors.full_messages.join(', ')}" if logger
1323 next
1322 next
1324 end
1323 end
1325 copied_issue_ids[child.id] = copy.id
1324 copied_issue_ids[child.id] = copy.id
1326 end
1325 end
1327 end
1326 end
1328 @after_create_from_copy_handled = true
1327 @after_create_from_copy_handled = true
1329 end
1328 end
1330
1329
1331 def update_nested_set_attributes
1330 def update_nested_set_attributes
1332 if root_id.nil?
1331 if root_id.nil?
1333 # issue was just created
1332 # issue was just created
1334 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
1333 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
1335 Issue.where(["id = ?", id]).update_all(["root_id = ?", root_id])
1334 Issue.where(["id = ?", id]).update_all(["root_id = ?", root_id])
1336 if @parent_issue
1335 if @parent_issue
1337 move_to_child_of(@parent_issue)
1336 move_to_child_of(@parent_issue)
1338 end
1337 end
1339 elsif parent_issue_id != parent_id
1338 elsif parent_issue_id != parent_id
1340 update_nested_set_attributes_on_parent_change
1339 update_nested_set_attributes_on_parent_change
1341 end
1340 end
1342 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
1341 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
1343 end
1342 end
1344
1343
1345 # Updates the nested set for when an existing issue is moved
1344 # Updates the nested set for when an existing issue is moved
1346 def update_nested_set_attributes_on_parent_change
1345 def update_nested_set_attributes_on_parent_change
1347 former_parent_id = parent_id
1346 former_parent_id = parent_id
1348 # moving an existing issue
1347 # moving an existing issue
1349 if @parent_issue && @parent_issue.root_id == root_id
1348 if @parent_issue && @parent_issue.root_id == root_id
1350 # inside the same tree
1349 # inside the same tree
1351 move_to_child_of(@parent_issue)
1350 move_to_child_of(@parent_issue)
1352 else
1351 else
1353 # to another tree
1352 # to another tree
1354 unless root?
1353 unless root?
1355 move_to_right_of(root)
1354 move_to_right_of(root)
1356 end
1355 end
1357 old_root_id = root_id
1356 old_root_id = root_id
1358 in_tenacious_transaction do
1357 in_tenacious_transaction do
1359 @parent_issue.reload_nested_set if @parent_issue
1358 @parent_issue.reload_nested_set if @parent_issue
1360 self.reload_nested_set
1359 self.reload_nested_set
1361 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
1360 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
1362 cond = ["root_id = ? AND lft >= ? AND rgt <= ? ", old_root_id, lft, rgt]
1361 cond = ["root_id = ? AND lft >= ? AND rgt <= ? ", old_root_id, lft, rgt]
1363 self.class.base_class.select('id').lock(true).where(cond)
1362 self.class.base_class.select('id').lock(true).where(cond)
1364 offset = rdm_right_most_bound + 1 - lft
1363 offset = rdm_right_most_bound + 1 - lft
1365 Issue.where(cond).
1364 Issue.where(cond).
1366 update_all(["root_id = ?, lft = lft + ?, rgt = rgt + ?", root_id, offset, offset])
1365 update_all(["root_id = ?, lft = lft + ?, rgt = rgt + ?", root_id, offset, offset])
1367 end
1366 end
1368 if @parent_issue
1367 if @parent_issue
1369 move_to_child_of(@parent_issue)
1368 move_to_child_of(@parent_issue)
1370 end
1369 end
1371 end
1370 end
1372 # delete invalid relations of all descendants
1371 # delete invalid relations of all descendants
1373 self_and_descendants.each do |issue|
1372 self_and_descendants.each do |issue|
1374 issue.relations.each do |relation|
1373 issue.relations.each do |relation|
1375 relation.destroy unless relation.valid?
1374 relation.destroy unless relation.valid?
1376 end
1375 end
1377 end
1376 end
1378 # update former parent
1377 # update former parent
1379 recalculate_attributes_for(former_parent_id) if former_parent_id
1378 recalculate_attributes_for(former_parent_id) if former_parent_id
1380 end
1379 end
1381
1380
1382 def rdm_right_most_bound
1381 def rdm_right_most_bound
1383 right_most_node =
1382 right_most_node =
1384 self.class.base_class.unscoped.
1383 self.class.base_class.unscoped.
1385 order("#{quoted_right_column_full_name} desc").limit(1).lock(true).first
1384 order("#{quoted_right_column_full_name} desc").limit(1).lock(true).first
1386 right_most_node ? (right_most_node[right_column_name] || 0 ) : 0
1385 right_most_node ? (right_most_node[right_column_name] || 0 ) : 0
1387 end
1386 end
1388 private :rdm_right_most_bound
1387 private :rdm_right_most_bound
1389
1388
1390 def update_parent_attributes
1389 def update_parent_attributes
1391 recalculate_attributes_for(parent_id) if parent_id
1390 recalculate_attributes_for(parent_id) if parent_id
1392 end
1391 end
1393
1392
1394 def recalculate_attributes_for(issue_id)
1393 def recalculate_attributes_for(issue_id)
1395 if issue_id && p = Issue.find_by_id(issue_id)
1394 if issue_id && p = Issue.find_by_id(issue_id)
1396 # priority = highest priority of children
1395 # priority = highest priority of children
1397 if priority_position = p.children.joins(:priority).maximum("#{IssuePriority.table_name}.position")
1396 if priority_position = p.children.joins(:priority).maximum("#{IssuePriority.table_name}.position")
1398 p.priority = IssuePriority.find_by_position(priority_position)
1397 p.priority = IssuePriority.find_by_position(priority_position)
1399 end
1398 end
1400
1399
1401 # start/due dates = lowest/highest dates of children
1400 # start/due dates = lowest/highest dates of children
1402 p.start_date = p.children.minimum(:start_date)
1401 p.start_date = p.children.minimum(:start_date)
1403 p.due_date = p.children.maximum(:due_date)
1402 p.due_date = p.children.maximum(:due_date)
1404 if p.start_date && p.due_date && p.due_date < p.start_date
1403 if p.start_date && p.due_date && p.due_date < p.start_date
1405 p.start_date, p.due_date = p.due_date, p.start_date
1404 p.start_date, p.due_date = p.due_date, p.start_date
1406 end
1405 end
1407
1406
1408 # done ratio = weighted average ratio of leaves
1407 # done ratio = weighted average ratio of leaves
1409 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
1408 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
1410 leaves_count = p.leaves.count
1409 leaves_count = p.leaves.count
1411 if leaves_count > 0
1410 if leaves_count > 0
1412 average = p.leaves.where("estimated_hours > 0").average(:estimated_hours).to_f
1411 average = p.leaves.where("estimated_hours > 0").average(:estimated_hours).to_f
1413 if average == 0
1412 if average == 0
1414 average = 1
1413 average = 1
1415 end
1414 end
1416 done = p.leaves.joins(:status).
1415 done = p.leaves.joins(:status).
1417 sum("COALESCE(CASE WHEN estimated_hours > 0 THEN estimated_hours ELSE NULL END, #{average}) " +
1416 sum("COALESCE(CASE WHEN estimated_hours > 0 THEN estimated_hours ELSE NULL END, #{average}) " +
1418 "* (CASE WHEN is_closed = #{self.class.connection.quoted_true} THEN 100 ELSE COALESCE(done_ratio, 0) END)").to_f
1417 "* (CASE WHEN is_closed = #{self.class.connection.quoted_true} THEN 100 ELSE COALESCE(done_ratio, 0) END)").to_f
1419 progress = done / (average * leaves_count)
1418 progress = done / (average * leaves_count)
1420 p.done_ratio = progress.round
1419 p.done_ratio = progress.round
1421 end
1420 end
1422 end
1421 end
1423
1422
1424 # estimate = sum of leaves estimates
1423 # estimate = sum of leaves estimates
1425 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
1424 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
1426 p.estimated_hours = nil if p.estimated_hours == 0.0
1425 p.estimated_hours = nil if p.estimated_hours == 0.0
1427
1426
1428 # ancestors will be recursively updated
1427 # ancestors will be recursively updated
1429 p.save(:validate => false)
1428 p.save(:validate => false)
1430 end
1429 end
1431 end
1430 end
1432
1431
1433 # Update issues so their versions are not pointing to a
1432 # Update issues so their versions are not pointing to a
1434 # fixed_version that is not shared with the issue's project
1433 # fixed_version that is not shared with the issue's project
1435 def self.update_versions(conditions=nil)
1434 def self.update_versions(conditions=nil)
1436 # Only need to update issues with a fixed_version from
1435 # Only need to update issues with a fixed_version from
1437 # a different project and that is not systemwide shared
1436 # a different project and that is not systemwide shared
1438 Issue.joins(:project, :fixed_version).
1437 Issue.joins(:project, :fixed_version).
1439 where("#{Issue.table_name}.fixed_version_id IS NOT NULL" +
1438 where("#{Issue.table_name}.fixed_version_id IS NOT NULL" +
1440 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
1439 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
1441 " AND #{Version.table_name}.sharing <> 'system'").
1440 " AND #{Version.table_name}.sharing <> 'system'").
1442 where(conditions).each do |issue|
1441 where(conditions).each do |issue|
1443 next if issue.project.nil? || issue.fixed_version.nil?
1442 next if issue.project.nil? || issue.fixed_version.nil?
1444 unless issue.project.shared_versions.include?(issue.fixed_version)
1443 unless issue.project.shared_versions.include?(issue.fixed_version)
1445 issue.init_journal(User.current)
1444 issue.init_journal(User.current)
1446 issue.fixed_version = nil
1445 issue.fixed_version = nil
1447 issue.save
1446 issue.save
1448 end
1447 end
1449 end
1448 end
1450 end
1449 end
1451
1450
1452 # Callback on file attachment
1451 # Callback on file attachment
1453 def attachment_added(obj)
1452 def attachment_added(obj)
1454 if @current_journal && !obj.new_record?
1453 if @current_journal && !obj.new_record?
1455 @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => obj.id, :value => obj.filename)
1454 @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => obj.id, :value => obj.filename)
1456 end
1455 end
1457 end
1456 end
1458
1457
1459 # Callback on attachment deletion
1458 # Callback on attachment deletion
1460 def attachment_removed(obj)
1459 def attachment_removed(obj)
1461 if @current_journal && !obj.new_record?
1460 if @current_journal && !obj.new_record?
1462 @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => obj.id, :old_value => obj.filename)
1461 @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => obj.id, :old_value => obj.filename)
1463 @current_journal.save
1462 @current_journal.save
1464 end
1463 end
1465 end
1464 end
1466
1465
1467 # Default assignment based on category
1466 # Default assignment based on category
1468 def default_assign
1467 def default_assign
1469 if assigned_to.nil? && category && category.assigned_to
1468 if assigned_to.nil? && category && category.assigned_to
1470 self.assigned_to = category.assigned_to
1469 self.assigned_to = category.assigned_to
1471 end
1470 end
1472 end
1471 end
1473
1472
1474 # Updates start/due dates of following issues
1473 # Updates start/due dates of following issues
1475 def reschedule_following_issues
1474 def reschedule_following_issues
1476 if start_date_changed? || due_date_changed?
1475 if start_date_changed? || due_date_changed?
1477 relations_from.each do |relation|
1476 relations_from.each do |relation|
1478 relation.set_issue_to_dates
1477 relation.set_issue_to_dates
1479 end
1478 end
1480 end
1479 end
1481 end
1480 end
1482
1481
1483 # Closes duplicates if the issue is being closed
1482 # Closes duplicates if the issue is being closed
1484 def close_duplicates
1483 def close_duplicates
1485 if closing?
1484 if closing?
1486 duplicates.each do |duplicate|
1485 duplicates.each do |duplicate|
1487 # Reload is needed in case the duplicate was updated by a previous duplicate
1486 # Reload is needed in case the duplicate was updated by a previous duplicate
1488 duplicate.reload
1487 duplicate.reload
1489 # Don't re-close it if it's already closed
1488 # Don't re-close it if it's already closed
1490 next if duplicate.closed?
1489 next if duplicate.closed?
1491 # Same user and notes
1490 # Same user and notes
1492 if @current_journal
1491 if @current_journal
1493 duplicate.init_journal(@current_journal.user, @current_journal.notes)
1492 duplicate.init_journal(@current_journal.user, @current_journal.notes)
1494 end
1493 end
1495 duplicate.update_attribute :status, self.status
1494 duplicate.update_attribute :status, self.status
1496 end
1495 end
1497 end
1496 end
1498 end
1497 end
1499
1498
1500 # Make sure updated_on is updated when adding a note and set updated_on now
1499 # Make sure updated_on is updated when adding a note and set updated_on now
1501 # so we can set closed_on with the same value on closing
1500 # so we can set closed_on with the same value on closing
1502 def force_updated_on_change
1501 def force_updated_on_change
1503 if @current_journal || changed?
1502 if @current_journal || changed?
1504 self.updated_on = current_time_from_proper_timezone
1503 self.updated_on = current_time_from_proper_timezone
1505 if new_record?
1504 if new_record?
1506 self.created_on = updated_on
1505 self.created_on = updated_on
1507 end
1506 end
1508 end
1507 end
1509 end
1508 end
1510
1509
1511 # Callback for setting closed_on when the issue is closed.
1510 # Callback for setting closed_on when the issue is closed.
1512 # The closed_on attribute stores the time of the last closing
1511 # The closed_on attribute stores the time of the last closing
1513 # and is preserved when the issue is reopened.
1512 # and is preserved when the issue is reopened.
1514 def update_closed_on
1513 def update_closed_on
1515 if closing? || (new_record? && closed?)
1514 if closing? || (new_record? && closed?)
1516 self.closed_on = updated_on
1515 self.closed_on = updated_on
1517 end
1516 end
1518 end
1517 end
1519
1518
1520 # Saves the changes in a Journal
1519 # Saves the changes in a Journal
1521 # Called after_save
1520 # Called after_save
1522 def create_journal
1521 def create_journal
1523 if @current_journal
1522 if @current_journal
1524 # attributes changes
1523 # attributes changes
1525 if @attributes_before_change
1524 if @attributes_before_change
1526 (Issue.column_names - %w(id root_id lft rgt lock_version created_on updated_on closed_on)).each {|c|
1525 (Issue.column_names - %w(id root_id lft rgt lock_version created_on updated_on closed_on)).each {|c|
1527 before = @attributes_before_change[c]
1526 before = @attributes_before_change[c]
1528 after = send(c)
1527 after = send(c)
1529 next if before == after || (before.blank? && after.blank?)
1528 next if before == after || (before.blank? && after.blank?)
1530 @current_journal.details << JournalDetail.new(:property => 'attr',
1529 @current_journal.details << JournalDetail.new(:property => 'attr',
1531 :prop_key => c,
1530 :prop_key => c,
1532 :old_value => before,
1531 :old_value => before,
1533 :value => after)
1532 :value => after)
1534 }
1533 }
1535 end
1534 end
1536 if @custom_values_before_change
1535 if @custom_values_before_change
1537 # custom fields changes
1536 # custom fields changes
1538 custom_field_values.each {|c|
1537 custom_field_values.each {|c|
1539 before = @custom_values_before_change[c.custom_field_id]
1538 before = @custom_values_before_change[c.custom_field_id]
1540 after = c.value
1539 after = c.value
1541 next if before == after || (before.blank? && after.blank?)
1540 next if before == after || (before.blank? && after.blank?)
1542
1541
1543 if before.is_a?(Array) || after.is_a?(Array)
1542 if before.is_a?(Array) || after.is_a?(Array)
1544 before = [before] unless before.is_a?(Array)
1543 before = [before] unless before.is_a?(Array)
1545 after = [after] unless after.is_a?(Array)
1544 after = [after] unless after.is_a?(Array)
1546
1545
1547 # values removed
1546 # values removed
1548 (before - after).reject(&:blank?).each do |value|
1547 (before - after).reject(&:blank?).each do |value|
1549 @current_journal.details << JournalDetail.new(:property => 'cf',
1548 @current_journal.details << JournalDetail.new(:property => 'cf',
1550 :prop_key => c.custom_field_id,
1549 :prop_key => c.custom_field_id,
1551 :old_value => value,
1550 :old_value => value,
1552 :value => nil)
1551 :value => nil)
1553 end
1552 end
1554 # values added
1553 # values added
1555 (after - before).reject(&:blank?).each do |value|
1554 (after - before).reject(&:blank?).each do |value|
1556 @current_journal.details << JournalDetail.new(:property => 'cf',
1555 @current_journal.details << JournalDetail.new(:property => 'cf',
1557 :prop_key => c.custom_field_id,
1556 :prop_key => c.custom_field_id,
1558 :old_value => nil,
1557 :old_value => nil,
1559 :value => value)
1558 :value => value)
1560 end
1559 end
1561 else
1560 else
1562 @current_journal.details << JournalDetail.new(:property => 'cf',
1561 @current_journal.details << JournalDetail.new(:property => 'cf',
1563 :prop_key => c.custom_field_id,
1562 :prop_key => c.custom_field_id,
1564 :old_value => before,
1563 :old_value => before,
1565 :value => after)
1564 :value => after)
1566 end
1565 end
1567 }
1566 }
1568 end
1567 end
1569 @current_journal.save
1568 @current_journal.save
1570 # reset current journal
1569 # reset current journal
1571 init_journal @current_journal.user, @current_journal.notes
1570 init_journal @current_journal.user, @current_journal.notes
1572 end
1571 end
1573 end
1572 end
1574
1573
1575 def send_notification
1574 def send_notification
1576 if Setting.notified_events.include?('issue_added')
1575 if Setting.notified_events.include?('issue_added')
1577 Mailer.deliver_issue_add(self)
1576 Mailer.deliver_issue_add(self)
1578 end
1577 end
1579 end
1578 end
1580
1579
1581 # Stores the previous assignee so we can still have access
1580 # Stores the previous assignee so we can still have access
1582 # to it during after_save callbacks (assigned_to_id_was is reset)
1581 # to it during after_save callbacks (assigned_to_id_was is reset)
1583 def set_assigned_to_was
1582 def set_assigned_to_was
1584 @previous_assigned_to_id = assigned_to_id_was
1583 @previous_assigned_to_id = assigned_to_id_was
1585 end
1584 end
1586
1585
1587 # Clears the previous assignee at the end of after_save callbacks
1586 # Clears the previous assignee at the end of after_save callbacks
1588 def clear_assigned_to_was
1587 def clear_assigned_to_was
1589 @assigned_to_was = nil
1588 @assigned_to_was = nil
1590 @previous_assigned_to_id = nil
1589 @previous_assigned_to_id = nil
1591 end
1590 end
1592 end
1591 end
@@ -1,1067 +1,1068
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2014 Jean-Philippe Lang
2 # Copyright (C) 2006-2014 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 overridden Activities
29 # Specific overridden Activities
30 has_many :time_entry_activities
30 has_many :time_entry_activities
31 has_many :members,
31 has_many :members,
32 lambda { joins(:principal, :roles).
32 lambda { joins(:principal, :roles).
33 where("#{Principal.table_name}.type='User' AND #{Principal.table_name}.status=#{Principal::STATUS_ACTIVE}") }
33 where("#{Principal.table_name}.type='User' AND #{Principal.table_name}.status=#{Principal::STATUS_ACTIVE}") }
34 has_many :memberships, :class_name => 'Member'
34 has_many :memberships, :class_name => 'Member'
35 has_many :member_principals,
35 has_many :member_principals,
36 lambda { joins(:principal).
36 lambda { joins(:principal).
37 where("#{Principal.table_name}.status=#{Principal::STATUS_ACTIVE}")},
37 where("#{Principal.table_name}.status=#{Principal::STATUS_ACTIVE}")},
38 :class_name => 'Member'
38 :class_name => 'Member'
39 has_many :enabled_modules, :dependent => :delete_all
39 has_many :enabled_modules, :dependent => :delete_all
40 has_and_belongs_to_many :trackers, lambda {order("#{Tracker.table_name}.position")}
40 has_and_belongs_to_many :trackers, lambda {order("#{Tracker.table_name}.position")}
41 has_many :issues, :dependent => :destroy
41 has_many :issues, :dependent => :destroy
42 has_many :issue_changes, :through => :issues, :source => :journals
42 has_many :issue_changes, :through => :issues, :source => :journals
43 has_many :versions, lambda {order("#{Version.table_name}.effective_date DESC, #{Version.table_name}.name DESC")}, :dependent => :destroy
43 has_many :versions, lambda {order("#{Version.table_name}.effective_date DESC, #{Version.table_name}.name DESC")}, :dependent => :destroy
44 has_many :time_entries, :dependent => :destroy
44 has_many :time_entries, :dependent => :destroy
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, lambda {includes(:author)}, :dependent => :destroy
47 has_many :news, lambda {includes(:author)}, :dependent => :destroy
48 has_many :issue_categories, lambda {order("#{IssueCategory.table_name}.name")}, :dependent => :delete_all
48 has_many :issue_categories, lambda {order("#{IssueCategory.table_name}.name")}, :dependent => :delete_all
49 has_many :boards, lambda {order("position ASC")}, :dependent => :destroy
49 has_many :boards, lambda {order("position ASC")}, :dependent => :destroy
50 has_one :repository, lambda {where(["is_default = ?", true])}
50 has_one :repository, lambda {where(["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 lambda {order("#{CustomField.table_name}.position")},
56 lambda {order("#{CustomField.table_name}.position")},
57 :class_name => 'IssueCustomField',
57 :class_name => 'IssueCustomField',
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 :dependent => :destroy
61 acts_as_nested_set :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 # downcase letters, digits, dashes but not digits only
79 # downcase 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 after_save :update_inherited_members, :if => Proc.new {|project| project.inherit_members_changed?}
85 after_save :update_inherited_members, :if => Proc.new {|project| project.inherit_members_changed?}
86 before_destroy :delete_all_members
86 before_destroy :delete_all_members
87
87
88 scope :has_module, lambda {|mod|
88 scope :has_module, lambda {|mod|
89 where("#{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name=?)", mod.to_s)
89 where("#{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name=?)", mod.to_s)
90 }
90 }
91 scope :active, lambda { where(:status => STATUS_ACTIVE) }
91 scope :active, lambda { where(:status => STATUS_ACTIVE) }
92 scope :status, lambda {|arg| where(arg.blank? ? nil : {:status => arg.to_i}) }
92 scope :status, lambda {|arg| where(arg.blank? ? nil : {:status => arg.to_i}) }
93 scope :all_public, lambda { where(:is_public => true) }
93 scope :all_public, lambda { where(:is_public => true) }
94 scope :visible, lambda {|*args| where(Project.visible_condition(args.shift || User.current, *args)) }
94 scope :visible, lambda {|*args| where(Project.visible_condition(args.shift || User.current, *args)) }
95 scope :allowed_to, lambda {|*args|
95 scope :allowed_to, lambda {|*args|
96 user = User.current
96 user = User.current
97 permission = nil
97 permission = nil
98 if args.first.is_a?(Symbol)
98 if args.first.is_a?(Symbol)
99 permission = args.shift
99 permission = args.shift
100 else
100 else
101 user = args.shift
101 user = args.shift
102 permission = args.shift
102 permission = args.shift
103 end
103 end
104 where(Project.allowed_to_condition(user, permission, *args))
104 where(Project.allowed_to_condition(user, permission, *args))
105 }
105 }
106 scope :like, lambda {|arg|
106 scope :like, lambda {|arg|
107 if arg.blank?
107 if arg.blank?
108 where(nil)
108 where(nil)
109 else
109 else
110 pattern = "%#{arg.to_s.strip.downcase}%"
110 pattern = "%#{arg.to_s.strip.downcase}%"
111 where("LOWER(identifier) LIKE :p OR LOWER(name) LIKE :p", :p => pattern)
111 where("LOWER(identifier) LIKE :p OR LOWER(name) LIKE :p", :p => pattern)
112 end
112 end
113 }
113 }
114
114
115 def initialize(attributes=nil, *args)
115 def initialize(attributes=nil, *args)
116 super
116 super
117
117
118 initialized = (attributes || {}).stringify_keys
118 initialized = (attributes || {}).stringify_keys
119 if !initialized.key?('identifier') && Setting.sequential_project_identifiers?
119 if !initialized.key?('identifier') && Setting.sequential_project_identifiers?
120 self.identifier = Project.next_identifier
120 self.identifier = Project.next_identifier
121 end
121 end
122 if !initialized.key?('is_public')
122 if !initialized.key?('is_public')
123 self.is_public = Setting.default_projects_public?
123 self.is_public = Setting.default_projects_public?
124 end
124 end
125 if !initialized.key?('enabled_module_names')
125 if !initialized.key?('enabled_module_names')
126 self.enabled_module_names = Setting.default_projects_modules
126 self.enabled_module_names = Setting.default_projects_modules
127 end
127 end
128 if !initialized.key?('trackers') && !initialized.key?('tracker_ids')
128 if !initialized.key?('trackers') && !initialized.key?('tracker_ids')
129 default = Setting.default_projects_tracker_ids
129 default = Setting.default_projects_tracker_ids
130 if default.is_a?(Array)
130 if default.is_a?(Array)
131 self.trackers = Tracker.where(:id => default.map(&:to_i)).sorted.to_a
131 self.trackers = Tracker.where(:id => default.map(&:to_i)).sorted.to_a
132 else
132 else
133 self.trackers = Tracker.sorted.to_a
133 self.trackers = Tracker.sorted.to_a
134 end
134 end
135 end
135 end
136 end
136 end
137
137
138 def identifier=(identifier)
138 def identifier=(identifier)
139 super unless identifier_frozen?
139 super unless identifier_frozen?
140 end
140 end
141
141
142 def identifier_frozen?
142 def identifier_frozen?
143 errors[:identifier].blank? && !(new_record? || identifier.blank?)
143 errors[:identifier].blank? && !(new_record? || identifier.blank?)
144 end
144 end
145
145
146 # returns latest created projects
146 # returns latest created projects
147 # non public projects will be returned only if user is a member of those
147 # non public projects will be returned only if user is a member of those
148 def self.latest(user=nil, count=5)
148 def self.latest(user=nil, count=5)
149 visible(user).limit(count).order("created_on DESC").to_a
149 visible(user).limit(count).order("created_on DESC").to_a
150 end
150 end
151
151
152 # Returns true if the project is visible to +user+ or to the current user.
152 # Returns true if the project is visible to +user+ or to the current user.
153 def visible?(user=User.current)
153 def visible?(user=User.current)
154 user.allowed_to?(:view_project, self)
154 user.allowed_to?(:view_project, self)
155 end
155 end
156
156
157 # Returns a SQL conditions string used to find all projects visible by the specified user.
157 # Returns a SQL conditions string used to find all projects visible by the specified user.
158 #
158 #
159 # Examples:
159 # Examples:
160 # Project.visible_condition(admin) => "projects.status = 1"
160 # Project.visible_condition(admin) => "projects.status = 1"
161 # Project.visible_condition(normal_user) => "((projects.status = 1) AND (projects.is_public = 1 OR projects.id IN (1,3,4)))"
161 # Project.visible_condition(normal_user) => "((projects.status = 1) AND (projects.is_public = 1 OR projects.id IN (1,3,4)))"
162 # Project.visible_condition(anonymous) => "((projects.status = 1) AND (projects.is_public = 1))"
162 # Project.visible_condition(anonymous) => "((projects.status = 1) AND (projects.is_public = 1))"
163 def self.visible_condition(user, options={})
163 def self.visible_condition(user, options={})
164 allowed_to_condition(user, :view_project, options)
164 allowed_to_condition(user, :view_project, options)
165 end
165 end
166
166
167 # Returns a SQL conditions string used to find all projects for which +user+ has the given +permission+
167 # Returns a SQL conditions string used to find all projects for which +user+ has the given +permission+
168 #
168 #
169 # Valid options:
169 # Valid options:
170 # * :project => limit the condition to project
170 # * :project => limit the condition to project
171 # * :with_subprojects => limit the condition to project and its subprojects
171 # * :with_subprojects => limit the condition to project and its subprojects
172 # * :member => limit the condition to the user projects
172 # * :member => limit the condition to the user projects
173 def self.allowed_to_condition(user, permission, options={})
173 def self.allowed_to_condition(user, permission, options={})
174 perm = Redmine::AccessControl.permission(permission)
174 perm = Redmine::AccessControl.permission(permission)
175 base_statement = (perm && perm.read? ? "#{Project.table_name}.status <> #{Project::STATUS_ARCHIVED}" : "#{Project.table_name}.status = #{Project::STATUS_ACTIVE}")
175 base_statement = (perm && perm.read? ? "#{Project.table_name}.status <> #{Project::STATUS_ARCHIVED}" : "#{Project.table_name}.status = #{Project::STATUS_ACTIVE}")
176 if perm && perm.project_module
176 if perm && perm.project_module
177 # If the permission belongs to a project module, make sure the module is enabled
177 # If the permission belongs to a project module, make sure the module is enabled
178 base_statement << " AND #{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name='#{perm.project_module}')"
178 base_statement << " AND #{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name='#{perm.project_module}')"
179 end
179 end
180 if project = options[:project]
180 if project = options[:project]
181 project_statement = project.project_condition(options[:with_subprojects])
181 project_statement = project.project_condition(options[:with_subprojects])
182 base_statement = "(#{project_statement}) AND (#{base_statement})"
182 base_statement = "(#{project_statement}) AND (#{base_statement})"
183 end
183 end
184
184
185 if user.admin?
185 if user.admin?
186 base_statement
186 base_statement
187 else
187 else
188 statement_by_role = {}
188 statement_by_role = {}
189 unless options[:member]
189 unless options[:member]
190 role = user.builtin_role
190 role = user.builtin_role
191 if role.allowed_to?(permission)
191 if role.allowed_to?(permission)
192 statement_by_role[role] = "#{Project.table_name}.is_public = #{connection.quoted_true}"
192 statement_by_role[role] = "#{Project.table_name}.is_public = #{connection.quoted_true}"
193 end
193 end
194 end
194 end
195 user.projects_by_role.each do |role, projects|
195 user.projects_by_role.each do |role, projects|
196 if role.allowed_to?(permission) && projects.any?
196 if role.allowed_to?(permission) && projects.any?
197 statement_by_role[role] = "#{Project.table_name}.id IN (#{projects.collect(&:id).join(',')})"
197 statement_by_role[role] = "#{Project.table_name}.id IN (#{projects.collect(&:id).join(',')})"
198 end
198 end
199 end
199 end
200 if statement_by_role.empty?
200 if statement_by_role.empty?
201 "1=0"
201 "1=0"
202 else
202 else
203 if block_given?
203 if block_given?
204 statement_by_role.each do |role, statement|
204 statement_by_role.each do |role, statement|
205 if s = yield(role, user)
205 if s = yield(role, user)
206 statement_by_role[role] = "(#{statement} AND (#{s}))"
206 statement_by_role[role] = "(#{statement} AND (#{s}))"
207 end
207 end
208 end
208 end
209 end
209 end
210 "((#{base_statement}) AND (#{statement_by_role.values.join(' OR ')}))"
210 "((#{base_statement}) AND (#{statement_by_role.values.join(' OR ')}))"
211 end
211 end
212 end
212 end
213 end
213 end
214
214
215 def override_roles(role)
215 def override_roles(role)
216 group_class = role.anonymous? ? GroupAnonymous : GroupNonMember
216 group_class = role.anonymous? ? GroupAnonymous : GroupNonMember
217 member = member_principals.where("#{Principal.table_name}.type = ?", group_class.name).first
217 member = member_principals.where("#{Principal.table_name}.type = ?", group_class.name).first
218 member ? member.roles.to_a : [role]
218 member ? member.roles.to_a : [role]
219 end
219 end
220
220
221 def principals
221 def principals
222 @principals ||= Principal.active.joins(:members).where("#{Member.table_name}.project_id = ?", id).uniq
222 @principals ||= Principal.active.joins(:members).where("#{Member.table_name}.project_id = ?", id).uniq
223 end
223 end
224
224
225 def users
225 def users
226 @users ||= User.active.joins(:members).where("#{Member.table_name}.project_id = ?", id).uniq
226 @users ||= User.active.joins(:members).where("#{Member.table_name}.project_id = ?", id).uniq
227 end
227 end
228
228
229 # Returns the Systemwide and project specific activities
229 # Returns the Systemwide and project specific activities
230 def activities(include_inactive=false)
230 def activities(include_inactive=false)
231 if include_inactive
231 if include_inactive
232 return all_activities
232 return all_activities
233 else
233 else
234 return active_activities
234 return active_activities
235 end
235 end
236 end
236 end
237
237
238 # Will create a new Project specific Activity or update an existing one
238 # Will create a new Project specific Activity or update an existing one
239 #
239 #
240 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
240 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
241 # does not successfully save.
241 # does not successfully save.
242 def update_or_create_time_entry_activity(id, activity_hash)
242 def update_or_create_time_entry_activity(id, activity_hash)
243 if activity_hash.respond_to?(:has_key?) && activity_hash.has_key?('parent_id')
243 if activity_hash.respond_to?(:has_key?) && activity_hash.has_key?('parent_id')
244 self.create_time_entry_activity_if_needed(activity_hash)
244 self.create_time_entry_activity_if_needed(activity_hash)
245 else
245 else
246 activity = project.time_entry_activities.find_by_id(id.to_i)
246 activity = project.time_entry_activities.find_by_id(id.to_i)
247 activity.update_attributes(activity_hash) if activity
247 activity.update_attributes(activity_hash) if activity
248 end
248 end
249 end
249 end
250
250
251 # Create a new TimeEntryActivity if it overrides a system TimeEntryActivity
251 # Create a new TimeEntryActivity if it overrides a system TimeEntryActivity
252 #
252 #
253 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
253 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
254 # does not successfully save.
254 # does not successfully save.
255 def create_time_entry_activity_if_needed(activity)
255 def create_time_entry_activity_if_needed(activity)
256 if activity['parent_id']
256 if activity['parent_id']
257 parent_activity = TimeEntryActivity.find(activity['parent_id'])
257 parent_activity = TimeEntryActivity.find(activity['parent_id'])
258 activity['name'] = parent_activity.name
258 activity['name'] = parent_activity.name
259 activity['position'] = parent_activity.position
259 activity['position'] = parent_activity.position
260 if Enumeration.overriding_change?(activity, parent_activity)
260 if Enumeration.overriding_change?(activity, parent_activity)
261 project_activity = self.time_entry_activities.create(activity)
261 project_activity = self.time_entry_activities.create(activity)
262 if project_activity.new_record?
262 if project_activity.new_record?
263 raise ActiveRecord::Rollback, "Overriding TimeEntryActivity was not successfully saved"
263 raise ActiveRecord::Rollback, "Overriding TimeEntryActivity was not successfully saved"
264 else
264 else
265 self.time_entries.
265 self.time_entries.
266 where(["activity_id = ?", parent_activity.id]).
266 where(["activity_id = ?", parent_activity.id]).
267 update_all("activity_id = #{project_activity.id}")
267 update_all("activity_id = #{project_activity.id}")
268 end
268 end
269 end
269 end
270 end
270 end
271 end
271 end
272
272
273 # Returns a :conditions SQL string that can be used to find the issues associated with this project.
273 # Returns a :conditions SQL string that can be used to find the issues associated with this project.
274 #
274 #
275 # Examples:
275 # Examples:
276 # project.project_condition(true) => "(projects.id = 1 OR (projects.lft > 1 AND projects.rgt < 10))"
276 # project.project_condition(true) => "(projects.id = 1 OR (projects.lft > 1 AND projects.rgt < 10))"
277 # project.project_condition(false) => "projects.id = 1"
277 # project.project_condition(false) => "projects.id = 1"
278 def project_condition(with_subprojects)
278 def project_condition(with_subprojects)
279 cond = "#{Project.table_name}.id = #{id}"
279 cond = "#{Project.table_name}.id = #{id}"
280 cond = "(#{cond} OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt}))" if with_subprojects
280 cond = "(#{cond} OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt}))" if with_subprojects
281 cond
281 cond
282 end
282 end
283
283
284 def self.find(*args)
284 def self.find(*args)
285 if args.first && args.first.is_a?(String) && !args.first.match(/^\d*$/)
285 if args.first && args.first.is_a?(String) && !args.first.match(/^\d*$/)
286 project = find_by_identifier(*args)
286 project = find_by_identifier(*args)
287 raise ActiveRecord::RecordNotFound, "Couldn't find Project with identifier=#{args.first}" if project.nil?
287 raise ActiveRecord::RecordNotFound, "Couldn't find Project with identifier=#{args.first}" if project.nil?
288 project
288 project
289 else
289 else
290 super
290 super
291 end
291 end
292 end
292 end
293
293
294 def self.find_by_param(*args)
294 def self.find_by_param(*args)
295 self.find(*args)
295 self.find(*args)
296 end
296 end
297
297
298 alias :base_reload :reload
298 alias :base_reload :reload
299 def reload(*args)
299 def reload(*args)
300 @principals = nil
300 @principals = nil
301 @users = nil
301 @users = nil
302 @shared_versions = nil
302 @shared_versions = nil
303 @rolled_up_versions = nil
303 @rolled_up_versions = nil
304 @rolled_up_trackers = nil
304 @rolled_up_trackers = nil
305 @all_issue_custom_fields = nil
305 @all_issue_custom_fields = nil
306 @all_time_entry_custom_fields = nil
306 @all_time_entry_custom_fields = nil
307 @to_param = nil
307 @to_param = nil
308 @allowed_parents = nil
308 @allowed_parents = nil
309 @allowed_permissions = nil
309 @allowed_permissions = nil
310 @actions_allowed = nil
310 @actions_allowed = nil
311 @start_date = nil
311 @start_date = nil
312 @due_date = nil
312 @due_date = nil
313 @override_members = nil
313 @override_members = nil
314 base_reload(*args)
314 base_reload(*args)
315 end
315 end
316
316
317 def to_param
317 def to_param
318 # id is used for projects with a numeric identifier (compatibility)
318 # id is used for projects with a numeric identifier (compatibility)
319 @to_param ||= (identifier.to_s =~ %r{^\d*$} ? id.to_s : identifier)
319 @to_param ||= (identifier.to_s =~ %r{^\d*$} ? id.to_s : identifier)
320 end
320 end
321
321
322 def active?
322 def active?
323 self.status == STATUS_ACTIVE
323 self.status == STATUS_ACTIVE
324 end
324 end
325
325
326 def archived?
326 def archived?
327 self.status == STATUS_ARCHIVED
327 self.status == STATUS_ARCHIVED
328 end
328 end
329
329
330 # Archives the project and its descendants
330 # Archives the project and its descendants
331 def archive
331 def archive
332 # Check that there is no issue of a non descendant project that is assigned
332 # Check that there is no issue of a non descendant project that is assigned
333 # to one of the project or descendant versions
333 # to one of the project or descendant versions
334 v_ids = self_and_descendants.collect {|p| p.version_ids}.flatten
334 v_ids = self_and_descendants.collect {|p| p.version_ids}.flatten
335 if v_ids.any? &&
335 if v_ids.any? &&
336 Issue.
336 Issue.
337 includes(:project).
337 includes(:project).
338 where("#{Project.table_name}.lft < ? OR #{Project.table_name}.rgt > ?", lft, rgt).
338 where("#{Project.table_name}.lft < ? OR #{Project.table_name}.rgt > ?", lft, rgt).
339 where("#{Issue.table_name}.fixed_version_id IN (?)", v_ids).
339 where("#{Issue.table_name}.fixed_version_id IN (?)", v_ids).
340 exists?
340 exists?
341 return false
341 return false
342 end
342 end
343 Project.transaction do
343 Project.transaction do
344 archive!
344 archive!
345 end
345 end
346 true
346 true
347 end
347 end
348
348
349 # Unarchives the project
349 # Unarchives the project
350 # All its ancestors must be active
350 # All its ancestors must be active
351 def unarchive
351 def unarchive
352 return false if ancestors.detect {|a| !a.active?}
352 return false if ancestors.detect {|a| !a.active?}
353 update_attribute :status, STATUS_ACTIVE
353 update_attribute :status, STATUS_ACTIVE
354 end
354 end
355
355
356 def close
356 def close
357 self_and_descendants.status(STATUS_ACTIVE).update_all :status => STATUS_CLOSED
357 self_and_descendants.status(STATUS_ACTIVE).update_all :status => STATUS_CLOSED
358 end
358 end
359
359
360 def reopen
360 def reopen
361 self_and_descendants.status(STATUS_CLOSED).update_all :status => STATUS_ACTIVE
361 self_and_descendants.status(STATUS_CLOSED).update_all :status => STATUS_ACTIVE
362 end
362 end
363
363
364 # Returns an array of projects the project can be moved to
364 # Returns an array of projects the project can be moved to
365 # by the current user
365 # by the current user
366 def allowed_parents
366 def allowed_parents
367 return @allowed_parents if @allowed_parents
367 return @allowed_parents if @allowed_parents
368 @allowed_parents = Project.where(Project.allowed_to_condition(User.current, :add_subprojects)).to_a
368 @allowed_parents = Project.where(Project.allowed_to_condition(User.current, :add_subprojects)).to_a
369 @allowed_parents = @allowed_parents - self_and_descendants
369 @allowed_parents = @allowed_parents - self_and_descendants
370 if User.current.allowed_to?(:add_project, nil, :global => true) || (!new_record? && parent.nil?)
370 if User.current.allowed_to?(:add_project, nil, :global => true) || (!new_record? && parent.nil?)
371 @allowed_parents << nil
371 @allowed_parents << nil
372 end
372 end
373 unless parent.nil? || @allowed_parents.empty? || @allowed_parents.include?(parent)
373 unless parent.nil? || @allowed_parents.empty? || @allowed_parents.include?(parent)
374 @allowed_parents << parent
374 @allowed_parents << parent
375 end
375 end
376 @allowed_parents
376 @allowed_parents
377 end
377 end
378
378
379 # Sets the parent of the project with authorization check
379 # Sets the parent of the project with authorization check
380 def set_allowed_parent!(p)
380 def set_allowed_parent!(p)
381 unless p.nil? || p.is_a?(Project)
381 unless p.nil? || p.is_a?(Project)
382 if p.to_s.blank?
382 if p.to_s.blank?
383 p = nil
383 p = nil
384 else
384 else
385 p = Project.find_by_id(p)
385 p = Project.find_by_id(p)
386 return false unless p
386 return false unless p
387 end
387 end
388 end
388 end
389 if p.nil?
389 if p.nil?
390 if !new_record? && allowed_parents.empty?
390 if !new_record? && allowed_parents.empty?
391 return false
391 return false
392 end
392 end
393 elsif !allowed_parents.include?(p)
393 elsif !allowed_parents.include?(p)
394 return false
394 return false
395 end
395 end
396 set_parent!(p)
396 set_parent!(p)
397 end
397 end
398
398
399 # Sets the parent of the project
399 # Sets the parent of the project
400 # Argument can be either a Project, a String, a Fixnum or nil
400 # Argument can be either a Project, a String, a Fixnum or nil
401 def set_parent!(p)
401 def set_parent!(p)
402 unless p.nil? || p.is_a?(Project)
402 unless p.nil? || p.is_a?(Project)
403 if p.to_s.blank?
403 if p.to_s.blank?
404 p = nil
404 p = nil
405 else
405 else
406 p = Project.find_by_id(p)
406 p = Project.find_by_id(p)
407 return false unless p
407 return false unless p
408 end
408 end
409 end
409 end
410 if p == parent && !p.nil?
410 if p == parent && !p.nil?
411 # Nothing to do
411 # Nothing to do
412 true
412 true
413 elsif p.nil? || (p.active? && move_possible?(p))
413 elsif p.nil? || (p.active? && move_possible?(p))
414 set_or_update_position_under(p)
414 set_or_update_position_under(p)
415 Issue.update_versions_from_hierarchy_change(self)
415 Issue.update_versions_from_hierarchy_change(self)
416 true
416 true
417 else
417 else
418 # Can not move to the given target
418 # Can not move to the given target
419 false
419 false
420 end
420 end
421 end
421 end
422
422
423 # Recalculates all lft and rgt values based on project names
423 # Recalculates all lft and rgt values based on project names
424 # Unlike Project.rebuild!, these values are recalculated even if the tree "looks" valid
424 # Unlike Project.rebuild!, these values are recalculated even if the tree "looks" valid
425 # Used in BuildProjectsTree migration
425 # Used in BuildProjectsTree migration
426 def self.rebuild_tree!
426 def self.rebuild_tree!
427 transaction do
427 transaction do
428 update_all "lft = NULL, rgt = NULL"
428 update_all "lft = NULL, rgt = NULL"
429 rebuild!(false)
429 rebuild!(false)
430 all.each { |p| p.set_or_update_position_under(p.parent) }
430 all.each { |p| p.set_or_update_position_under(p.parent) }
431 end
431 end
432 end
432 end
433
433
434 # Returns an array of the trackers used by the project and its active sub projects
434 # Returns an array of the trackers used by the project and its active sub projects
435 def rolled_up_trackers
435 def rolled_up_trackers
436 @rolled_up_trackers ||=
436 @rolled_up_trackers ||=
437 Tracker.
437 Tracker.
438 joins(:projects).
438 joins(:projects).
439 joins("JOIN #{EnabledModule.table_name} ON #{EnabledModule.table_name}.project_id = #{Project.table_name}.id AND #{EnabledModule.table_name}.name = 'issue_tracking'").
439 joins("JOIN #{EnabledModule.table_name} ON #{EnabledModule.table_name}.project_id = #{Project.table_name}.id AND #{EnabledModule.table_name}.name = 'issue_tracking'").
440 select("DISTINCT #{Tracker.table_name}.*").
440 select("DISTINCT #{Tracker.table_name}.*").
441 where("#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ? AND #{Project.table_name}.status <> #{STATUS_ARCHIVED}", lft, rgt).
441 where("#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ? AND #{Project.table_name}.status <> #{STATUS_ARCHIVED}", lft, rgt).
442 sorted.
442 sorted.
443 to_a
443 to_a
444 end
444 end
445
445
446 # Closes open and locked project versions that are completed
446 # Closes open and locked project versions that are completed
447 def close_completed_versions
447 def close_completed_versions
448 Version.transaction do
448 Version.transaction do
449 versions.where(:status => %w(open locked)).each do |version|
449 versions.where(:status => %w(open locked)).each do |version|
450 if version.completed?
450 if version.completed?
451 version.update_attribute(:status, 'closed')
451 version.update_attribute(:status, 'closed')
452 end
452 end
453 end
453 end
454 end
454 end
455 end
455 end
456
456
457 # Returns a scope of the Versions on subprojects
457 # Returns a scope of the Versions on subprojects
458 def rolled_up_versions
458 def rolled_up_versions
459 @rolled_up_versions ||=
459 @rolled_up_versions ||=
460 Version.
460 Version.
461 joins(:project).
461 joins(:project).
462 where("#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ? AND #{Project.table_name}.status <> ?", lft, rgt, STATUS_ARCHIVED)
462 where("#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ? AND #{Project.table_name}.status <> ?", lft, rgt, STATUS_ARCHIVED)
463 end
463 end
464
464
465 # Returns a scope of the Versions used by the project
465 # Returns a scope of the Versions used by the project
466 def shared_versions
466 def shared_versions
467 if new_record?
467 if new_record?
468 Version.
468 Version.
469 joins(:project).
469 joins(:project).
470 preload(:project).
470 preload(:project).
471 where("#{Project.table_name}.status <> ? AND #{Version.table_name}.sharing = 'system'", STATUS_ARCHIVED)
471 where("#{Project.table_name}.status <> ? AND #{Version.table_name}.sharing = 'system'", STATUS_ARCHIVED)
472 else
472 else
473 @shared_versions ||= begin
473 @shared_versions ||= begin
474 r = root? ? self : root
474 r = root? ? self : root
475 Version.
475 Version.
476 joins(:project).
476 joins(:project).
477 preload(:project).
477 preload(:project).
478 where("#{Project.table_name}.id = #{id}" +
478 where("#{Project.table_name}.id = #{id}" +
479 " OR (#{Project.table_name}.status <> #{Project::STATUS_ARCHIVED} AND (" +
479 " OR (#{Project.table_name}.status <> #{Project::STATUS_ARCHIVED} AND (" +
480 " #{Version.table_name}.sharing = 'system'" +
480 " #{Version.table_name}.sharing = 'system'" +
481 " OR (#{Project.table_name}.lft >= #{r.lft} AND #{Project.table_name}.rgt <= #{r.rgt} AND #{Version.table_name}.sharing = 'tree')" +
481 " OR (#{Project.table_name}.lft >= #{r.lft} AND #{Project.table_name}.rgt <= #{r.rgt} AND #{Version.table_name}.sharing = 'tree')" +
482 " OR (#{Project.table_name}.lft < #{lft} AND #{Project.table_name}.rgt > #{rgt} AND #{Version.table_name}.sharing IN ('hierarchy', 'descendants'))" +
482 " OR (#{Project.table_name}.lft < #{lft} AND #{Project.table_name}.rgt > #{rgt} AND #{Version.table_name}.sharing IN ('hierarchy', 'descendants'))" +
483 " OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt} AND #{Version.table_name}.sharing = 'hierarchy')" +
483 " OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt} AND #{Version.table_name}.sharing = 'hierarchy')" +
484 "))")
484 "))")
485 end
485 end
486 end
486 end
487 end
487 end
488
488
489 # Returns a hash of project users grouped by role
489 # Returns a hash of project users grouped by role
490 def users_by_role
490 def users_by_role
491 members.includes(:user, :roles).inject({}) do |h, m|
491 members.includes(:user, :roles).inject({}) do |h, m|
492 m.roles.each do |r|
492 m.roles.each do |r|
493 h[r] ||= []
493 h[r] ||= []
494 h[r] << m.user
494 h[r] << m.user
495 end
495 end
496 h
496 h
497 end
497 end
498 end
498 end
499
499
500 # Deletes all project's members
500 # Deletes all project's members
501 def delete_all_members
501 def delete_all_members
502 me, mr = Member.table_name, MemberRole.table_name
502 me, mr = Member.table_name, MemberRole.table_name
503 self.class.connection.delete("DELETE FROM #{mr} WHERE #{mr}.member_id IN (SELECT #{me}.id FROM #{me} WHERE #{me}.project_id = #{id})")
503 self.class.connection.delete("DELETE FROM #{mr} WHERE #{mr}.member_id IN (SELECT #{me}.id FROM #{me} WHERE #{me}.project_id = #{id})")
504 Member.delete_all(['project_id = ?', id])
504 Member.delete_all(['project_id = ?', id])
505 end
505 end
506
506
507 # Users/groups issues can be assigned to
507 # Users/groups issues can be assigned to
508 def assignable_users
508 def assignable_users
509 types = ['User']
509 types = ['User']
510 types << 'Group' if Setting.issue_group_assignment?
510 types << 'Group' if Setting.issue_group_assignment?
511
511
512 member_principals.
512 member_principals.
513 select {|m| types.include?(m.principal.type) && m.roles.detect(&:assignable?)}.
513 select {|m| types.include?(m.principal.type) && m.roles.detect(&:assignable?)}.
514 map(&:principal).
514 map(&:principal).
515 sort
515 sort
516 end
516 end
517
517
518 # Returns the mail addresses of users that should be always notified on project events
518 # Returns the mail addresses of users that should be always notified on project events
519 def recipients
519 def recipients
520 notified_users.collect {|user| user.mail}
520 notified_users.collect {|user| user.mail}
521 end
521 end
522
522
523 # Returns the users that should be notified on project events
523 # Returns the users that should be notified on project events
524 def notified_users
524 def notified_users
525 # TODO: User part should be extracted to User#notify_about?
525 # TODO: User part should be extracted to User#notify_about?
526 members.select {|m| m.principal.present? && (m.mail_notification? || m.principal.mail_notification == 'all')}.collect {|m| m.principal}
526 members.select {|m| m.principal.present? && (m.mail_notification? || m.principal.mail_notification == 'all')}.collect {|m| m.principal}
527 end
527 end
528
528
529 # Returns a scope of all custom fields enabled for project issues
529 # Returns a scope of all custom fields enabled for project issues
530 # (explicitly associated custom fields and custom fields enabled for all projects)
530 # (explicitly associated custom fields and custom fields enabled for all projects)
531 def all_issue_custom_fields
531 def all_issue_custom_fields
532 @all_issue_custom_fields ||= IssueCustomField.
532 @all_issue_custom_fields ||= IssueCustomField.
533 sorted.
533 sorted.
534 where("is_for_all = ? OR id IN (SELECT DISTINCT cfp.custom_field_id" +
534 where("is_for_all = ? OR id IN (SELECT DISTINCT cfp.custom_field_id" +
535 " FROM #{table_name_prefix}custom_fields_projects#{table_name_suffix} cfp" +
535 " FROM #{table_name_prefix}custom_fields_projects#{table_name_suffix} cfp" +
536 " WHERE cfp.project_id = ?)", true, id)
536 " WHERE cfp.project_id = ?)", true, id)
537 end
537 end
538
538
539 # Returns an array of all custom fields enabled for project time entries
539 # Returns an array of all custom fields enabled for project time entries
540 # (explictly associated custom fields and custom fields enabled for all projects)
540 # (explictly associated custom fields and custom fields enabled for all projects)
541 def all_time_entry_custom_fields
541 def all_time_entry_custom_fields
542 @all_time_entry_custom_fields ||= (TimeEntryCustomField.for_all + time_entry_custom_fields).uniq.sort
542 @all_time_entry_custom_fields ||= (TimeEntryCustomField.for_all + time_entry_custom_fields).uniq.sort
543 end
543 end
544
544
545 def project
545 def project
546 self
546 self
547 end
547 end
548
548
549 def <=>(project)
549 def <=>(project)
550 name.downcase <=> project.name.downcase
550 name.downcase <=> project.name.downcase
551 end
551 end
552
552
553 def to_s
553 def to_s
554 name
554 name
555 end
555 end
556
556
557 # Returns a short description of the projects (first lines)
557 # Returns a short description of the projects (first lines)
558 def short_description(length = 255)
558 def short_description(length = 255)
559 description.gsub(/^(.{#{length}}[^\n\r]*).*$/m, '\1...').strip if description
559 description.gsub(/^(.{#{length}}[^\n\r]*).*$/m, '\1...').strip if description
560 end
560 end
561
561
562 def css_classes
562 def css_classes
563 s = 'project'
563 s = 'project'
564 s << ' root' if root?
564 s << ' root' if root?
565 s << ' child' if child?
565 s << ' child' if child?
566 s << (leaf? ? ' leaf' : ' parent')
566 s << (leaf? ? ' leaf' : ' parent')
567 unless active?
567 unless active?
568 if archived?
568 if archived?
569 s << ' archived'
569 s << ' archived'
570 else
570 else
571 s << ' closed'
571 s << ' closed'
572 end
572 end
573 end
573 end
574 s
574 s
575 end
575 end
576
576
577 # The earliest start date of a project, based on it's issues and versions
577 # The earliest start date of a project, based on it's issues and versions
578 def start_date
578 def start_date
579 @start_date ||= [
579 @start_date ||= [
580 issues.minimum('start_date'),
580 issues.minimum('start_date'),
581 shared_versions.minimum('effective_date'),
581 shared_versions.minimum('effective_date'),
582 Issue.fixed_version(shared_versions).minimum('start_date')
582 Issue.fixed_version(shared_versions).minimum('start_date')
583 ].compact.min
583 ].compact.min
584 end
584 end
585
585
586 # The latest due date of an issue or version
586 # The latest due date of an issue or version
587 def due_date
587 def due_date
588 @due_date ||= [
588 @due_date ||= [
589 issues.maximum('due_date'),
589 issues.maximum('due_date'),
590 shared_versions.maximum('effective_date'),
590 shared_versions.maximum('effective_date'),
591 Issue.fixed_version(shared_versions).maximum('due_date')
591 Issue.fixed_version(shared_versions).maximum('due_date')
592 ].compact.max
592 ].compact.max
593 end
593 end
594
594
595 def overdue?
595 def overdue?
596 active? && !due_date.nil? && (due_date < Date.today)
596 active? && !due_date.nil? && (due_date < Date.today)
597 end
597 end
598
598
599 # Returns the percent completed for this project, based on the
599 # Returns the percent completed for this project, based on the
600 # progress on it's versions.
600 # progress on it's versions.
601 def completed_percent(options={:include_subprojects => false})
601 def completed_percent(options={:include_subprojects => false})
602 if options.delete(:include_subprojects)
602 if options.delete(:include_subprojects)
603 total = self_and_descendants.collect(&:completed_percent).sum
603 total = self_and_descendants.collect(&:completed_percent).sum
604
604
605 total / self_and_descendants.count
605 total / self_and_descendants.count
606 else
606 else
607 if versions.count > 0
607 if versions.count > 0
608 total = versions.collect(&:completed_percent).sum
608 total = versions.collect(&:completed_percent).sum
609
609
610 total / versions.count
610 total / versions.count
611 else
611 else
612 100
612 100
613 end
613 end
614 end
614 end
615 end
615 end
616
616
617 # Return true if this project allows to do the specified action.
617 # Return true if this project allows to do the specified action.
618 # action can be:
618 # action can be:
619 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
619 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
620 # * a permission Symbol (eg. :edit_project)
620 # * a permission Symbol (eg. :edit_project)
621 def allows_to?(action)
621 def allows_to?(action)
622 if archived?
622 if archived?
623 # No action allowed on archived projects
623 # No action allowed on archived projects
624 return false
624 return false
625 end
625 end
626 unless active? || Redmine::AccessControl.read_action?(action)
626 unless active? || Redmine::AccessControl.read_action?(action)
627 # No write action allowed on closed projects
627 # No write action allowed on closed projects
628 return false
628 return false
629 end
629 end
630 # No action allowed on disabled modules
630 # No action allowed on disabled modules
631 if action.is_a? Hash
631 if action.is_a? Hash
632 allowed_actions.include? "#{action[:controller]}/#{action[:action]}"
632 allowed_actions.include? "#{action[:controller]}/#{action[:action]}"
633 else
633 else
634 allowed_permissions.include? action
634 allowed_permissions.include? action
635 end
635 end
636 end
636 end
637
637
638 # Return the enabled module with the given name
638 # Return the enabled module with the given name
639 # or nil if the module is not enabled for the project
639 # or nil if the module is not enabled for the project
640 def enabled_module(name)
640 def enabled_module(name)
641 name = name.to_s
641 name = name.to_s
642 enabled_modules.detect {|m| m.name == name}
642 enabled_modules.detect {|m| m.name == name}
643 end
643 end
644
644
645 # Return true if the module with the given name is enabled
645 # Return true if the module with the given name is enabled
646 def module_enabled?(name)
646 def module_enabled?(name)
647 enabled_module(name).present?
647 enabled_module(name).present?
648 end
648 end
649
649
650 def enabled_module_names=(module_names)
650 def enabled_module_names=(module_names)
651 if module_names && module_names.is_a?(Array)
651 if module_names && module_names.is_a?(Array)
652 module_names = module_names.collect(&:to_s).reject(&:blank?)
652 module_names = module_names.collect(&:to_s).reject(&:blank?)
653 self.enabled_modules = module_names.collect {|name| enabled_modules.detect {|mod| mod.name == name} || EnabledModule.new(:name => name)}
653 self.enabled_modules = module_names.collect {|name| enabled_modules.detect {|mod| mod.name == name} || EnabledModule.new(:name => name)}
654 else
654 else
655 enabled_modules.clear
655 enabled_modules.clear
656 end
656 end
657 end
657 end
658
658
659 # Returns an array of the enabled modules names
659 # Returns an array of the enabled modules names
660 def enabled_module_names
660 def enabled_module_names
661 enabled_modules.collect(&:name)
661 enabled_modules.collect(&:name)
662 end
662 end
663
663
664 # Enable a specific module
664 # Enable a specific module
665 #
665 #
666 # Examples:
666 # Examples:
667 # project.enable_module!(:issue_tracking)
667 # project.enable_module!(:issue_tracking)
668 # project.enable_module!("issue_tracking")
668 # project.enable_module!("issue_tracking")
669 def enable_module!(name)
669 def enable_module!(name)
670 enabled_modules << EnabledModule.new(:name => name.to_s) unless module_enabled?(name)
670 enabled_modules << EnabledModule.new(:name => name.to_s) unless module_enabled?(name)
671 end
671 end
672
672
673 # Disable a module if it exists
673 # Disable a module if it exists
674 #
674 #
675 # Examples:
675 # Examples:
676 # project.disable_module!(:issue_tracking)
676 # project.disable_module!(:issue_tracking)
677 # project.disable_module!("issue_tracking")
677 # project.disable_module!("issue_tracking")
678 # project.disable_module!(project.enabled_modules.first)
678 # project.disable_module!(project.enabled_modules.first)
679 def disable_module!(target)
679 def disable_module!(target)
680 target = enabled_modules.detect{|mod| target.to_s == mod.name} unless enabled_modules.include?(target)
680 target = enabled_modules.detect{|mod| target.to_s == mod.name} unless enabled_modules.include?(target)
681 target.destroy unless target.blank?
681 target.destroy unless target.blank?
682 end
682 end
683
683
684 safe_attributes 'name',
684 safe_attributes 'name',
685 'description',
685 'description',
686 'homepage',
686 'homepage',
687 'is_public',
687 'is_public',
688 'identifier',
688 'identifier',
689 'custom_field_values',
689 'custom_field_values',
690 'custom_fields',
690 'custom_fields',
691 'tracker_ids',
691 'tracker_ids',
692 'issue_custom_field_ids'
692 'issue_custom_field_ids'
693
693
694 safe_attributes 'enabled_module_names',
694 safe_attributes 'enabled_module_names',
695 :if => lambda {|project, user| project.new_record? || user.allowed_to?(:select_project_modules, project) }
695 :if => lambda {|project, user| project.new_record? || user.allowed_to?(:select_project_modules, project) }
696
696
697 safe_attributes 'inherit_members',
697 safe_attributes 'inherit_members',
698 :if => lambda {|project, user| project.parent.nil? || project.parent.visible?(user)}
698 :if => lambda {|project, user| project.parent.nil? || project.parent.visible?(user)}
699
699
700 # Returns an array of projects that are in this project's hierarchy
700 # Returns an array of projects that are in this project's hierarchy
701 #
701 #
702 # Example: parents, children, siblings
702 # Example: parents, children, siblings
703 def hierarchy
703 def hierarchy
704 parents = project.self_and_ancestors || []
704 parents = project.self_and_ancestors || []
705 descendants = project.descendants || []
705 descendants = project.descendants || []
706 project_hierarchy = parents | descendants # Set union
706 project_hierarchy = parents | descendants # Set union
707 end
707 end
708
708
709 # Returns an auto-generated project identifier based on the last identifier used
709 # Returns an auto-generated project identifier based on the last identifier used
710 def self.next_identifier
710 def self.next_identifier
711 p = Project.order('id DESC').first
711 p = Project.order('id DESC').first
712 p.nil? ? nil : p.identifier.to_s.succ
712 p.nil? ? nil : p.identifier.to_s.succ
713 end
713 end
714
714
715 # Copies and saves the Project instance based on the +project+.
715 # Copies and saves the Project instance based on the +project+.
716 # Duplicates the source project's:
716 # Duplicates the source project's:
717 # * Wiki
717 # * Wiki
718 # * Versions
718 # * Versions
719 # * Categories
719 # * Categories
720 # * Issues
720 # * Issues
721 # * Members
721 # * Members
722 # * Queries
722 # * Queries
723 #
723 #
724 # Accepts an +options+ argument to specify what to copy
724 # Accepts an +options+ argument to specify what to copy
725 #
725 #
726 # Examples:
726 # Examples:
727 # project.copy(1) # => copies everything
727 # project.copy(1) # => copies everything
728 # project.copy(1, :only => 'members') # => copies members only
728 # project.copy(1, :only => 'members') # => copies members only
729 # project.copy(1, :only => ['members', 'versions']) # => copies members and versions
729 # project.copy(1, :only => ['members', 'versions']) # => copies members and versions
730 def copy(project, options={})
730 def copy(project, options={})
731 project = project.is_a?(Project) ? project : Project.find(project)
731 project = project.is_a?(Project) ? project : Project.find(project)
732
732
733 to_be_copied = %w(wiki versions issue_categories issues members queries boards)
733 to_be_copied = %w(wiki versions issue_categories issues members queries boards)
734 to_be_copied = to_be_copied & Array.wrap(options[:only]) unless options[:only].nil?
734 to_be_copied = to_be_copied & Array.wrap(options[:only]) unless options[:only].nil?
735
735
736 Project.transaction do
736 Project.transaction do
737 if save
737 if save
738 reload
738 reload
739 to_be_copied.each do |name|
739 to_be_copied.each do |name|
740 send "copy_#{name}", project
740 send "copy_#{name}", project
741 end
741 end
742 Redmine::Hook.call_hook(:model_project_copy_before_save, :source_project => project, :destination_project => self)
742 Redmine::Hook.call_hook(:model_project_copy_before_save, :source_project => project, :destination_project => self)
743 save
743 save
744 end
744 end
745 end
745 end
746 true
746 true
747 end
747 end
748
748
749 # Returns a new unsaved Project instance with attributes copied from +project+
749 # Returns a new unsaved Project instance with attributes copied from +project+
750 def self.copy_from(project)
750 def self.copy_from(project)
751 project = project.is_a?(Project) ? project : Project.find(project)
751 project = project.is_a?(Project) ? project : Project.find(project)
752 # clear unique attributes
752 # clear unique attributes
753 attributes = project.attributes.dup.except('id', 'name', 'identifier', 'status', 'parent_id', 'lft', 'rgt')
753 attributes = project.attributes.dup.except('id', 'name', 'identifier', 'status', 'parent_id', 'lft', 'rgt')
754 copy = Project.new(attributes)
754 copy = Project.new(attributes)
755 copy.enabled_modules = project.enabled_modules
755 copy.enabled_modules = project.enabled_modules
756 copy.trackers = project.trackers
756 copy.trackers = project.trackers
757 copy.custom_values = project.custom_values.collect {|v| v.clone}
757 copy.custom_values = project.custom_values.collect {|v| v.clone}
758 copy.issue_custom_fields = project.issue_custom_fields
758 copy.issue_custom_fields = project.issue_custom_fields
759 copy
759 copy
760 end
760 end
761
761
762 # Yields the given block for each project with its level in the tree
762 # Yields the given block for each project with its level in the tree
763 def self.project_tree(projects, &block)
763 def self.project_tree(projects, &block)
764 ancestors = []
764 ancestors = []
765 projects.sort_by(&:lft).each do |project|
765 projects.sort_by(&:lft).each do |project|
766 while (ancestors.any? && !project.is_descendant_of?(ancestors.last))
766 while (ancestors.any? && !project.is_descendant_of?(ancestors.last))
767 ancestors.pop
767 ancestors.pop
768 end
768 end
769 yield project, ancestors.size
769 yield project, ancestors.size
770 ancestors << project
770 ancestors << project
771 end
771 end
772 end
772 end
773
773
774 private
774 private
775
775
776 def after_parent_changed(parent_was)
776 def after_parent_changed(parent_was)
777 remove_inherited_member_roles
777 remove_inherited_member_roles
778 add_inherited_member_roles
778 add_inherited_member_roles
779 end
779 end
780
780
781 def update_inherited_members
781 def update_inherited_members
782 if parent
782 if parent
783 if inherit_members? && !inherit_members_was
783 if inherit_members? && !inherit_members_was
784 remove_inherited_member_roles
784 remove_inherited_member_roles
785 add_inherited_member_roles
785 add_inherited_member_roles
786 elsif !inherit_members? && inherit_members_was
786 elsif !inherit_members? && inherit_members_was
787 remove_inherited_member_roles
787 remove_inherited_member_roles
788 end
788 end
789 end
789 end
790 end
790 end
791
791
792 def remove_inherited_member_roles
792 def remove_inherited_member_roles
793 member_roles = memberships.map(&:member_roles).flatten
793 member_roles = memberships.map(&:member_roles).flatten
794 member_role_ids = member_roles.map(&:id)
794 member_role_ids = member_roles.map(&:id)
795 member_roles.each do |member_role|
795 member_roles.each do |member_role|
796 if member_role.inherited_from && !member_role_ids.include?(member_role.inherited_from)
796 if member_role.inherited_from && !member_role_ids.include?(member_role.inherited_from)
797 member_role.destroy
797 member_role.destroy
798 end
798 end
799 end
799 end
800 end
800 end
801
801
802 def add_inherited_member_roles
802 def add_inherited_member_roles
803 if inherit_members? && parent
803 if inherit_members? && parent
804 parent.memberships.each do |parent_member|
804 parent.memberships.each do |parent_member|
805 member = Member.find_or_new(self.id, parent_member.user_id)
805 member = Member.find_or_new(self.id, parent_member.user_id)
806 parent_member.member_roles.each do |parent_member_role|
806 parent_member.member_roles.each do |parent_member_role|
807 member.member_roles << MemberRole.new(:role => parent_member_role.role, :inherited_from => parent_member_role.id)
807 member.member_roles << MemberRole.new(:role => parent_member_role.role, :inherited_from => parent_member_role.id)
808 end
808 end
809 member.save!
809 member.save!
810 end
810 end
811 end
811 end
812 end
812 end
813
813
814 # Copies wiki from +project+
814 # Copies wiki from +project+
815 def copy_wiki(project)
815 def copy_wiki(project)
816 # Check that the source project has a wiki first
816 # Check that the source project has a wiki first
817 unless project.wiki.nil?
817 unless project.wiki.nil?
818 wiki = self.wiki || Wiki.new
818 wiki = self.wiki || Wiki.new
819 wiki.attributes = project.wiki.attributes.dup.except("id", "project_id")
819 wiki.attributes = project.wiki.attributes.dup.except("id", "project_id")
820 wiki_pages_map = {}
820 wiki_pages_map = {}
821 project.wiki.pages.each do |page|
821 project.wiki.pages.each do |page|
822 # Skip pages without content
822 # Skip pages without content
823 next if page.content.nil?
823 next if page.content.nil?
824 new_wiki_content = WikiContent.new(page.content.attributes.dup.except("id", "page_id", "updated_on"))
824 new_wiki_content = WikiContent.new(page.content.attributes.dup.except("id", "page_id", "updated_on"))
825 new_wiki_page = WikiPage.new(page.attributes.dup.except("id", "wiki_id", "created_on", "parent_id"))
825 new_wiki_page = WikiPage.new(page.attributes.dup.except("id", "wiki_id", "created_on", "parent_id"))
826 new_wiki_page.content = new_wiki_content
826 new_wiki_page.content = new_wiki_content
827 wiki.pages << new_wiki_page
827 wiki.pages << new_wiki_page
828 wiki_pages_map[page.id] = new_wiki_page
828 wiki_pages_map[page.id] = new_wiki_page
829 end
829 end
830
830
831 self.wiki = wiki
831 self.wiki = wiki
832 wiki.save
832 wiki.save
833 # Reproduce page hierarchy
833 # Reproduce page hierarchy
834 project.wiki.pages.each do |page|
834 project.wiki.pages.each do |page|
835 if page.parent_id && wiki_pages_map[page.id]
835 if page.parent_id && wiki_pages_map[page.id]
836 wiki_pages_map[page.id].parent = wiki_pages_map[page.parent_id]
836 wiki_pages_map[page.id].parent = wiki_pages_map[page.parent_id]
837 wiki_pages_map[page.id].save
837 wiki_pages_map[page.id].save
838 end
838 end
839 end
839 end
840 end
840 end
841 end
841 end
842
842
843 # Copies versions from +project+
843 # Copies versions from +project+
844 def copy_versions(project)
844 def copy_versions(project)
845 project.versions.each do |version|
845 project.versions.each do |version|
846 new_version = Version.new
846 new_version = Version.new
847 new_version.attributes = version.attributes.dup.except("id", "project_id", "created_on", "updated_on")
847 new_version.attributes = version.attributes.dup.except("id", "project_id", "created_on", "updated_on")
848 self.versions << new_version
848 self.versions << new_version
849 end
849 end
850 end
850 end
851
851
852 # Copies issue categories from +project+
852 # Copies issue categories from +project+
853 def copy_issue_categories(project)
853 def copy_issue_categories(project)
854 project.issue_categories.each do |issue_category|
854 project.issue_categories.each do |issue_category|
855 new_issue_category = IssueCategory.new
855 new_issue_category = IssueCategory.new
856 new_issue_category.attributes = issue_category.attributes.dup.except("id", "project_id")
856 new_issue_category.attributes = issue_category.attributes.dup.except("id", "project_id")
857 self.issue_categories << new_issue_category
857 self.issue_categories << new_issue_category
858 end
858 end
859 end
859 end
860
860
861 # Copies issues from +project+
861 # Copies issues from +project+
862 def copy_issues(project)
862 def copy_issues(project)
863 # Stores the source issue id as a key and the copied issues as the
863 # Stores the source issue id as a key and the copied issues as the
864 # value. Used to map the two together for issue relations.
864 # value. Used to map the two together for issue relations.
865 issues_map = {}
865 issues_map = {}
866
866
867 # Store status and reopen locked/closed versions
867 # Store status and reopen locked/closed versions
868 version_statuses = versions.reject(&:open?).map {|version| [version, version.status]}
868 version_statuses = versions.reject(&:open?).map {|version| [version, version.status]}
869 version_statuses.each do |version, status|
869 version_statuses.each do |version, status|
870 version.update_attribute :status, 'open'
870 version.update_attribute :status, 'open'
871 end
871 end
872
872
873 # Get issues sorted by root_id, lft so that parent issues
873 # Get issues sorted by root_id, lft so that parent issues
874 # get copied before their children
874 # get copied before their children
875 project.issues.reorder('root_id, lft').each do |issue|
875 project.issues.reorder('root_id, lft').each do |issue|
876 new_issue = Issue.new
876 new_issue = Issue.new
877 new_issue.copy_from(issue, :subtasks => false, :link => false)
877 new_issue.copy_from(issue, :subtasks => false, :link => false)
878 new_issue.project = self
878 new_issue.project = self
879 # Changing project resets the custom field values
879 # Changing project resets the custom field values
880 # TODO: handle this in Issue#project=
880 # TODO: handle this in Issue#project=
881 new_issue.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
881 new_issue.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
882 # Reassign fixed_versions by name, since names are unique per project
882 # Reassign fixed_versions by name, since names are unique per project
883 if issue.fixed_version && issue.fixed_version.project == project
883 if issue.fixed_version && issue.fixed_version.project == project
884 new_issue.fixed_version = self.versions.detect {|v| v.name == issue.fixed_version.name}
884 new_issue.fixed_version = self.versions.detect {|v| v.name == issue.fixed_version.name}
885 end
885 end
886 # Reassign the category by name, since names are unique per project
886 # Reassign the category by name, since names are unique per project
887 if issue.category
887 if issue.category
888 new_issue.category = self.issue_categories.detect {|c| c.name == issue.category.name}
888 new_issue.category = self.issue_categories.detect {|c| c.name == issue.category.name}
889 end
889 end
890 # Parent issue
890 # Parent issue
891 if issue.parent_id
891 if issue.parent_id
892 if copied_parent = issues_map[issue.parent_id]
892 if copied_parent = issues_map[issue.parent_id]
893 new_issue.parent_issue_id = copied_parent.id
893 new_issue.parent_issue_id = copied_parent.id
894 end
894 end
895 end
895 end
896
896
897 self.issues << new_issue
897 self.issues << new_issue
898 if new_issue.new_record?
898 if new_issue.new_record?
899 logger.info "Project#copy_issues: issue ##{issue.id} could not be copied: #{new_issue.errors.full_messages}" if logger && logger.info
899 logger.info "Project#copy_issues: issue ##{issue.id} could not be copied: #{new_issue.errors.full_messages}" if logger && logger.info
900 else
900 else
901 issues_map[issue.id] = new_issue unless new_issue.new_record?
901 issues_map[issue.id] = new_issue unless new_issue.new_record?
902 end
902 end
903 end
903 end
904
904
905 # Restore locked/closed version statuses
905 # Restore locked/closed version statuses
906 version_statuses.each do |version, status|
906 version_statuses.each do |version, status|
907 version.update_attribute :status, status
907 version.update_attribute :status, status
908 end
908 end
909
909
910 # Relations after in case issues related each other
910 # Relations after in case issues related each other
911 project.issues.each do |issue|
911 project.issues.each do |issue|
912 new_issue = issues_map[issue.id]
912 new_issue = issues_map[issue.id]
913 unless new_issue
913 unless new_issue
914 # Issue was not copied
914 # Issue was not copied
915 next
915 next
916 end
916 end
917
917
918 # Relations
918 # Relations
919 issue.relations_from.each do |source_relation|
919 issue.relations_from.each do |source_relation|
920 new_issue_relation = IssueRelation.new
920 new_issue_relation = IssueRelation.new
921 new_issue_relation.attributes = source_relation.attributes.dup.except("id", "issue_from_id", "issue_to_id")
921 new_issue_relation.attributes = source_relation.attributes.dup.except("id", "issue_from_id", "issue_to_id")
922 new_issue_relation.issue_to = issues_map[source_relation.issue_to_id]
922 new_issue_relation.issue_to = issues_map[source_relation.issue_to_id]
923 if new_issue_relation.issue_to.nil? && Setting.cross_project_issue_relations?
923 if new_issue_relation.issue_to.nil? && Setting.cross_project_issue_relations?
924 new_issue_relation.issue_to = source_relation.issue_to
924 new_issue_relation.issue_to = source_relation.issue_to
925 end
925 end
926 new_issue.relations_from << new_issue_relation
926 new_issue.relations_from << new_issue_relation
927 end
927 end
928
928
929 issue.relations_to.each do |source_relation|
929 issue.relations_to.each do |source_relation|
930 new_issue_relation = IssueRelation.new
930 new_issue_relation = IssueRelation.new
931 new_issue_relation.attributes = source_relation.attributes.dup.except("id", "issue_from_id", "issue_to_id")
931 new_issue_relation.attributes = source_relation.attributes.dup.except("id", "issue_from_id", "issue_to_id")
932 new_issue_relation.issue_from = issues_map[source_relation.issue_from_id]
932 new_issue_relation.issue_from = issues_map[source_relation.issue_from_id]
933 if new_issue_relation.issue_from.nil? && Setting.cross_project_issue_relations?
933 if new_issue_relation.issue_from.nil? && Setting.cross_project_issue_relations?
934 new_issue_relation.issue_from = source_relation.issue_from
934 new_issue_relation.issue_from = source_relation.issue_from
935 end
935 end
936 new_issue.relations_to << new_issue_relation
936 new_issue.relations_to << new_issue_relation
937 end
937 end
938 end
938 end
939 end
939 end
940
940
941 # Copies members from +project+
941 # Copies members from +project+
942 def copy_members(project)
942 def copy_members(project)
943 # Copy users first, then groups to handle members with inherited and given roles
943 # Copy users first, then groups to handle members with inherited and given roles
944 members_to_copy = []
944 members_to_copy = []
945 members_to_copy += project.memberships.select {|m| m.principal.is_a?(User)}
945 members_to_copy += project.memberships.select {|m| m.principal.is_a?(User)}
946 members_to_copy += project.memberships.select {|m| !m.principal.is_a?(User)}
946 members_to_copy += project.memberships.select {|m| !m.principal.is_a?(User)}
947
947
948 members_to_copy.each do |member|
948 members_to_copy.each do |member|
949 new_member = Member.new
949 new_member = Member.new
950 new_member.attributes = member.attributes.dup.except("id", "project_id", "created_on")
950 new_member.attributes = member.attributes.dup.except("id", "project_id", "created_on")
951 # only copy non inherited roles
951 # only copy non inherited roles
952 # inherited roles will be added when copying the group membership
952 # inherited roles will be added when copying the group membership
953 role_ids = member.member_roles.reject(&:inherited?).collect(&:role_id)
953 role_ids = member.member_roles.reject(&:inherited?).collect(&:role_id)
954 next if role_ids.empty?
954 next if role_ids.empty?
955 new_member.role_ids = role_ids
955 new_member.role_ids = role_ids
956 new_member.project = self
956 new_member.project = self
957 self.members << new_member
957 self.members << new_member
958 end
958 end
959 end
959 end
960
960
961 # Copies queries from +project+
961 # Copies queries from +project+
962 def copy_queries(project)
962 def copy_queries(project)
963 project.queries.each do |query|
963 project.queries.each do |query|
964 new_query = IssueQuery.new
964 new_query = IssueQuery.new
965 new_query.attributes = query.attributes.dup.except("id", "project_id", "sort_criteria")
965 new_query.attributes = query.attributes.dup.except("id", "project_id", "sort_criteria", "user_id", "type")
966 new_query.sort_criteria = query.sort_criteria if query.sort_criteria
966 new_query.sort_criteria = query.sort_criteria if query.sort_criteria
967 new_query.project = self
967 new_query.project = self
968 new_query.user_id = query.user_id
968 new_query.user_id = query.user_id
969 new_query.role_ids = query.role_ids if query.visibility == IssueQuery::VISIBILITY_ROLES
969 self.queries << new_query
970 self.queries << new_query
970 end
971 end
971 end
972 end
972
973
973 # Copies boards from +project+
974 # Copies boards from +project+
974 def copy_boards(project)
975 def copy_boards(project)
975 project.boards.each do |board|
976 project.boards.each do |board|
976 new_board = Board.new
977 new_board = Board.new
977 new_board.attributes = board.attributes.dup.except("id", "project_id", "topics_count", "messages_count", "last_message_id")
978 new_board.attributes = board.attributes.dup.except("id", "project_id", "topics_count", "messages_count", "last_message_id")
978 new_board.project = self
979 new_board.project = self
979 self.boards << new_board
980 self.boards << new_board
980 end
981 end
981 end
982 end
982
983
983 def allowed_permissions
984 def allowed_permissions
984 @allowed_permissions ||= begin
985 @allowed_permissions ||= begin
985 module_names = enabled_modules.loaded? ? enabled_modules.map(&:name) : enabled_modules.pluck(:name)
986 module_names = enabled_modules.loaded? ? enabled_modules.map(&:name) : enabled_modules.pluck(:name)
986 Redmine::AccessControl.modules_permissions(module_names).collect {|p| p.name}
987 Redmine::AccessControl.modules_permissions(module_names).collect {|p| p.name}
987 end
988 end
988 end
989 end
989
990
990 def allowed_actions
991 def allowed_actions
991 @actions_allowed ||= allowed_permissions.inject([]) { |actions, permission| actions += Redmine::AccessControl.allowed_actions(permission) }.flatten
992 @actions_allowed ||= allowed_permissions.inject([]) { |actions, permission| actions += Redmine::AccessControl.allowed_actions(permission) }.flatten
992 end
993 end
993
994
994 # Returns all the active Systemwide and project specific activities
995 # Returns all the active Systemwide and project specific activities
995 def active_activities
996 def active_activities
996 overridden_activity_ids = self.time_entry_activities.collect(&:parent_id)
997 overridden_activity_ids = self.time_entry_activities.collect(&:parent_id)
997
998
998 if overridden_activity_ids.empty?
999 if overridden_activity_ids.empty?
999 return TimeEntryActivity.shared.active
1000 return TimeEntryActivity.shared.active
1000 else
1001 else
1001 return system_activities_and_project_overrides
1002 return system_activities_and_project_overrides
1002 end
1003 end
1003 end
1004 end
1004
1005
1005 # Returns all the Systemwide and project specific activities
1006 # Returns all the Systemwide and project specific activities
1006 # (inactive and active)
1007 # (inactive and active)
1007 def all_activities
1008 def all_activities
1008 overridden_activity_ids = self.time_entry_activities.collect(&:parent_id)
1009 overridden_activity_ids = self.time_entry_activities.collect(&:parent_id)
1009
1010
1010 if overridden_activity_ids.empty?
1011 if overridden_activity_ids.empty?
1011 return TimeEntryActivity.shared
1012 return TimeEntryActivity.shared
1012 else
1013 else
1013 return system_activities_and_project_overrides(true)
1014 return system_activities_and_project_overrides(true)
1014 end
1015 end
1015 end
1016 end
1016
1017
1017 # Returns the systemwide active activities merged with the project specific overrides
1018 # Returns the systemwide active activities merged with the project specific overrides
1018 def system_activities_and_project_overrides(include_inactive=false)
1019 def system_activities_and_project_overrides(include_inactive=false)
1019 t = TimeEntryActivity.table_name
1020 t = TimeEntryActivity.table_name
1020 scope = TimeEntryActivity.where(
1021 scope = TimeEntryActivity.where(
1021 "(#{t}.project_id IS NULL AND #{t}.id NOT IN (?)) OR (#{t}.project_id = ?)",
1022 "(#{t}.project_id IS NULL AND #{t}.id NOT IN (?)) OR (#{t}.project_id = ?)",
1022 time_entry_activities.map(&:parent_id), id
1023 time_entry_activities.map(&:parent_id), id
1023 )
1024 )
1024 unless include_inactive
1025 unless include_inactive
1025 scope = scope.active
1026 scope = scope.active
1026 end
1027 end
1027 scope
1028 scope
1028 end
1029 end
1029
1030
1030 # Archives subprojects recursively
1031 # Archives subprojects recursively
1031 def archive!
1032 def archive!
1032 children.each do |subproject|
1033 children.each do |subproject|
1033 subproject.send :archive!
1034 subproject.send :archive!
1034 end
1035 end
1035 update_attribute :status, STATUS_ARCHIVED
1036 update_attribute :status, STATUS_ARCHIVED
1036 end
1037 end
1037
1038
1038 def update_position_under_parent
1039 def update_position_under_parent
1039 set_or_update_position_under(parent)
1040 set_or_update_position_under(parent)
1040 end
1041 end
1041
1042
1042 public
1043 public
1043
1044
1044 # Inserts/moves the project so that target's children or root projects stay alphabetically sorted
1045 # Inserts/moves the project so that target's children or root projects stay alphabetically sorted
1045 def set_or_update_position_under(target_parent)
1046 def set_or_update_position_under(target_parent)
1046 parent_was = parent
1047 parent_was = parent
1047 sibs = (target_parent.nil? ? self.class.roots : target_parent.children)
1048 sibs = (target_parent.nil? ? self.class.roots : target_parent.children)
1048 to_be_inserted_before = sibs.sort_by {|c| c.name.to_s.downcase}.detect {|c| c.name.to_s.downcase > name.to_s.downcase }
1049 to_be_inserted_before = sibs.sort_by {|c| c.name.to_s.downcase}.detect {|c| c.name.to_s.downcase > name.to_s.downcase }
1049
1050
1050 if to_be_inserted_before
1051 if to_be_inserted_before
1051 move_to_left_of(to_be_inserted_before)
1052 move_to_left_of(to_be_inserted_before)
1052 elsif target_parent.nil?
1053 elsif target_parent.nil?
1053 if sibs.empty?
1054 if sibs.empty?
1054 # move_to_root adds the project in first (ie. left) position
1055 # move_to_root adds the project in first (ie. left) position
1055 move_to_root
1056 move_to_root
1056 else
1057 else
1057 move_to_right_of(sibs.last) unless self == sibs.last
1058 move_to_right_of(sibs.last) unless self == sibs.last
1058 end
1059 end
1059 else
1060 else
1060 # move_to_child_of adds the project in last (ie.right) position
1061 # move_to_child_of adds the project in last (ie.right) position
1061 move_to_child_of(target_parent)
1062 move_to_child_of(target_parent)
1062 end
1063 end
1063 if parent_was != target_parent
1064 if parent_was != target_parent
1064 after_parent_changed(parent_was)
1065 after_parent_changed(parent_was)
1065 end
1066 end
1066 end
1067 end
1067 end
1068 end
@@ -1,801 +1,801
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2014 Jean-Philippe Lang
2 # Copyright (C) 2006-2014 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 "digest/sha1"
18 require "digest/sha1"
19
19
20 class User < Principal
20 class User < Principal
21 include Redmine::SafeAttributes
21 include Redmine::SafeAttributes
22
22
23 # Different ways of displaying/sorting users
23 # Different ways of displaying/sorting users
24 USER_FORMATS = {
24 USER_FORMATS = {
25 :firstname_lastname => {
25 :firstname_lastname => {
26 :string => '#{firstname} #{lastname}',
26 :string => '#{firstname} #{lastname}',
27 :order => %w(firstname lastname id),
27 :order => %w(firstname lastname id),
28 :setting_order => 1
28 :setting_order => 1
29 },
29 },
30 :firstname_lastinitial => {
30 :firstname_lastinitial => {
31 :string => '#{firstname} #{lastname.to_s.chars.first}.',
31 :string => '#{firstname} #{lastname.to_s.chars.first}.',
32 :order => %w(firstname lastname id),
32 :order => %w(firstname lastname id),
33 :setting_order => 2
33 :setting_order => 2
34 },
34 },
35 :firstinitial_lastname => {
35 :firstinitial_lastname => {
36 :string => '#{firstname.to_s.gsub(/(([[:alpha:]])[[:alpha:]]*\.?)/, \'\2.\')} #{lastname}',
36 :string => '#{firstname.to_s.gsub(/(([[:alpha:]])[[:alpha:]]*\.?)/, \'\2.\')} #{lastname}',
37 :order => %w(firstname lastname id),
37 :order => %w(firstname lastname id),
38 :setting_order => 2
38 :setting_order => 2
39 },
39 },
40 :firstname => {
40 :firstname => {
41 :string => '#{firstname}',
41 :string => '#{firstname}',
42 :order => %w(firstname id),
42 :order => %w(firstname id),
43 :setting_order => 3
43 :setting_order => 3
44 },
44 },
45 :lastname_firstname => {
45 :lastname_firstname => {
46 :string => '#{lastname} #{firstname}',
46 :string => '#{lastname} #{firstname}',
47 :order => %w(lastname firstname id),
47 :order => %w(lastname firstname id),
48 :setting_order => 4
48 :setting_order => 4
49 },
49 },
50 :lastname_coma_firstname => {
50 :lastname_coma_firstname => {
51 :string => '#{lastname}, #{firstname}',
51 :string => '#{lastname}, #{firstname}',
52 :order => %w(lastname firstname id),
52 :order => %w(lastname firstname id),
53 :setting_order => 5
53 :setting_order => 5
54 },
54 },
55 :lastname => {
55 :lastname => {
56 :string => '#{lastname}',
56 :string => '#{lastname}',
57 :order => %w(lastname id),
57 :order => %w(lastname id),
58 :setting_order => 6
58 :setting_order => 6
59 },
59 },
60 :username => {
60 :username => {
61 :string => '#{login}',
61 :string => '#{login}',
62 :order => %w(login id),
62 :order => %w(login id),
63 :setting_order => 7
63 :setting_order => 7
64 },
64 },
65 }
65 }
66
66
67 MAIL_NOTIFICATION_OPTIONS = [
67 MAIL_NOTIFICATION_OPTIONS = [
68 ['all', :label_user_mail_option_all],
68 ['all', :label_user_mail_option_all],
69 ['selected', :label_user_mail_option_selected],
69 ['selected', :label_user_mail_option_selected],
70 ['only_my_events', :label_user_mail_option_only_my_events],
70 ['only_my_events', :label_user_mail_option_only_my_events],
71 ['only_assigned', :label_user_mail_option_only_assigned],
71 ['only_assigned', :label_user_mail_option_only_assigned],
72 ['only_owner', :label_user_mail_option_only_owner],
72 ['only_owner', :label_user_mail_option_only_owner],
73 ['none', :label_user_mail_option_none]
73 ['none', :label_user_mail_option_none]
74 ]
74 ]
75
75
76 has_and_belongs_to_many :groups,
76 has_and_belongs_to_many :groups,
77 :join_table => "#{table_name_prefix}groups_users#{table_name_suffix}",
77 :join_table => "#{table_name_prefix}groups_users#{table_name_suffix}",
78 :after_add => Proc.new {|user, group| group.user_added(user)},
78 :after_add => Proc.new {|user, group| group.user_added(user)},
79 :after_remove => Proc.new {|user, group| group.user_removed(user)}
79 :after_remove => Proc.new {|user, group| group.user_removed(user)}
80 has_many :changesets, :dependent => :nullify
80 has_many :changesets, :dependent => :nullify
81 has_one :preference, :dependent => :destroy, :class_name => 'UserPreference'
81 has_one :preference, :dependent => :destroy, :class_name => 'UserPreference'
82 has_one :rss_token, lambda {where "action='feeds'"}, :class_name => 'Token'
82 has_one :rss_token, lambda {where "action='feeds'"}, :class_name => 'Token'
83 has_one :api_token, lambda {where "action='api'"}, :class_name => 'Token'
83 has_one :api_token, lambda {where "action='api'"}, :class_name => 'Token'
84 belongs_to :auth_source
84 belongs_to :auth_source
85
85
86 scope :logged, lambda { where("#{User.table_name}.status <> #{STATUS_ANONYMOUS}") }
86 scope :logged, lambda { where("#{User.table_name}.status <> #{STATUS_ANONYMOUS}") }
87 scope :status, lambda {|arg| where(arg.blank? ? nil : {:status => arg.to_i}) }
87 scope :status, lambda {|arg| where(arg.blank? ? nil : {:status => arg.to_i}) }
88
88
89 acts_as_customizable
89 acts_as_customizable
90
90
91 attr_accessor :password, :password_confirmation, :generate_password
91 attr_accessor :password, :password_confirmation, :generate_password
92 attr_accessor :last_before_login_on
92 attr_accessor :last_before_login_on
93 # Prevents unauthorized assignments
93 # Prevents unauthorized assignments
94 attr_protected :login, :admin, :password, :password_confirmation, :hashed_password
94 attr_protected :login, :admin, :password, :password_confirmation, :hashed_password
95
95
96 LOGIN_LENGTH_LIMIT = 60
96 LOGIN_LENGTH_LIMIT = 60
97 MAIL_LENGTH_LIMIT = 60
97 MAIL_LENGTH_LIMIT = 60
98
98
99 validates_presence_of :login, :firstname, :lastname, :mail, :if => Proc.new { |user| !user.is_a?(AnonymousUser) }
99 validates_presence_of :login, :firstname, :lastname, :mail, :if => Proc.new { |user| !user.is_a?(AnonymousUser) }
100 validates_uniqueness_of :login, :if => Proc.new { |user| user.login_changed? && user.login.present? }, :case_sensitive => false
100 validates_uniqueness_of :login, :if => Proc.new { |user| user.login_changed? && user.login.present? }, :case_sensitive => false
101 validates_uniqueness_of :mail, :if => Proc.new { |user| user.mail_changed? && user.mail.present? }, :case_sensitive => false
101 validates_uniqueness_of :mail, :if => Proc.new { |user| user.mail_changed? && user.mail.present? }, :case_sensitive => false
102 # Login must contain letters, numbers, underscores only
102 # Login must contain letters, numbers, underscores only
103 validates_format_of :login, :with => /\A[a-z0-9_\-@\.]*\z/i
103 validates_format_of :login, :with => /\A[a-z0-9_\-@\.]*\z/i
104 validates_length_of :login, :maximum => LOGIN_LENGTH_LIMIT
104 validates_length_of :login, :maximum => LOGIN_LENGTH_LIMIT
105 validates_length_of :firstname, :lastname, :maximum => 30
105 validates_length_of :firstname, :lastname, :maximum => 30
106 validates_format_of :mail, :with => /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\z/i, :allow_blank => true
106 validates_format_of :mail, :with => /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\z/i, :allow_blank => true
107 validates_length_of :mail, :maximum => MAIL_LENGTH_LIMIT, :allow_nil => true
107 validates_length_of :mail, :maximum => MAIL_LENGTH_LIMIT, :allow_nil => true
108 validates_inclusion_of :mail_notification, :in => MAIL_NOTIFICATION_OPTIONS.collect(&:first), :allow_blank => true
108 validates_inclusion_of :mail_notification, :in => MAIL_NOTIFICATION_OPTIONS.collect(&:first), :allow_blank => true
109 validate :validate_password_length
109 validate :validate_password_length
110 validate do
110 validate do
111 if password_confirmation && password != password_confirmation
111 if password_confirmation && password != password_confirmation
112 errors.add(:password, :confirmation)
112 errors.add(:password, :confirmation)
113 end
113 end
114 end
114 end
115
115
116 before_create :set_mail_notification
116 before_create :set_mail_notification
117 before_save :generate_password_if_needed, :update_hashed_password
117 before_save :generate_password_if_needed, :update_hashed_password
118 before_destroy :remove_references_before_destroy
118 before_destroy :remove_references_before_destroy
119 after_save :update_notified_project_ids, :destroy_tokens
119 after_save :update_notified_project_ids, :destroy_tokens
120
120
121 scope :in_group, lambda {|group|
121 scope :in_group, lambda {|group|
122 group_id = group.is_a?(Group) ? group.id : group.to_i
122 group_id = group.is_a?(Group) ? group.id : group.to_i
123 where("#{User.table_name}.id IN (SELECT gu.user_id FROM #{table_name_prefix}groups_users#{table_name_suffix} gu WHERE gu.group_id = ?)", group_id)
123 where("#{User.table_name}.id IN (SELECT gu.user_id FROM #{table_name_prefix}groups_users#{table_name_suffix} gu WHERE gu.group_id = ?)", group_id)
124 }
124 }
125 scope :not_in_group, lambda {|group|
125 scope :not_in_group, lambda {|group|
126 group_id = group.is_a?(Group) ? group.id : group.to_i
126 group_id = group.is_a?(Group) ? group.id : group.to_i
127 where("#{User.table_name}.id NOT IN (SELECT gu.user_id FROM #{table_name_prefix}groups_users#{table_name_suffix} gu WHERE gu.group_id = ?)", group_id)
127 where("#{User.table_name}.id NOT IN (SELECT gu.user_id FROM #{table_name_prefix}groups_users#{table_name_suffix} gu WHERE gu.group_id = ?)", group_id)
128 }
128 }
129 scope :sorted, lambda { order(*User.fields_for_order_statement)}
129 scope :sorted, lambda { order(*User.fields_for_order_statement)}
130
130
131 def set_mail_notification
131 def set_mail_notification
132 self.mail_notification = Setting.default_notification_option if self.mail_notification.blank?
132 self.mail_notification = Setting.default_notification_option if self.mail_notification.blank?
133 true
133 true
134 end
134 end
135
135
136 def update_hashed_password
136 def update_hashed_password
137 # update hashed_password if password was set
137 # update hashed_password if password was set
138 if self.password && self.auth_source_id.blank?
138 if self.password && self.auth_source_id.blank?
139 salt_password(password)
139 salt_password(password)
140 end
140 end
141 end
141 end
142
142
143 alias :base_reload :reload
143 alias :base_reload :reload
144 def reload(*args)
144 def reload(*args)
145 @name = nil
145 @name = nil
146 @projects_by_role = nil
146 @projects_by_role = nil
147 @membership_by_project_id = nil
147 @membership_by_project_id = nil
148 @notified_projects_ids = nil
148 @notified_projects_ids = nil
149 @notified_projects_ids_changed = false
149 @notified_projects_ids_changed = false
150 @builtin_role = nil
150 @builtin_role = nil
151 base_reload(*args)
151 base_reload(*args)
152 end
152 end
153
153
154 def mail=(arg)
154 def mail=(arg)
155 write_attribute(:mail, arg.to_s.strip)
155 write_attribute(:mail, arg.to_s.strip)
156 end
156 end
157
157
158 def self.find_or_initialize_by_identity_url(url)
158 def self.find_or_initialize_by_identity_url(url)
159 user = where(:identity_url => url).first
159 user = where(:identity_url => url).first
160 unless user
160 unless user
161 user = User.new
161 user = User.new
162 user.identity_url = url
162 user.identity_url = url
163 end
163 end
164 user
164 user
165 end
165 end
166
166
167 def identity_url=(url)
167 def identity_url=(url)
168 if url.blank?
168 if url.blank?
169 write_attribute(:identity_url, '')
169 write_attribute(:identity_url, '')
170 else
170 else
171 begin
171 begin
172 write_attribute(:identity_url, OpenIdAuthentication.normalize_identifier(url))
172 write_attribute(:identity_url, OpenIdAuthentication.normalize_identifier(url))
173 rescue OpenIdAuthentication::InvalidOpenId
173 rescue OpenIdAuthentication::InvalidOpenId
174 # Invalid url, don't save
174 # Invalid url, don't save
175 end
175 end
176 end
176 end
177 self.read_attribute(:identity_url)
177 self.read_attribute(:identity_url)
178 end
178 end
179
179
180 # Returns the user that matches provided login and password, or nil
180 # Returns the user that matches provided login and password, or nil
181 def self.try_to_login(login, password, active_only=true)
181 def self.try_to_login(login, password, active_only=true)
182 login = login.to_s
182 login = login.to_s
183 password = password.to_s
183 password = password.to_s
184
184
185 # Make sure no one can sign in with an empty login or password
185 # Make sure no one can sign in with an empty login or password
186 return nil if login.empty? || password.empty?
186 return nil if login.empty? || password.empty?
187 user = find_by_login(login)
187 user = find_by_login(login)
188 if user
188 if user
189 # user is already in local database
189 # user is already in local database
190 return nil unless user.check_password?(password)
190 return nil unless user.check_password?(password)
191 return nil if !user.active? && active_only
191 return nil if !user.active? && active_only
192 else
192 else
193 # user is not yet registered, try to authenticate with available sources
193 # user is not yet registered, try to authenticate with available sources
194 attrs = AuthSource.authenticate(login, password)
194 attrs = AuthSource.authenticate(login, password)
195 if attrs
195 if attrs
196 user = new(attrs)
196 user = new(attrs)
197 user.login = login
197 user.login = login
198 user.language = Setting.default_language
198 user.language = Setting.default_language
199 if user.save
199 if user.save
200 user.reload
200 user.reload
201 logger.info("User '#{user.login}' created from external auth source: #{user.auth_source.type} - #{user.auth_source.name}") if logger && user.auth_source
201 logger.info("User '#{user.login}' created from external auth source: #{user.auth_source.type} - #{user.auth_source.name}") if logger && user.auth_source
202 end
202 end
203 end
203 end
204 end
204 end
205 user.update_column(:last_login_on, Time.now) if user && !user.new_record? && user.active?
205 user.update_column(:last_login_on, Time.now) if user && !user.new_record? && user.active?
206 user
206 user
207 rescue => text
207 rescue => text
208 raise text
208 raise text
209 end
209 end
210
210
211 # Returns the user who matches the given autologin +key+ or nil
211 # Returns the user who matches the given autologin +key+ or nil
212 def self.try_to_autologin(key)
212 def self.try_to_autologin(key)
213 user = Token.find_active_user('autologin', key, Setting.autologin.to_i)
213 user = Token.find_active_user('autologin', key, Setting.autologin.to_i)
214 if user
214 if user
215 user.update_column(:last_login_on, Time.now)
215 user.update_column(:last_login_on, Time.now)
216 user
216 user
217 end
217 end
218 end
218 end
219
219
220 def self.name_formatter(formatter = nil)
220 def self.name_formatter(formatter = nil)
221 USER_FORMATS[formatter || Setting.user_format] || USER_FORMATS[:firstname_lastname]
221 USER_FORMATS[formatter || Setting.user_format] || USER_FORMATS[:firstname_lastname]
222 end
222 end
223
223
224 # Returns an array of fields names than can be used to make an order statement for users
224 # Returns an array of fields names than can be used to make an order statement for users
225 # according to how user names are displayed
225 # according to how user names are displayed
226 # Examples:
226 # Examples:
227 #
227 #
228 # User.fields_for_order_statement => ['users.login', 'users.id']
228 # User.fields_for_order_statement => ['users.login', 'users.id']
229 # User.fields_for_order_statement('authors') => ['authors.login', 'authors.id']
229 # User.fields_for_order_statement('authors') => ['authors.login', 'authors.id']
230 def self.fields_for_order_statement(table=nil)
230 def self.fields_for_order_statement(table=nil)
231 table ||= table_name
231 table ||= table_name
232 name_formatter[:order].map {|field| "#{table}.#{field}"}
232 name_formatter[:order].map {|field| "#{table}.#{field}"}
233 end
233 end
234
234
235 # Return user's full name for display
235 # Return user's full name for display
236 def name(formatter = nil)
236 def name(formatter = nil)
237 f = self.class.name_formatter(formatter)
237 f = self.class.name_formatter(formatter)
238 if formatter
238 if formatter
239 eval('"' + f[:string] + '"')
239 eval('"' + f[:string] + '"')
240 else
240 else
241 @name ||= eval('"' + f[:string] + '"')
241 @name ||= eval('"' + f[:string] + '"')
242 end
242 end
243 end
243 end
244
244
245 def active?
245 def active?
246 self.status == STATUS_ACTIVE
246 self.status == STATUS_ACTIVE
247 end
247 end
248
248
249 def registered?
249 def registered?
250 self.status == STATUS_REGISTERED
250 self.status == STATUS_REGISTERED
251 end
251 end
252
252
253 def locked?
253 def locked?
254 self.status == STATUS_LOCKED
254 self.status == STATUS_LOCKED
255 end
255 end
256
256
257 def activate
257 def activate
258 self.status = STATUS_ACTIVE
258 self.status = STATUS_ACTIVE
259 end
259 end
260
260
261 def register
261 def register
262 self.status = STATUS_REGISTERED
262 self.status = STATUS_REGISTERED
263 end
263 end
264
264
265 def lock
265 def lock
266 self.status = STATUS_LOCKED
266 self.status = STATUS_LOCKED
267 end
267 end
268
268
269 def activate!
269 def activate!
270 update_attribute(:status, STATUS_ACTIVE)
270 update_attribute(:status, STATUS_ACTIVE)
271 end
271 end
272
272
273 def register!
273 def register!
274 update_attribute(:status, STATUS_REGISTERED)
274 update_attribute(:status, STATUS_REGISTERED)
275 end
275 end
276
276
277 def lock!
277 def lock!
278 update_attribute(:status, STATUS_LOCKED)
278 update_attribute(:status, STATUS_LOCKED)
279 end
279 end
280
280
281 # Returns true if +clear_password+ is the correct user's password, otherwise false
281 # Returns true if +clear_password+ is the correct user's password, otherwise false
282 def check_password?(clear_password)
282 def check_password?(clear_password)
283 if auth_source_id.present?
283 if auth_source_id.present?
284 auth_source.authenticate(self.login, clear_password)
284 auth_source.authenticate(self.login, clear_password)
285 else
285 else
286 User.hash_password("#{salt}#{User.hash_password clear_password}") == hashed_password
286 User.hash_password("#{salt}#{User.hash_password clear_password}") == hashed_password
287 end
287 end
288 end
288 end
289
289
290 # Generates a random salt and computes hashed_password for +clear_password+
290 # Generates a random salt and computes hashed_password for +clear_password+
291 # The hashed password is stored in the following form: SHA1(salt + SHA1(password))
291 # The hashed password is stored in the following form: SHA1(salt + SHA1(password))
292 def salt_password(clear_password)
292 def salt_password(clear_password)
293 self.salt = User.generate_salt
293 self.salt = User.generate_salt
294 self.hashed_password = User.hash_password("#{salt}#{User.hash_password clear_password}")
294 self.hashed_password = User.hash_password("#{salt}#{User.hash_password clear_password}")
295 self.passwd_changed_on = Time.now
295 self.passwd_changed_on = Time.now
296 end
296 end
297
297
298 # Does the backend storage allow this user to change their password?
298 # Does the backend storage allow this user to change their password?
299 def change_password_allowed?
299 def change_password_allowed?
300 return true if auth_source.nil?
300 return true if auth_source.nil?
301 return auth_source.allow_password_changes?
301 return auth_source.allow_password_changes?
302 end
302 end
303
303
304 def must_change_password?
304 def must_change_password?
305 must_change_passwd? && change_password_allowed?
305 must_change_passwd? && change_password_allowed?
306 end
306 end
307
307
308 def generate_password?
308 def generate_password?
309 generate_password == '1' || generate_password == true
309 generate_password == '1' || generate_password == true
310 end
310 end
311
311
312 # Generate and set a random password on given length
312 # Generate and set a random password on given length
313 def random_password(length=40)
313 def random_password(length=40)
314 chars = ("a".."z").to_a + ("A".."Z").to_a + ("0".."9").to_a
314 chars = ("a".."z").to_a + ("A".."Z").to_a + ("0".."9").to_a
315 chars -= %w(0 O 1 l)
315 chars -= %w(0 O 1 l)
316 password = ''
316 password = ''
317 length.times {|i| password << chars[SecureRandom.random_number(chars.size)] }
317 length.times {|i| password << chars[SecureRandom.random_number(chars.size)] }
318 self.password = password
318 self.password = password
319 self.password_confirmation = password
319 self.password_confirmation = password
320 self
320 self
321 end
321 end
322
322
323 def pref
323 def pref
324 self.preference ||= UserPreference.new(:user => self)
324 self.preference ||= UserPreference.new(:user => self)
325 end
325 end
326
326
327 def time_zone
327 def time_zone
328 @time_zone ||= (self.pref.time_zone.blank? ? nil : ActiveSupport::TimeZone[self.pref.time_zone])
328 @time_zone ||= (self.pref.time_zone.blank? ? nil : ActiveSupport::TimeZone[self.pref.time_zone])
329 end
329 end
330
330
331 def force_default_language?
331 def force_default_language?
332 Setting.force_default_language_for_loggedin?
332 Setting.force_default_language_for_loggedin?
333 end
333 end
334
334
335 def language
335 def language
336 if force_default_language?
336 if force_default_language?
337 Setting.default_language
337 Setting.default_language
338 else
338 else
339 super
339 super
340 end
340 end
341 end
341 end
342
342
343 def wants_comments_in_reverse_order?
343 def wants_comments_in_reverse_order?
344 self.pref[:comments_sorting] == 'desc'
344 self.pref[:comments_sorting] == 'desc'
345 end
345 end
346
346
347 # Return user's RSS key (a 40 chars long string), used to access feeds
347 # Return user's RSS key (a 40 chars long string), used to access feeds
348 def rss_key
348 def rss_key
349 if rss_token.nil?
349 if rss_token.nil?
350 create_rss_token(:action => 'feeds')
350 create_rss_token(:action => 'feeds')
351 end
351 end
352 rss_token.value
352 rss_token.value
353 end
353 end
354
354
355 # Return user's API key (a 40 chars long string), used to access the API
355 # Return user's API key (a 40 chars long string), used to access the API
356 def api_key
356 def api_key
357 if api_token.nil?
357 if api_token.nil?
358 create_api_token(:action => 'api')
358 create_api_token(:action => 'api')
359 end
359 end
360 api_token.value
360 api_token.value
361 end
361 end
362
362
363 # Return an array of project ids for which the user has explicitly turned mail notifications on
363 # Return an array of project ids for which the user has explicitly turned mail notifications on
364 def notified_projects_ids
364 def notified_projects_ids
365 @notified_projects_ids ||= memberships.select {|m| m.mail_notification?}.collect(&:project_id)
365 @notified_projects_ids ||= memberships.select {|m| m.mail_notification?}.collect(&:project_id)
366 end
366 end
367
367
368 def notified_project_ids=(ids)
368 def notified_project_ids=(ids)
369 @notified_projects_ids_changed = true
369 @notified_projects_ids_changed = true
370 @notified_projects_ids = ids
370 @notified_projects_ids = ids
371 end
371 end
372
372
373 # Updates per project notifications (after_save callback)
373 # Updates per project notifications (after_save callback)
374 def update_notified_project_ids
374 def update_notified_project_ids
375 if @notified_projects_ids_changed
375 if @notified_projects_ids_changed
376 ids = (mail_notification == 'selected' ? Array.wrap(notified_projects_ids).reject(&:blank?) : [])
376 ids = (mail_notification == 'selected' ? Array.wrap(notified_projects_ids).reject(&:blank?) : [])
377 members.update_all(:mail_notification => false)
377 members.update_all(:mail_notification => false)
378 members.where(:project_id => ids).update_all(:mail_notification => true) if ids.any?
378 members.where(:project_id => ids).update_all(:mail_notification => true) if ids.any?
379 end
379 end
380 end
380 end
381 private :update_notified_project_ids
381 private :update_notified_project_ids
382
382
383 def valid_notification_options
383 def valid_notification_options
384 self.class.valid_notification_options(self)
384 self.class.valid_notification_options(self)
385 end
385 end
386
386
387 # Only users that belong to more than 1 project can select projects for which they are notified
387 # Only users that belong to more than 1 project can select projects for which they are notified
388 def self.valid_notification_options(user=nil)
388 def self.valid_notification_options(user=nil)
389 # Note that @user.membership.size would fail since AR ignores
389 # Note that @user.membership.size would fail since AR ignores
390 # :include association option when doing a count
390 # :include association option when doing a count
391 if user.nil? || user.memberships.length < 1
391 if user.nil? || user.memberships.length < 1
392 MAIL_NOTIFICATION_OPTIONS.reject {|option| option.first == 'selected'}
392 MAIL_NOTIFICATION_OPTIONS.reject {|option| option.first == 'selected'}
393 else
393 else
394 MAIL_NOTIFICATION_OPTIONS
394 MAIL_NOTIFICATION_OPTIONS
395 end
395 end
396 end
396 end
397
397
398 # Find a user account by matching the exact login and then a case-insensitive
398 # Find a user account by matching the exact login and then a case-insensitive
399 # version. Exact matches will be given priority.
399 # version. Exact matches will be given priority.
400 def self.find_by_login(login)
400 def self.find_by_login(login)
401 login = Redmine::CodesetUtil.replace_invalid_utf8(login.to_s)
401 login = Redmine::CodesetUtil.replace_invalid_utf8(login.to_s)
402 if login.present?
402 if login.present?
403 # First look for an exact match
403 # First look for an exact match
404 user = where(:login => login).detect {|u| u.login == login}
404 user = where(:login => login).detect {|u| u.login == login}
405 unless user
405 unless user
406 # Fail over to case-insensitive if none was found
406 # Fail over to case-insensitive if none was found
407 user = where("LOWER(login) = ?", login.downcase).first
407 user = where("LOWER(login) = ?", login.downcase).first
408 end
408 end
409 user
409 user
410 end
410 end
411 end
411 end
412
412
413 def self.find_by_rss_key(key)
413 def self.find_by_rss_key(key)
414 Token.find_active_user('feeds', key)
414 Token.find_active_user('feeds', key)
415 end
415 end
416
416
417 def self.find_by_api_key(key)
417 def self.find_by_api_key(key)
418 Token.find_active_user('api', key)
418 Token.find_active_user('api', key)
419 end
419 end
420
420
421 # Makes find_by_mail case-insensitive
421 # Makes find_by_mail case-insensitive
422 def self.find_by_mail(mail)
422 def self.find_by_mail(mail)
423 where("LOWER(mail) = ?", mail.to_s.downcase).first
423 where("LOWER(mail) = ?", mail.to_s.downcase).first
424 end
424 end
425
425
426 # Returns true if the default admin account can no longer be used
426 # Returns true if the default admin account can no longer be used
427 def self.default_admin_account_changed?
427 def self.default_admin_account_changed?
428 !User.active.find_by_login("admin").try(:check_password?, "admin")
428 !User.active.find_by_login("admin").try(:check_password?, "admin")
429 end
429 end
430
430
431 def to_s
431 def to_s
432 name
432 name
433 end
433 end
434
434
435 CSS_CLASS_BY_STATUS = {
435 CSS_CLASS_BY_STATUS = {
436 STATUS_ANONYMOUS => 'anon',
436 STATUS_ANONYMOUS => 'anon',
437 STATUS_ACTIVE => 'active',
437 STATUS_ACTIVE => 'active',
438 STATUS_REGISTERED => 'registered',
438 STATUS_REGISTERED => 'registered',
439 STATUS_LOCKED => 'locked'
439 STATUS_LOCKED => 'locked'
440 }
440 }
441
441
442 def css_classes
442 def css_classes
443 "user #{CSS_CLASS_BY_STATUS[status]}"
443 "user #{CSS_CLASS_BY_STATUS[status]}"
444 end
444 end
445
445
446 # Returns the current day according to user's time zone
446 # Returns the current day according to user's time zone
447 def today
447 def today
448 if time_zone.nil?
448 if time_zone.nil?
449 Date.today
449 Date.today
450 else
450 else
451 Time.now.in_time_zone(time_zone).to_date
451 Time.now.in_time_zone(time_zone).to_date
452 end
452 end
453 end
453 end
454
454
455 # Returns the day of +time+ according to user's time zone
455 # Returns the day of +time+ according to user's time zone
456 def time_to_date(time)
456 def time_to_date(time)
457 if time_zone.nil?
457 if time_zone.nil?
458 time.to_date
458 time.to_date
459 else
459 else
460 time.in_time_zone(time_zone).to_date
460 time.in_time_zone(time_zone).to_date
461 end
461 end
462 end
462 end
463
463
464 def logged?
464 def logged?
465 true
465 true
466 end
466 end
467
467
468 def anonymous?
468 def anonymous?
469 !logged?
469 !logged?
470 end
470 end
471
471
472 # Returns user's membership for the given project
472 # Returns user's membership for the given project
473 # or nil if the user is not a member of project
473 # or nil if the user is not a member of project
474 def membership(project)
474 def membership(project)
475 project_id = project.is_a?(Project) ? project.id : project
475 project_id = project.is_a?(Project) ? project.id : project
476
476
477 @membership_by_project_id ||= Hash.new {|h, project_id|
477 @membership_by_project_id ||= Hash.new {|h, project_id|
478 h[project_id] = memberships.where(:project_id => project_id).first
478 h[project_id] = memberships.where(:project_id => project_id).first
479 }
479 }
480 @membership_by_project_id[project_id]
480 @membership_by_project_id[project_id]
481 end
481 end
482
482
483 # Returns the user's bult-in role
483 # Returns the user's bult-in role
484 def builtin_role
484 def builtin_role
485 @builtin_role ||= Role.non_member
485 @builtin_role ||= Role.non_member
486 end
486 end
487
487
488 # Return user's roles for project
488 # Return user's roles for project
489 def roles_for_project(project)
489 def roles_for_project(project)
490 # No role on archived projects
490 # No role on archived projects
491 return [] if project.nil? || project.archived?
491 return [] if project.nil? || project.archived?
492 if membership = membership(project)
492 if membership = membership(project)
493 membership.roles.dup
493 membership.roles.dup
494 elsif project.is_public?
494 elsif project.is_public?
495 project.override_roles(builtin_role)
495 project.override_roles(builtin_role)
496 else
496 else
497 []
497 []
498 end
498 end
499 end
499 end
500
500
501 # Returns a hash of user's projects grouped by roles
501 # Returns a hash of user's projects grouped by roles
502 def projects_by_role
502 def projects_by_role
503 return @projects_by_role if @projects_by_role
503 return @projects_by_role if @projects_by_role
504
504
505 hash = Hash.new([])
505 hash = Hash.new([])
506
506
507 group_class = anonymous? ? GroupAnonymous : GroupNonMember
507 group_class = anonymous? ? GroupAnonymous : GroupNonMember
508 members = Member.joins(:project, :principal).
508 members = Member.joins(:project, :principal).
509 where("#{Project.table_name}.status <> 9").
509 where("#{Project.table_name}.status <> 9").
510 where("#{Member.table_name}.user_id = ? OR (#{Project.table_name}.is_public = ? AND #{Principal.table_name}.type = ?)", self.id, true, group_class.name).
510 where("#{Member.table_name}.user_id = ? OR (#{Project.table_name}.is_public = ? AND #{Principal.table_name}.type = ?)", self.id, true, group_class.name).
511 preload(:project, :roles).
511 preload(:project, :roles).
512 to_a
512 to_a
513
513
514 members.reject! {|member| member.user_id != id && project_ids.include?(member.project_id)}
514 members.reject! {|member| member.user_id != id && project_ids.include?(member.project_id)}
515 members.each do |member|
515 members.each do |member|
516 if member.project
516 if member.project
517 member.roles.each do |role|
517 member.roles.each do |role|
518 hash[role] = [] unless hash.key?(role)
518 hash[role] = [] unless hash.key?(role)
519 hash[role] << member.project
519 hash[role] << member.project
520 end
520 end
521 end
521 end
522 end
522 end
523
523
524 hash.each do |role, projects|
524 hash.each do |role, projects|
525 projects.uniq!
525 projects.uniq!
526 end
526 end
527
527
528 @projects_by_role = hash
528 @projects_by_role = hash
529 end
529 end
530
530
531 # Returns true if user is arg or belongs to arg
531 # Returns true if user is arg or belongs to arg
532 def is_or_belongs_to?(arg)
532 def is_or_belongs_to?(arg)
533 if arg.is_a?(User)
533 if arg.is_a?(User)
534 self == arg
534 self == arg
535 elsif arg.is_a?(Group)
535 elsif arg.is_a?(Group)
536 arg.users.include?(self)
536 arg.users.include?(self)
537 else
537 else
538 false
538 false
539 end
539 end
540 end
540 end
541
541
542 # Return true if the user is allowed to do the specified action on a specific context
542 # Return true if the user is allowed to do the specified action on a specific context
543 # Action can be:
543 # Action can be:
544 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
544 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
545 # * a permission Symbol (eg. :edit_project)
545 # * a permission Symbol (eg. :edit_project)
546 # Context can be:
546 # Context can be:
547 # * a project : returns true if user is allowed to do the specified action on this project
547 # * a project : returns true if user is allowed to do the specified action on this project
548 # * an array of projects : returns true if user is allowed on every project
548 # * an array of projects : returns true if user is allowed on every project
549 # * nil with options[:global] set : check if user has at least one role allowed for this action,
549 # * nil with options[:global] set : check if user has at least one role allowed for this action,
550 # or falls back to Non Member / Anonymous permissions depending if the user is logged
550 # or falls back to Non Member / Anonymous permissions depending if the user is logged
551 def allowed_to?(action, context, options={}, &block)
551 def allowed_to?(action, context, options={}, &block)
552 if context && context.is_a?(Project)
552 if context && context.is_a?(Project)
553 return false unless context.allows_to?(action)
553 return false unless context.allows_to?(action)
554 # Admin users are authorized for anything else
554 # Admin users are authorized for anything else
555 return true if admin?
555 return true if admin?
556
556
557 roles = roles_for_project(context)
557 roles = roles_for_project(context)
558 return false unless roles
558 return false unless roles
559 roles.any? {|role|
559 roles.any? {|role|
560 (context.is_public? || role.member?) &&
560 (context.is_public? || role.member?) &&
561 role.allowed_to?(action) &&
561 role.allowed_to?(action) &&
562 (block_given? ? yield(role, self) : true)
562 (block_given? ? yield(role, self) : true)
563 }
563 }
564 elsif context && context.is_a?(Array)
564 elsif context && context.is_a?(Array)
565 if context.empty?
565 if context.empty?
566 false
566 false
567 else
567 else
568 # Authorize if user is authorized on every element of the array
568 # Authorize if user is authorized on every element of the array
569 context.map {|project| allowed_to?(action, project, options, &block)}.reduce(:&)
569 context.map {|project| allowed_to?(action, project, options, &block)}.reduce(:&)
570 end
570 end
571 elsif context
571 elsif context
572 raise ArgumentError.new("#allowed_to? context argument must be a Project, an Array of projects or nil")
572 raise ArgumentError.new("#allowed_to? context argument must be a Project, an Array of projects or nil")
573 elsif options[:global]
573 elsif options[:global]
574 # Admin users are always authorized
574 # Admin users are always authorized
575 return true if admin?
575 return true if admin?
576
576
577 # authorize if user has at least one role that has this permission
577 # authorize if user has at least one role that has this permission
578 roles = memberships.collect {|m| m.roles}.flatten.uniq
578 roles = memberships.collect {|m| m.roles}.flatten.uniq
579 roles << (self.logged? ? Role.non_member : Role.anonymous)
579 roles << (self.logged? ? Role.non_member : Role.anonymous)
580 roles.any? {|role|
580 roles.any? {|role|
581 role.allowed_to?(action) &&
581 role.allowed_to?(action) &&
582 (block_given? ? yield(role, self) : true)
582 (block_given? ? yield(role, self) : true)
583 }
583 }
584 else
584 else
585 false
585 false
586 end
586 end
587 end
587 end
588
588
589 # Is the user allowed to do the specified action on any project?
589 # Is the user allowed to do the specified action on any project?
590 # See allowed_to? for the actions and valid options.
590 # See allowed_to? for the actions and valid options.
591 #
591 #
592 # NB: this method is not used anywhere in the core codebase as of
592 # NB: this method is not used anywhere in the core codebase as of
593 # 2.5.2, but it's used by many plugins so if we ever want to remove
593 # 2.5.2, but it's used by many plugins so if we ever want to remove
594 # it it has to be carefully deprecated for a version or two.
594 # it it has to be carefully deprecated for a version or two.
595 def allowed_to_globally?(action, options={}, &block)
595 def allowed_to_globally?(action, options={}, &block)
596 allowed_to?(action, nil, options.reverse_merge(:global => true), &block)
596 allowed_to?(action, nil, options.reverse_merge(:global => true), &block)
597 end
597 end
598
598
599 # Returns true if the user is allowed to delete the user's own account
599 # Returns true if the user is allowed to delete the user's own account
600 def own_account_deletable?
600 def own_account_deletable?
601 Setting.unsubscribe? &&
601 Setting.unsubscribe? &&
602 (!admin? || User.active.where("admin = ? AND id <> ?", true, id).exists?)
602 (!admin? || User.active.where("admin = ? AND id <> ?", true, id).exists?)
603 end
603 end
604
604
605 safe_attributes 'login',
605 safe_attributes 'login',
606 'firstname',
606 'firstname',
607 'lastname',
607 'lastname',
608 'mail',
608 'mail',
609 'mail_notification',
609 'mail_notification',
610 'notified_project_ids',
610 'notified_project_ids',
611 'language',
611 'language',
612 'custom_field_values',
612 'custom_field_values',
613 'custom_fields',
613 'custom_fields',
614 'identity_url'
614 'identity_url'
615
615
616 safe_attributes 'status',
616 safe_attributes 'status',
617 'auth_source_id',
617 'auth_source_id',
618 'generate_password',
618 'generate_password',
619 'must_change_passwd',
619 'must_change_passwd',
620 :if => lambda {|user, current_user| current_user.admin?}
620 :if => lambda {|user, current_user| current_user.admin?}
621
621
622 safe_attributes 'group_ids',
622 safe_attributes 'group_ids',
623 :if => lambda {|user, current_user| current_user.admin? && !user.new_record?}
623 :if => lambda {|user, current_user| current_user.admin? && !user.new_record?}
624
624
625 # Utility method to help check if a user should be notified about an
625 # Utility method to help check if a user should be notified about an
626 # event.
626 # event.
627 #
627 #
628 # TODO: only supports Issue events currently
628 # TODO: only supports Issue events currently
629 def notify_about?(object)
629 def notify_about?(object)
630 if mail_notification == 'all'
630 if mail_notification == 'all'
631 true
631 true
632 elsif mail_notification.blank? || mail_notification == 'none'
632 elsif mail_notification.blank? || mail_notification == 'none'
633 false
633 false
634 else
634 else
635 case object
635 case object
636 when Issue
636 when Issue
637 case mail_notification
637 case mail_notification
638 when 'selected', 'only_my_events'
638 when 'selected', 'only_my_events'
639 # user receives notifications for created/assigned issues on unselected projects
639 # user receives notifications for created/assigned issues on unselected projects
640 object.author == self || is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.assigned_to_was)
640 object.author == self || is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.assigned_to_was)
641 when 'only_assigned'
641 when 'only_assigned'
642 is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.assigned_to_was)
642 is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.assigned_to_was)
643 when 'only_owner'
643 when 'only_owner'
644 object.author == self
644 object.author == self
645 end
645 end
646 when News
646 when News
647 # always send to project members except when mail_notification is set to 'none'
647 # always send to project members except when mail_notification is set to 'none'
648 true
648 true
649 end
649 end
650 end
650 end
651 end
651 end
652
652
653 def self.current=(user)
653 def self.current=(user)
654 RequestStore.store[:current_user] = user
654 RequestStore.store[:current_user] = user
655 end
655 end
656
656
657 def self.current
657 def self.current
658 RequestStore.store[:current_user] ||= User.anonymous
658 RequestStore.store[:current_user] ||= User.anonymous
659 end
659 end
660
660
661 # Returns the anonymous user. If the anonymous user does not exist, it is created. There can be only
661 # Returns the anonymous user. If the anonymous user does not exist, it is created. There can be only
662 # one anonymous user per database.
662 # one anonymous user per database.
663 def self.anonymous
663 def self.anonymous
664 anonymous_user = AnonymousUser.first
664 anonymous_user = AnonymousUser.first
665 if anonymous_user.nil?
665 if anonymous_user.nil?
666 anonymous_user = AnonymousUser.create(:lastname => 'Anonymous', :firstname => '', :mail => '', :login => '', :status => 0)
666 anonymous_user = AnonymousUser.create(:lastname => 'Anonymous', :firstname => '', :mail => '', :login => '', :status => 0)
667 raise 'Unable to create the anonymous user.' if anonymous_user.new_record?
667 raise 'Unable to create the anonymous user.' if anonymous_user.new_record?
668 end
668 end
669 anonymous_user
669 anonymous_user
670 end
670 end
671
671
672 # Salts all existing unsalted passwords
672 # Salts all existing unsalted passwords
673 # It changes password storage scheme from SHA1(password) to SHA1(salt + SHA1(password))
673 # It changes password storage scheme from SHA1(password) to SHA1(salt + SHA1(password))
674 # This method is used in the SaltPasswords migration and is to be kept as is
674 # This method is used in the SaltPasswords migration and is to be kept as is
675 def self.salt_unsalted_passwords!
675 def self.salt_unsalted_passwords!
676 transaction do
676 transaction do
677 User.where("salt IS NULL OR salt = ''").find_each do |user|
677 User.where("salt IS NULL OR salt = ''").find_each do |user|
678 next if user.hashed_password.blank?
678 next if user.hashed_password.blank?
679 salt = User.generate_salt
679 salt = User.generate_salt
680 hashed_password = User.hash_password("#{salt}#{user.hashed_password}")
680 hashed_password = User.hash_password("#{salt}#{user.hashed_password}")
681 User.where(:id => user.id).update_all(:salt => salt, :hashed_password => hashed_password)
681 User.where(:id => user.id).update_all(:salt => salt, :hashed_password => hashed_password)
682 end
682 end
683 end
683 end
684 end
684 end
685
685
686 protected
686 protected
687
687
688 def validate_password_length
688 def validate_password_length
689 return if password.blank? && generate_password?
689 return if password.blank? && generate_password?
690 # Password length validation based on setting
690 # Password length validation based on setting
691 if !password.nil? && password.size < Setting.password_min_length.to_i
691 if !password.nil? && password.size < Setting.password_min_length.to_i
692 errors.add(:password, :too_short, :count => Setting.password_min_length.to_i)
692 errors.add(:password, :too_short, :count => Setting.password_min_length.to_i)
693 end
693 end
694 end
694 end
695
695
696 private
696 private
697
697
698 def generate_password_if_needed
698 def generate_password_if_needed
699 if generate_password? && auth_source.nil?
699 if generate_password? && auth_source.nil?
700 length = [Setting.password_min_length.to_i + 2, 10].max
700 length = [Setting.password_min_length.to_i + 2, 10].max
701 random_password(length)
701 random_password(length)
702 end
702 end
703 end
703 end
704
704
705 # Delete all outstanding password reset tokens on password or email change.
705 # Delete all outstanding password reset tokens on password or email change.
706 # Delete the autologin tokens on password change to prohibit session leakage.
706 # Delete the autologin tokens on password change to prohibit session leakage.
707 # This helps to keep the account secure in case the associated email account
707 # This helps to keep the account secure in case the associated email account
708 # was compromised.
708 # was compromised.
709 def destroy_tokens
709 def destroy_tokens
710 tokens = []
710 tokens = []
711 tokens |= ['recovery', 'autologin'] if hashed_password_changed?
711 tokens |= ['recovery', 'autologin'] if hashed_password_changed?
712 tokens |= ['recovery'] if mail_changed?
712 tokens |= ['recovery'] if mail_changed?
713
713
714 if tokens.any?
714 if tokens.any?
715 Token.where(:user_id => id, :action => tokens).delete_all
715 Token.where(:user_id => id, :action => tokens).delete_all
716 end
716 end
717 end
717 end
718
718
719 # Removes references that are not handled by associations
719 # Removes references that are not handled by associations
720 # Things that are not deleted are reassociated with the anonymous user
720 # Things that are not deleted are reassociated with the anonymous user
721 def remove_references_before_destroy
721 def remove_references_before_destroy
722 return if self.id.nil?
722 return if self.id.nil?
723
723
724 substitute = User.anonymous
724 substitute = User.anonymous
725 Attachment.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
725 Attachment.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
726 Comment.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
726 Comment.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
727 Issue.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
727 Issue.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
728 Issue.where(['assigned_to_id = ?', id]).update_all('assigned_to_id = NULL')
728 Issue.where(['assigned_to_id = ?', id]).update_all('assigned_to_id = NULL')
729 Journal.where(['user_id = ?', id]).update_all(['user_id = ?', substitute.id])
729 Journal.where(['user_id = ?', id]).update_all(['user_id = ?', substitute.id])
730 JournalDetail.
730 JournalDetail.
731 where(["property = 'attr' AND prop_key = 'assigned_to_id' AND old_value = ?", id.to_s]).
731 where(["property = 'attr' AND prop_key = 'assigned_to_id' AND old_value = ?", id.to_s]).
732 update_all(['old_value = ?', substitute.id.to_s])
732 update_all(['old_value = ?', substitute.id.to_s])
733 JournalDetail.
733 JournalDetail.
734 where(["property = 'attr' AND prop_key = 'assigned_to_id' AND value = ?", id.to_s]).
734 where(["property = 'attr' AND prop_key = 'assigned_to_id' AND value = ?", id.to_s]).
735 update_all(['value = ?', substitute.id.to_s])
735 update_all(['value = ?', substitute.id.to_s])
736 Message.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
736 Message.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
737 News.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
737 News.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
738 # Remove private queries and keep public ones
738 # Remove private queries and keep public ones
739 ::Query.delete_all ['user_id = ? AND visibility = ?', id, ::Query::VISIBILITY_PRIVATE]
739 ::Query.delete_all ['user_id = ? AND visibility = ?', id, ::Query::VISIBILITY_PRIVATE]
740 ::Query.where(['user_id = ?', id]).update_all(['user_id = ?', substitute.id])
740 ::Query.where(['user_id = ?', id]).update_all(['user_id = ?', substitute.id])
741 TimeEntry.where(['user_id = ?', id]).update_all(['user_id = ?', substitute.id])
741 TimeEntry.where(['user_id = ?', id]).update_all(['user_id = ?', substitute.id])
742 Token.delete_all ['user_id = ?', id]
742 Token.delete_all ['user_id = ?', id]
743 Watcher.delete_all ['user_id = ?', id]
743 Watcher.delete_all ['user_id = ?', id]
744 WikiContent.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
744 WikiContent.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
745 WikiContent::Version.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
745 WikiContent::Version.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
746 end
746 end
747
747
748 # Return password digest
748 # Return password digest
749 def self.hash_password(clear_password)
749 def self.hash_password(clear_password)
750 Digest::SHA1.hexdigest(clear_password || "")
750 Digest::SHA1.hexdigest(clear_password || "")
751 end
751 end
752
752
753 # Returns a 128bits random salt as a hex string (32 chars long)
753 # Returns a 128bits random salt as a hex string (32 chars long)
754 def self.generate_salt
754 def self.generate_salt
755 Redmine::Utils.random_hex(16)
755 Redmine::Utils.random_hex(16)
756 end
756 end
757
757
758 end
758 end
759
759
760 class AnonymousUser < User
760 class AnonymousUser < User
761 validate :validate_anonymous_uniqueness, :on => :create
761 validate :validate_anonymous_uniqueness, :on => :create
762
762
763 def validate_anonymous_uniqueness
763 def validate_anonymous_uniqueness
764 # There should be only one AnonymousUser in the database
764 # There should be only one AnonymousUser in the database
765 errors.add :base, 'An anonymous user already exists.' if AnonymousUser.exists?
765 errors.add :base, 'An anonymous user already exists.' if AnonymousUser.exists?
766 end
766 end
767
767
768 def available_custom_fields
768 def available_custom_fields
769 []
769 []
770 end
770 end
771
771
772 # Overrides a few properties
772 # Overrides a few properties
773 def logged?; false end
773 def logged?; false end
774 def admin; false end
774 def admin; false end
775 def name(*args); I18n.t(:label_user_anonymous) end
775 def name(*args); I18n.t(:label_user_anonymous) end
776 def mail; nil end
776 def mail; nil end
777 def time_zone; nil end
777 def time_zone; nil end
778 def rss_key; nil end
778 def rss_key; nil end
779
779
780 def pref
780 def pref
781 UserPreference.new(:user => self)
781 UserPreference.new(:user => self)
782 end
782 end
783
783
784 # Returns the user's bult-in role
784 # Returns the user's bult-in role
785 def builtin_role
785 def builtin_role
786 @builtin_role ||= Role.anonymous
786 @builtin_role ||= Role.anonymous
787 end
787 end
788
788
789 def membership(*args)
789 def membership(*args)
790 nil
790 nil
791 end
791 end
792
792
793 def member_of?(*args)
793 def member_of?(*args)
794 false
794 false
795 end
795 end
796
796
797 # Anonymous user can not be destroyed
797 # Anonymous user can not be destroyed
798 def destroy
798 def destroy
799 false
799 false
800 end
800 end
801 end
801 end
@@ -1,284 +1,289
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2014 Jean-Philippe Lang
2 # Copyright (C) 2006-2014 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 :effective_date, :date => true
33 validates :effective_date, :date => 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 attr_protected :id
36 attr_protected :id
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 joins(:project).
41 joins(:project).
42 where(Project.allowed_to_condition(args.first || User.current, :view_issues))
42 where(Project.allowed_to_condition(args.first || User.current, :view_issues))
43 }
43 }
44
44
45 safe_attributes 'name',
45 safe_attributes 'name',
46 'description',
46 'description',
47 'effective_date',
47 'effective_date',
48 'due_date',
48 'due_date',
49 'wiki_page_title',
49 'wiki_page_title',
50 'status',
50 'status',
51 'sharing',
51 'sharing',
52 'custom_field_values',
52 'custom_field_values',
53 'custom_fields'
53 'custom_fields'
54
54
55 # Returns true if +user+ or current user is allowed to view the version
55 # Returns true if +user+ or current user is allowed to view the version
56 def visible?(user=User.current)
56 def visible?(user=User.current)
57 user.allowed_to?(:view_issues, self.project)
57 user.allowed_to?(:view_issues, self.project)
58 end
58 end
59
59
60 # Version files have same visibility as project files
60 # Version files have same visibility as project files
61 def attachments_visible?(*args)
61 def attachments_visible?(*args)
62 project.present? && project.attachments_visible?(*args)
62 project.present? && project.attachments_visible?(*args)
63 end
63 end
64
64
65 def attachments_deletable?(usr=User.current)
65 def attachments_deletable?(usr=User.current)
66 project.present? && project.attachments_deletable?(usr)
66 project.present? && project.attachments_deletable?(usr)
67 end
67 end
68
68
69 def start_date
69 def start_date
70 @start_date ||= fixed_issues.minimum('start_date')
70 @start_date ||= fixed_issues.minimum('start_date')
71 end
71 end
72
72
73 def due_date
73 def due_date
74 effective_date
74 effective_date
75 end
75 end
76
76
77 def due_date=(arg)
77 def due_date=(arg)
78 self.effective_date=(arg)
78 self.effective_date=(arg)
79 end
79 end
80
80
81 # Returns the total estimated time for this version
81 # Returns the total estimated time for this version
82 # (sum of leaves estimated_hours)
82 # (sum of leaves estimated_hours)
83 def estimated_hours
83 def estimated_hours
84 @estimated_hours ||= fixed_issues.leaves.sum(:estimated_hours).to_f
84 @estimated_hours ||= fixed_issues.leaves.sum(:estimated_hours).to_f
85 end
85 end
86
86
87 # Returns the total reported time for this version
87 # Returns the total reported time for this version
88 def spent_hours
88 def spent_hours
89 @spent_hours ||= TimeEntry.joins(:issue).where("#{Issue.table_name}.fixed_version_id = ?", id).sum(:hours).to_f
89 @spent_hours ||= TimeEntry.joins(:issue).where("#{Issue.table_name}.fixed_version_id = ?", id).sum(:hours).to_f
90 end
90 end
91
91
92 def closed?
92 def closed?
93 status == 'closed'
93 status == 'closed'
94 end
94 end
95
95
96 def open?
96 def open?
97 status == 'open'
97 status == 'open'
98 end
98 end
99
99
100 # Returns true if the version is completed: due date reached and no open issues
100 # Returns true if the version is completed: due date reached and no open issues
101 def completed?
101 def completed?
102 effective_date && (effective_date < Date.today) && (open_issues_count == 0)
102 effective_date && (effective_date < Date.today) && (open_issues_count == 0)
103 end
103 end
104
104
105 def behind_schedule?
105 def behind_schedule?
106 if completed_percent == 100
106 if completed_percent == 100
107 return false
107 return false
108 elsif due_date && start_date
108 elsif due_date && start_date
109 done_date = start_date + ((due_date - start_date+1)* completed_percent/100).floor
109 done_date = start_date + ((due_date - start_date+1)* completed_percent/100).floor
110 return done_date <= Date.today
110 return done_date <= Date.today
111 else
111 else
112 false # No issues so it's not late
112 false # No issues so it's not late
113 end
113 end
114 end
114 end
115
115
116 # Returns the completion percentage of this version based on the amount of open/closed issues
116 # Returns the completion percentage of this version based on the amount of open/closed issues
117 # and the time spent on the open issues.
117 # and the time spent on the open issues.
118 def completed_percent
118 def completed_percent
119 if issues_count == 0
119 if issues_count == 0
120 0
120 0
121 elsif open_issues_count == 0
121 elsif open_issues_count == 0
122 100
122 100
123 else
123 else
124 issues_progress(false) + issues_progress(true)
124 issues_progress(false) + issues_progress(true)
125 end
125 end
126 end
126 end
127
127
128 # Returns the percentage of issues that have been marked as 'closed'.
128 # Returns the percentage of issues that have been marked as 'closed'.
129 def closed_percent
129 def closed_percent
130 if issues_count == 0
130 if issues_count == 0
131 0
131 0
132 else
132 else
133 issues_progress(false)
133 issues_progress(false)
134 end
134 end
135 end
135 end
136
136
137 # Returns true if the version is overdue: due date reached and some open issues
137 # Returns true if the version is overdue: due date reached and some open issues
138 def overdue?
138 def overdue?
139 effective_date && (effective_date < Date.today) && (open_issues_count > 0)
139 effective_date && (effective_date < Date.today) && (open_issues_count > 0)
140 end
140 end
141
141
142 # Returns assigned issues count
142 # Returns assigned issues count
143 def issues_count
143 def issues_count
144 load_issue_counts
144 load_issue_counts
145 @issue_count
145 @issue_count
146 end
146 end
147
147
148 # Returns the total amount of open issues for this version.
148 # Returns the total amount of open issues for this version.
149 def open_issues_count
149 def open_issues_count
150 load_issue_counts
150 load_issue_counts
151 @open_issues_count
151 @open_issues_count
152 end
152 end
153
153
154 # Returns the total amount of closed issues for this version.
154 # Returns the total amount of closed issues for this version.
155 def closed_issues_count
155 def closed_issues_count
156 load_issue_counts
156 load_issue_counts
157 @closed_issues_count
157 @closed_issues_count
158 end
158 end
159
159
160 def wiki_page
160 def wiki_page
161 if project.wiki && !wiki_page_title.blank?
161 if project.wiki && !wiki_page_title.blank?
162 @wiki_page ||= project.wiki.find_page(wiki_page_title)
162 @wiki_page ||= project.wiki.find_page(wiki_page_title)
163 end
163 end
164 @wiki_page
164 @wiki_page
165 end
165 end
166
166
167 def to_s; name end
167 def to_s; name end
168
168
169 def to_s_with_project
169 def to_s_with_project
170 "#{project} - #{name}"
170 "#{project} - #{name}"
171 end
171 end
172
172
173 # Versions are sorted by effective_date and name
173 # Versions are sorted by effective_date and name
174 # Those with no effective_date are at the end, sorted by name
174 # Those with no effective_date are at the end, sorted by name
175 def <=>(version)
175 def <=>(version)
176 if self.effective_date
176 if self.effective_date
177 if version.effective_date
177 if version.effective_date
178 if self.effective_date == version.effective_date
178 if self.effective_date == version.effective_date
179 name == version.name ? id <=> version.id : name <=> version.name
179 name == version.name ? id <=> version.id : name <=> version.name
180 else
180 else
181 self.effective_date <=> version.effective_date
181 self.effective_date <=> version.effective_date
182 end
182 end
183 else
183 else
184 -1
184 -1
185 end
185 end
186 else
186 else
187 if version.effective_date
187 if version.effective_date
188 1
188 1
189 else
189 else
190 name == version.name ? id <=> version.id : name <=> version.name
190 name == version.name ? id <=> version.id : name <=> version.name
191 end
191 end
192 end
192 end
193 end
193 end
194
194
195 def self.fields_for_order_statement(table=nil)
195 def self.fields_for_order_statement(table=nil)
196 table ||= table_name
196 table ||= table_name
197 ["(CASE WHEN #{table}.effective_date IS NULL THEN 1 ELSE 0 END)", "#{table}.effective_date", "#{table}.name", "#{table}.id"]
197 ["(CASE WHEN #{table}.effective_date IS NULL THEN 1 ELSE 0 END)", "#{table}.effective_date", "#{table}.name", "#{table}.id"]
198 end
198 end
199
199
200 scope :sorted, lambda { order(fields_for_order_statement) }
200 scope :sorted, lambda { order(fields_for_order_statement) }
201
201
202 # Returns the sharings that +user+ can set the version to
202 # Returns the sharings that +user+ can set the version to
203 def allowed_sharings(user = User.current)
203 def allowed_sharings(user = User.current)
204 VERSION_SHARINGS.select do |s|
204 VERSION_SHARINGS.select do |s|
205 if sharing == s
205 if sharing == s
206 true
206 true
207 else
207 else
208 case s
208 case s
209 when 'system'
209 when 'system'
210 # Only admin users can set a systemwide sharing
210 # Only admin users can set a systemwide sharing
211 user.admin?
211 user.admin?
212 when 'hierarchy', 'tree'
212 when 'hierarchy', 'tree'
213 # Only users allowed to manage versions of the root project can
213 # Only users allowed to manage versions of the root project can
214 # set sharing to hierarchy or tree
214 # set sharing to hierarchy or tree
215 project.nil? || user.allowed_to?(:manage_versions, project.root)
215 project.nil? || user.allowed_to?(:manage_versions, project.root)
216 else
216 else
217 true
217 true
218 end
218 end
219 end
219 end
220 end
220 end
221 end
221 end
222
222
223 # Returns true if the version is shared, otherwise false
224 def shared?
225 sharing != 'none'
226 end
227
223 private
228 private
224
229
225 def load_issue_counts
230 def load_issue_counts
226 unless @issue_count
231 unless @issue_count
227 @open_issues_count = 0
232 @open_issues_count = 0
228 @closed_issues_count = 0
233 @closed_issues_count = 0
229 fixed_issues.group(:status).count.each do |status, count|
234 fixed_issues.group(:status).count.each do |status, count|
230 if status.is_closed?
235 if status.is_closed?
231 @closed_issues_count += count
236 @closed_issues_count += count
232 else
237 else
233 @open_issues_count += count
238 @open_issues_count += count
234 end
239 end
235 end
240 end
236 @issue_count = @open_issues_count + @closed_issues_count
241 @issue_count = @open_issues_count + @closed_issues_count
237 end
242 end
238 end
243 end
239
244
240 # Update the issue's fixed versions. Used if a version's sharing changes.
245 # Update the issue's fixed versions. Used if a version's sharing changes.
241 def update_issues_from_sharing_change
246 def update_issues_from_sharing_change
242 if sharing_changed?
247 if sharing_changed?
243 if VERSION_SHARINGS.index(sharing_was).nil? ||
248 if VERSION_SHARINGS.index(sharing_was).nil? ||
244 VERSION_SHARINGS.index(sharing).nil? ||
249 VERSION_SHARINGS.index(sharing).nil? ||
245 VERSION_SHARINGS.index(sharing_was) > VERSION_SHARINGS.index(sharing)
250 VERSION_SHARINGS.index(sharing_was) > VERSION_SHARINGS.index(sharing)
246 Issue.update_versions_from_sharing_change self
251 Issue.update_versions_from_sharing_change self
247 end
252 end
248 end
253 end
249 end
254 end
250
255
251 # Returns the average estimated time of assigned issues
256 # Returns the average estimated time of assigned issues
252 # or 1 if no issue has an estimated time
257 # or 1 if no issue has an estimated time
253 # Used to weight unestimated issues in progress calculation
258 # Used to weight unestimated issues in progress calculation
254 def estimated_average
259 def estimated_average
255 if @estimated_average.nil?
260 if @estimated_average.nil?
256 average = fixed_issues.average(:estimated_hours).to_f
261 average = fixed_issues.average(:estimated_hours).to_f
257 if average == 0
262 if average == 0
258 average = 1
263 average = 1
259 end
264 end
260 @estimated_average = average
265 @estimated_average = average
261 end
266 end
262 @estimated_average
267 @estimated_average
263 end
268 end
264
269
265 # Returns the total progress of open or closed issues. The returned percentage takes into account
270 # Returns the total progress of open or closed issues. The returned percentage takes into account
266 # the amount of estimated time set for this version.
271 # the amount of estimated time set for this version.
267 #
272 #
268 # Examples:
273 # Examples:
269 # issues_progress(true) => returns the progress percentage for open issues.
274 # issues_progress(true) => returns the progress percentage for open issues.
270 # issues_progress(false) => returns the progress percentage for closed issues.
275 # issues_progress(false) => returns the progress percentage for closed issues.
271 def issues_progress(open)
276 def issues_progress(open)
272 @issues_progress ||= {}
277 @issues_progress ||= {}
273 @issues_progress[open] ||= begin
278 @issues_progress[open] ||= begin
274 progress = 0
279 progress = 0
275 if issues_count > 0
280 if issues_count > 0
276 ratio = open ? 'done_ratio' : 100
281 ratio = open ? 'done_ratio' : 100
277
282
278 done = fixed_issues.open(open).sum("COALESCE(estimated_hours, #{estimated_average}) * #{ratio}").to_f
283 done = fixed_issues.open(open).sum("COALESCE(estimated_hours, #{estimated_average}) * #{ratio}").to_f
279 progress = done / (estimated_average * issues_count)
284 progress = done / (estimated_average * issues_count)
280 end
285 end
281 progress
286 progress
282 end
287 end
283 end
288 end
284 end
289 end
@@ -1,26 +1,26
1 <div class="tabs">
1 <div class="tabs">
2 <ul>
2 <ul>
3 <% tabs.each do |tab| -%>
3 <% tabs.each do |tab| -%>
4 <li><%= link_to l(tab[:label]), { :tab => tab[:name] },
4 <li><%= link_to l(tab[:label]), { :tab => tab[:name] },
5 :id => "tab-#{tab[:name]}",
5 :id => "tab-#{tab[:name]}",
6 :class => (tab[:name] != selected_tab ? nil : 'selected'),
6 :class => (tab[:name] != selected_tab ? nil : 'selected'),
7 :onclick => "showTab('#{tab[:name]}', this.href); this.blur(); return false;" %></li>
7 :onclick => "showTab('#{tab[:name]}', this.href); this.blur(); return false;" %></li>
8 <% end -%>
8 <% end -%>
9 </ul>
9 </ul>
10 <div class="tabs-buttons" style="display:none;">
10 <div class="tabs-buttons" style="display:none;">
11 <button class="tab-left" onclick="moveTabLeft(this); return false;"></button>
11 <button class="tab-left" type="button" onclick="moveTabLeft(this);"></button>
12 <button class="tab-right" onclick="moveTabRight(this); return false;"></button>
12 <button class="tab-right" type="button" onclick="moveTabRight(this);"></button>
13 </div>
13 </div>
14 </div>
14 </div>
15
15
16 <script>
16 <script>
17 $(document).ready(displayTabsButtons);
17 $(document).ready(displayTabsButtons);
18 $(window).resize(displayTabsButtons);
18 $(window).resize(displayTabsButtons);
19 </script>
19 </script>
20
20
21 <% tabs.each do |tab| -%>
21 <% tabs.each do |tab| -%>
22 <%= content_tag('div', render(:partial => tab[:partial], :locals => {:tab => tab} ),
22 <%= content_tag('div', render(:partial => tab[:partial], :locals => {:tab => tab} ),
23 :id => "tab-content-#{tab[:name]}",
23 :id => "tab-content-#{tab[:name]}",
24 :style => (tab[:name] != selected_tab ? 'display:none' : nil),
24 :style => (tab[:name] != selected_tab ? 'display:none' : nil),
25 :class => 'tab-content') %>
25 :class => 'tab-content') %>
26 <% end -%>
26 <% end -%>
@@ -1,24 +1,30
1 I18n.default_locale = 'en'
1 I18n.default_locale = 'en'
2 I18n.backend = Redmine::I18n::Backend.new
2 I18n.backend = Redmine::I18n::Backend.new
3 # Forces I18n to load available locales from the backend
3 # Forces I18n to load available locales from the backend
4 I18n.config.available_locales = nil
4 I18n.config.available_locales = nil
5
5
6 require 'redmine'
6 require 'redmine'
7
7
8 # Load the secret token from the Redmine configuration file
8 # Load the secret token from the Redmine configuration file
9 secret = Redmine::Configuration['secret_token']
9 secret = Redmine::Configuration['secret_token']
10 if secret.present?
10 if secret.present?
11 RedmineApp::Application.config.secret_token = secret
11 RedmineApp::Application.config.secret_token = secret
12 end
12 end
13
13
14 if Object.const_defined?(:OpenIdAuthentication)
14 if Object.const_defined?(:OpenIdAuthentication)
15 openid_authentication_store = Redmine::Configuration['openid_authentication_store']
15 openid_authentication_store = Redmine::Configuration['openid_authentication_store']
16 OpenIdAuthentication.store =
16 OpenIdAuthentication.store =
17 openid_authentication_store.present? ?
17 openid_authentication_store.present? ?
18 openid_authentication_store : :memory
18 openid_authentication_store : :memory
19 end
19 end
20
20
21 Redmine::Plugin.load
21 Redmine::Plugin.load
22 unless Redmine::Configuration['mirror_plugins_assets_on_startup'] == false
22 unless Redmine::Configuration['mirror_plugins_assets_on_startup'] == false
23 Redmine::Plugin.mirror_assets
23 Redmine::Plugin.mirror_assets
24 end
24 end
25
26 Rails.application.config.to_prepare do
27 Redmine::FieldFormat::RecordList.subclasses.each do |klass|
28 klass.instance.reset_target_class
29 end
30 end No newline at end of file
1 NO CONTENT: modified file
NO CONTENT: modified file
The requested commit or file is too big and content was truncated. Show full diff
@@ -1,706 +1,710
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2014 Jean-Philippe Lang
2 # Copyright (C) 2006-2014 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 FieldFormat
19 module FieldFormat
20 def self.add(name, klass)
20 def self.add(name, klass)
21 all[name.to_s] = klass.instance
21 all[name.to_s] = klass.instance
22 end
22 end
23
23
24 def self.delete(name)
24 def self.delete(name)
25 all.delete(name.to_s)
25 all.delete(name.to_s)
26 end
26 end
27
27
28 def self.all
28 def self.all
29 @formats ||= Hash.new(Base.instance)
29 @formats ||= Hash.new(Base.instance)
30 end
30 end
31
31
32 def self.available_formats
32 def self.available_formats
33 all.keys
33 all.keys
34 end
34 end
35
35
36 def self.find(name)
36 def self.find(name)
37 all[name.to_s]
37 all[name.to_s]
38 end
38 end
39
39
40 # Return an array of custom field formats which can be used in select_tag
40 # Return an array of custom field formats which can be used in select_tag
41 def self.as_select(class_name=nil)
41 def self.as_select(class_name=nil)
42 formats = all.values.select do |format|
42 formats = all.values.select do |format|
43 format.class.customized_class_names.nil? || format.class.customized_class_names.include?(class_name)
43 format.class.customized_class_names.nil? || format.class.customized_class_names.include?(class_name)
44 end
44 end
45 formats.map {|format| [::I18n.t(format.label), format.name] }.sort_by(&:first)
45 formats.map {|format| [::I18n.t(format.label), format.name] }.sort_by(&:first)
46 end
46 end
47
47
48 class Base
48 class Base
49 include Singleton
49 include Singleton
50 include Redmine::I18n
50 include Redmine::I18n
51 include ERB::Util
51 include ERB::Util
52
52
53 class_attribute :format_name
53 class_attribute :format_name
54 self.format_name = nil
54 self.format_name = nil
55
55
56 # Set this to true if the format supports multiple values
56 # Set this to true if the format supports multiple values
57 class_attribute :multiple_supported
57 class_attribute :multiple_supported
58 self.multiple_supported = false
58 self.multiple_supported = false
59
59
60 # Set this to true if the format supports textual search on custom values
60 # Set this to true if the format supports textual search on custom values
61 class_attribute :searchable_supported
61 class_attribute :searchable_supported
62 self.searchable_supported = false
62 self.searchable_supported = false
63
63
64 # Restricts the classes that the custom field can be added to
64 # Restricts the classes that the custom field can be added to
65 # Set to nil for no restrictions
65 # Set to nil for no restrictions
66 class_attribute :customized_class_names
66 class_attribute :customized_class_names
67 self.customized_class_names = nil
67 self.customized_class_names = nil
68
68
69 # Name of the partial for editing the custom field
69 # Name of the partial for editing the custom field
70 class_attribute :form_partial
70 class_attribute :form_partial
71 self.form_partial = nil
71 self.form_partial = nil
72
72
73 def self.add(name)
73 def self.add(name)
74 self.format_name = name
74 self.format_name = name
75 Redmine::FieldFormat.add(name, self)
75 Redmine::FieldFormat.add(name, self)
76 end
76 end
77 private_class_method :add
77 private_class_method :add
78
78
79 def self.field_attributes(*args)
79 def self.field_attributes(*args)
80 CustomField.store_accessor :format_store, *args
80 CustomField.store_accessor :format_store, *args
81 end
81 end
82
82
83 field_attributes :url_pattern
83 field_attributes :url_pattern
84
84
85 def name
85 def name
86 self.class.format_name
86 self.class.format_name
87 end
87 end
88
88
89 def label
89 def label
90 "label_#{name}"
90 "label_#{name}"
91 end
91 end
92
92
93 def cast_custom_value(custom_value)
93 def cast_custom_value(custom_value)
94 cast_value(custom_value.custom_field, custom_value.value, custom_value.customized)
94 cast_value(custom_value.custom_field, custom_value.value, custom_value.customized)
95 end
95 end
96
96
97 def cast_value(custom_field, value, customized=nil)
97 def cast_value(custom_field, value, customized=nil)
98 if value.blank?
98 if value.blank?
99 nil
99 nil
100 elsif value.is_a?(Array)
100 elsif value.is_a?(Array)
101 casted = value.map do |v|
101 casted = value.map do |v|
102 cast_single_value(custom_field, v, customized)
102 cast_single_value(custom_field, v, customized)
103 end
103 end
104 casted.compact.sort
104 casted.compact.sort
105 else
105 else
106 cast_single_value(custom_field, value, customized)
106 cast_single_value(custom_field, value, customized)
107 end
107 end
108 end
108 end
109
109
110 def cast_single_value(custom_field, value, customized=nil)
110 def cast_single_value(custom_field, value, customized=nil)
111 value.to_s
111 value.to_s
112 end
112 end
113
113
114 def target_class
114 def target_class
115 nil
115 nil
116 end
116 end
117
117
118 def possible_custom_value_options(custom_value)
118 def possible_custom_value_options(custom_value)
119 possible_values_options(custom_value.custom_field, custom_value.customized)
119 possible_values_options(custom_value.custom_field, custom_value.customized)
120 end
120 end
121
121
122 def possible_values_options(custom_field, object=nil)
122 def possible_values_options(custom_field, object=nil)
123 []
123 []
124 end
124 end
125
125
126 # Returns the validation errors for custom_field
126 # Returns the validation errors for custom_field
127 # Should return an empty array if custom_field is valid
127 # Should return an empty array if custom_field is valid
128 def validate_custom_field(custom_field)
128 def validate_custom_field(custom_field)
129 []
129 []
130 end
130 end
131
131
132 # Returns the validation error messages for custom_value
132 # Returns the validation error messages for custom_value
133 # Should return an empty array if custom_value is valid
133 # Should return an empty array if custom_value is valid
134 def validate_custom_value(custom_value)
134 def validate_custom_value(custom_value)
135 values = Array.wrap(custom_value.value).reject {|value| value.to_s == ''}
135 values = Array.wrap(custom_value.value).reject {|value| value.to_s == ''}
136 errors = values.map do |value|
136 errors = values.map do |value|
137 validate_single_value(custom_value.custom_field, value, custom_value.customized)
137 validate_single_value(custom_value.custom_field, value, custom_value.customized)
138 end
138 end
139 errors.flatten.uniq
139 errors.flatten.uniq
140 end
140 end
141
141
142 def validate_single_value(custom_field, value, customized=nil)
142 def validate_single_value(custom_field, value, customized=nil)
143 []
143 []
144 end
144 end
145
145
146 def formatted_custom_value(view, custom_value, html=false)
146 def formatted_custom_value(view, custom_value, html=false)
147 formatted_value(view, custom_value.custom_field, custom_value.value, custom_value.customized, html)
147 formatted_value(view, custom_value.custom_field, custom_value.value, custom_value.customized, html)
148 end
148 end
149
149
150 def formatted_value(view, custom_field, value, customized=nil, html=false)
150 def formatted_value(view, custom_field, value, customized=nil, html=false)
151 casted = cast_value(custom_field, value, customized)
151 casted = cast_value(custom_field, value, customized)
152 if html && custom_field.url_pattern.present?
152 if html && custom_field.url_pattern.present?
153 texts_and_urls = Array.wrap(casted).map do |single_value|
153 texts_and_urls = Array.wrap(casted).map do |single_value|
154 text = view.format_object(single_value, false).to_s
154 text = view.format_object(single_value, false).to_s
155 url = url_from_pattern(custom_field, single_value, customized)
155 url = url_from_pattern(custom_field, single_value, customized)
156 [text, url]
156 [text, url]
157 end
157 end
158 links = texts_and_urls.sort_by(&:first).map {|text, url| view.link_to text, url}
158 links = texts_and_urls.sort_by(&:first).map {|text, url| view.link_to text, url}
159 links.join(', ').html_safe
159 links.join(', ').html_safe
160 else
160 else
161 casted
161 casted
162 end
162 end
163 end
163 end
164
164
165 # Returns an URL generated with the custom field URL pattern
165 # Returns an URL generated with the custom field URL pattern
166 # and variables substitution:
166 # and variables substitution:
167 # %value% => the custom field value
167 # %value% => the custom field value
168 # %id% => id of the customized object
168 # %id% => id of the customized object
169 # %project_id% => id of the project of the customized object if defined
169 # %project_id% => id of the project of the customized object if defined
170 # %project_identifier% => identifier of the project of the customized object if defined
170 # %project_identifier% => identifier of the project of the customized object if defined
171 # %m1%, %m2%... => capture groups matches of the custom field regexp if defined
171 # %m1%, %m2%... => capture groups matches of the custom field regexp if defined
172 def url_from_pattern(custom_field, value, customized)
172 def url_from_pattern(custom_field, value, customized)
173 url = custom_field.url_pattern.to_s.dup
173 url = custom_field.url_pattern.to_s.dup
174 url.gsub!('%value%') {value.to_s}
174 url.gsub!('%value%') {value.to_s}
175 url.gsub!('%id%') {customized.id.to_s}
175 url.gsub!('%id%') {customized.id.to_s}
176 url.gsub!('%project_id%') {(customized.respond_to?(:project) ? customized.project.try(:id) : nil).to_s}
176 url.gsub!('%project_id%') {(customized.respond_to?(:project) ? customized.project.try(:id) : nil).to_s}
177 url.gsub!('%project_identifier%') {(customized.respond_to?(:project) ? customized.project.try(:identifier) : nil).to_s}
177 url.gsub!('%project_identifier%') {(customized.respond_to?(:project) ? customized.project.try(:identifier) : nil).to_s}
178 if custom_field.regexp.present?
178 if custom_field.regexp.present?
179 url.gsub!(%r{%m(\d+)%}) do
179 url.gsub!(%r{%m(\d+)%}) do
180 m = $1.to_i
180 m = $1.to_i
181 if matches ||= value.to_s.match(Regexp.new(custom_field.regexp))
181 if matches ||= value.to_s.match(Regexp.new(custom_field.regexp))
182 matches[m].to_s
182 matches[m].to_s
183 end
183 end
184 end
184 end
185 end
185 end
186 url
186 url
187 end
187 end
188 protected :url_from_pattern
188 protected :url_from_pattern
189
189
190 def edit_tag(view, tag_id, tag_name, custom_value, options={})
190 def edit_tag(view, tag_id, tag_name, custom_value, options={})
191 view.text_field_tag(tag_name, custom_value.value, options.merge(:id => tag_id))
191 view.text_field_tag(tag_name, custom_value.value, options.merge(:id => tag_id))
192 end
192 end
193
193
194 def bulk_edit_tag(view, tag_id, tag_name, custom_field, objects, value, options={})
194 def bulk_edit_tag(view, tag_id, tag_name, custom_field, objects, value, options={})
195 view.text_field_tag(tag_name, value, options.merge(:id => tag_id)) +
195 view.text_field_tag(tag_name, value, options.merge(:id => tag_id)) +
196 bulk_clear_tag(view, tag_id, tag_name, custom_field, value)
196 bulk_clear_tag(view, tag_id, tag_name, custom_field, value)
197 end
197 end
198
198
199 def bulk_clear_tag(view, tag_id, tag_name, custom_field, value)
199 def bulk_clear_tag(view, tag_id, tag_name, custom_field, value)
200 if custom_field.is_required?
200 if custom_field.is_required?
201 ''.html_safe
201 ''.html_safe
202 else
202 else
203 view.content_tag('label',
203 view.content_tag('label',
204 view.check_box_tag(tag_name, '__none__', (value == '__none__'), :id => nil, :data => {:disables => "##{tag_id}"}) + l(:button_clear),
204 view.check_box_tag(tag_name, '__none__', (value == '__none__'), :id => nil, :data => {:disables => "##{tag_id}"}) + l(:button_clear),
205 :class => 'inline'
205 :class => 'inline'
206 )
206 )
207 end
207 end
208 end
208 end
209 protected :bulk_clear_tag
209 protected :bulk_clear_tag
210
210
211 def query_filter_options(custom_field, query)
211 def query_filter_options(custom_field, query)
212 {:type => :string}
212 {:type => :string}
213 end
213 end
214
214
215 def before_custom_field_save(custom_field)
215 def before_custom_field_save(custom_field)
216 end
216 end
217
217
218 # Returns a ORDER BY clause that can used to sort customized
218 # Returns a ORDER BY clause that can used to sort customized
219 # objects by their value of the custom field.
219 # objects by their value of the custom field.
220 # Returns nil if the custom field can not be used for sorting.
220 # Returns nil if the custom field can not be used for sorting.
221 def order_statement(custom_field)
221 def order_statement(custom_field)
222 # COALESCE is here to make sure that blank and NULL values are sorted equally
222 # COALESCE is here to make sure that blank and NULL values are sorted equally
223 "COALESCE(#{join_alias custom_field}.value, '')"
223 "COALESCE(#{join_alias custom_field}.value, '')"
224 end
224 end
225
225
226 # Returns a GROUP BY clause that can used to group by custom value
226 # Returns a GROUP BY clause that can used to group by custom value
227 # Returns nil if the custom field can not be used for grouping.
227 # Returns nil if the custom field can not be used for grouping.
228 def group_statement(custom_field)
228 def group_statement(custom_field)
229 nil
229 nil
230 end
230 end
231
231
232 # Returns a JOIN clause that is added to the query when sorting by custom values
232 # Returns a JOIN clause that is added to the query when sorting by custom values
233 def join_for_order_statement(custom_field)
233 def join_for_order_statement(custom_field)
234 alias_name = join_alias(custom_field)
234 alias_name = join_alias(custom_field)
235
235
236 "LEFT OUTER JOIN #{CustomValue.table_name} #{alias_name}" +
236 "LEFT OUTER JOIN #{CustomValue.table_name} #{alias_name}" +
237 " ON #{alias_name}.customized_type = '#{custom_field.class.customized_class.base_class.name}'" +
237 " ON #{alias_name}.customized_type = '#{custom_field.class.customized_class.base_class.name}'" +
238 " AND #{alias_name}.customized_id = #{custom_field.class.customized_class.table_name}.id" +
238 " AND #{alias_name}.customized_id = #{custom_field.class.customized_class.table_name}.id" +
239 " AND #{alias_name}.custom_field_id = #{custom_field.id}" +
239 " AND #{alias_name}.custom_field_id = #{custom_field.id}" +
240 " AND (#{custom_field.visibility_by_project_condition})" +
240 " AND (#{custom_field.visibility_by_project_condition})" +
241 " AND #{alias_name}.value <> ''" +
241 " AND #{alias_name}.value <> ''" +
242 " AND #{alias_name}.id = (SELECT max(#{alias_name}_2.id) FROM #{CustomValue.table_name} #{alias_name}_2" +
242 " AND #{alias_name}.id = (SELECT max(#{alias_name}_2.id) FROM #{CustomValue.table_name} #{alias_name}_2" +
243 " WHERE #{alias_name}_2.customized_type = #{alias_name}.customized_type" +
243 " WHERE #{alias_name}_2.customized_type = #{alias_name}.customized_type" +
244 " AND #{alias_name}_2.customized_id = #{alias_name}.customized_id" +
244 " AND #{alias_name}_2.customized_id = #{alias_name}.customized_id" +
245 " AND #{alias_name}_2.custom_field_id = #{alias_name}.custom_field_id)"
245 " AND #{alias_name}_2.custom_field_id = #{alias_name}.custom_field_id)"
246 end
246 end
247
247
248 def join_alias(custom_field)
248 def join_alias(custom_field)
249 "cf_#{custom_field.id}"
249 "cf_#{custom_field.id}"
250 end
250 end
251 protected :join_alias
251 protected :join_alias
252 end
252 end
253
253
254 class Unbounded < Base
254 class Unbounded < Base
255 def validate_single_value(custom_field, value, customized=nil)
255 def validate_single_value(custom_field, value, customized=nil)
256 errs = super
256 errs = super
257 value = value.to_s
257 value = value.to_s
258 unless custom_field.regexp.blank? or value =~ Regexp.new(custom_field.regexp)
258 unless custom_field.regexp.blank? or value =~ Regexp.new(custom_field.regexp)
259 errs << ::I18n.t('activerecord.errors.messages.invalid')
259 errs << ::I18n.t('activerecord.errors.messages.invalid')
260 end
260 end
261 if custom_field.min_length && value.length < custom_field.min_length
261 if custom_field.min_length && value.length < custom_field.min_length
262 errs << ::I18n.t('activerecord.errors.messages.too_short', :count => custom_field.min_length)
262 errs << ::I18n.t('activerecord.errors.messages.too_short', :count => custom_field.min_length)
263 end
263 end
264 if custom_field.max_length && custom_field.max_length > 0 && value.length > custom_field.max_length
264 if custom_field.max_length && custom_field.max_length > 0 && value.length > custom_field.max_length
265 errs << ::I18n.t('activerecord.errors.messages.too_long', :count => custom_field.max_length)
265 errs << ::I18n.t('activerecord.errors.messages.too_long', :count => custom_field.max_length)
266 end
266 end
267 errs
267 errs
268 end
268 end
269 end
269 end
270
270
271 class StringFormat < Unbounded
271 class StringFormat < Unbounded
272 add 'string'
272 add 'string'
273 self.searchable_supported = true
273 self.searchable_supported = true
274 self.form_partial = 'custom_fields/formats/string'
274 self.form_partial = 'custom_fields/formats/string'
275 field_attributes :text_formatting
275 field_attributes :text_formatting
276
276
277 def formatted_value(view, custom_field, value, customized=nil, html=false)
277 def formatted_value(view, custom_field, value, customized=nil, html=false)
278 if html
278 if html
279 if custom_field.url_pattern.present?
279 if custom_field.url_pattern.present?
280 super
280 super
281 elsif custom_field.text_formatting == 'full'
281 elsif custom_field.text_formatting == 'full'
282 view.textilizable(value, :object => customized)
282 view.textilizable(value, :object => customized)
283 else
283 else
284 value.to_s
284 value.to_s
285 end
285 end
286 else
286 else
287 value.to_s
287 value.to_s
288 end
288 end
289 end
289 end
290 end
290 end
291
291
292 class TextFormat < Unbounded
292 class TextFormat < Unbounded
293 add 'text'
293 add 'text'
294 self.searchable_supported = true
294 self.searchable_supported = true
295 self.form_partial = 'custom_fields/formats/text'
295 self.form_partial = 'custom_fields/formats/text'
296
296
297 def formatted_value(view, custom_field, value, customized=nil, html=false)
297 def formatted_value(view, custom_field, value, customized=nil, html=false)
298 if html
298 if html
299 if custom_field.text_formatting == 'full'
299 if custom_field.text_formatting == 'full'
300 view.textilizable(value, :object => customized)
300 view.textilizable(value, :object => customized)
301 else
301 else
302 view.simple_format(html_escape(value))
302 view.simple_format(html_escape(value))
303 end
303 end
304 else
304 else
305 value.to_s
305 value.to_s
306 end
306 end
307 end
307 end
308
308
309 def edit_tag(view, tag_id, tag_name, custom_value, options={})
309 def edit_tag(view, tag_id, tag_name, custom_value, options={})
310 view.text_area_tag(tag_name, custom_value.value, options.merge(:id => tag_id, :rows => 3))
310 view.text_area_tag(tag_name, custom_value.value, options.merge(:id => tag_id, :rows => 3))
311 end
311 end
312
312
313 def bulk_edit_tag(view, tag_id, tag_name, custom_field, objects, value, options={})
313 def bulk_edit_tag(view, tag_id, tag_name, custom_field, objects, value, options={})
314 view.text_area_tag(tag_name, value, options.merge(:id => tag_id, :rows => 3)) +
314 view.text_area_tag(tag_name, value, options.merge(:id => tag_id, :rows => 3)) +
315 '<br />'.html_safe +
315 '<br />'.html_safe +
316 bulk_clear_tag(view, tag_id, tag_name, custom_field, value)
316 bulk_clear_tag(view, tag_id, tag_name, custom_field, value)
317 end
317 end
318
318
319 def query_filter_options(custom_field, query)
319 def query_filter_options(custom_field, query)
320 {:type => :text}
320 {:type => :text}
321 end
321 end
322 end
322 end
323
323
324 class LinkFormat < StringFormat
324 class LinkFormat < StringFormat
325 add 'link'
325 add 'link'
326 self.searchable_supported = false
326 self.searchable_supported = false
327 self.form_partial = 'custom_fields/formats/link'
327 self.form_partial = 'custom_fields/formats/link'
328
328
329 def formatted_value(view, custom_field, value, customized=nil, html=false)
329 def formatted_value(view, custom_field, value, customized=nil, html=false)
330 if html
330 if html
331 if custom_field.url_pattern.present?
331 if custom_field.url_pattern.present?
332 url = url_from_pattern(custom_field, value, customized)
332 url = url_from_pattern(custom_field, value, customized)
333 else
333 else
334 url = value.to_s
334 url = value.to_s
335 unless url =~ %r{\A[a-z]+://}i
335 unless url =~ %r{\A[a-z]+://}i
336 # no protocol found, use http by default
336 # no protocol found, use http by default
337 url = "http://" + url
337 url = "http://" + url
338 end
338 end
339 end
339 end
340 view.link_to value.to_s, url
340 view.link_to value.to_s, url
341 else
341 else
342 value.to_s
342 value.to_s
343 end
343 end
344 end
344 end
345 end
345 end
346
346
347 class Numeric < Unbounded
347 class Numeric < Unbounded
348 self.form_partial = 'custom_fields/formats/numeric'
348 self.form_partial = 'custom_fields/formats/numeric'
349
349
350 def order_statement(custom_field)
350 def order_statement(custom_field)
351 # Make the database cast values into numeric
351 # Make the database cast values into numeric
352 # Postgresql will raise an error if a value can not be casted!
352 # Postgresql will raise an error if a value can not be casted!
353 # CustomValue validations should ensure that it doesn't occur
353 # CustomValue validations should ensure that it doesn't occur
354 "CAST(CASE #{join_alias custom_field}.value WHEN '' THEN '0' ELSE #{join_alias custom_field}.value END AS decimal(30,3))"
354 "CAST(CASE #{join_alias custom_field}.value WHEN '' THEN '0' ELSE #{join_alias custom_field}.value END AS decimal(30,3))"
355 end
355 end
356 end
356 end
357
357
358 class IntFormat < Numeric
358 class IntFormat < Numeric
359 add 'int'
359 add 'int'
360
360
361 def label
361 def label
362 "label_integer"
362 "label_integer"
363 end
363 end
364
364
365 def cast_single_value(custom_field, value, customized=nil)
365 def cast_single_value(custom_field, value, customized=nil)
366 value.to_i
366 value.to_i
367 end
367 end
368
368
369 def validate_single_value(custom_field, value, customized=nil)
369 def validate_single_value(custom_field, value, customized=nil)
370 errs = super
370 errs = super
371 errs << ::I18n.t('activerecord.errors.messages.not_a_number') unless value =~ /^[+-]?\d+$/
371 errs << ::I18n.t('activerecord.errors.messages.not_a_number') unless value =~ /^[+-]?\d+$/
372 errs
372 errs
373 end
373 end
374
374
375 def query_filter_options(custom_field, query)
375 def query_filter_options(custom_field, query)
376 {:type => :integer}
376 {:type => :integer}
377 end
377 end
378
378
379 def group_statement(custom_field)
379 def group_statement(custom_field)
380 order_statement(custom_field)
380 order_statement(custom_field)
381 end
381 end
382 end
382 end
383
383
384 class FloatFormat < Numeric
384 class FloatFormat < Numeric
385 add 'float'
385 add 'float'
386
386
387 def cast_single_value(custom_field, value, customized=nil)
387 def cast_single_value(custom_field, value, customized=nil)
388 value.to_f
388 value.to_f
389 end
389 end
390
390
391 def validate_single_value(custom_field, value, customized=nil)
391 def validate_single_value(custom_field, value, customized=nil)
392 errs = super
392 errs = super
393 errs << ::I18n.t('activerecord.errors.messages.invalid') unless (Kernel.Float(value) rescue nil)
393 errs << ::I18n.t('activerecord.errors.messages.invalid') unless (Kernel.Float(value) rescue nil)
394 errs
394 errs
395 end
395 end
396
396
397 def query_filter_options(custom_field, query)
397 def query_filter_options(custom_field, query)
398 {:type => :float}
398 {:type => :float}
399 end
399 end
400 end
400 end
401
401
402 class DateFormat < Unbounded
402 class DateFormat < Unbounded
403 add 'date'
403 add 'date'
404 self.form_partial = 'custom_fields/formats/date'
404 self.form_partial = 'custom_fields/formats/date'
405
405
406 def cast_single_value(custom_field, value, customized=nil)
406 def cast_single_value(custom_field, value, customized=nil)
407 value.to_date rescue nil
407 value.to_date rescue nil
408 end
408 end
409
409
410 def validate_single_value(custom_field, value, customized=nil)
410 def validate_single_value(custom_field, value, customized=nil)
411 if value =~ /^\d{4}-\d{2}-\d{2}$/ && (value.to_date rescue false)
411 if value =~ /^\d{4}-\d{2}-\d{2}$/ && (value.to_date rescue false)
412 []
412 []
413 else
413 else
414 [::I18n.t('activerecord.errors.messages.not_a_date')]
414 [::I18n.t('activerecord.errors.messages.not_a_date')]
415 end
415 end
416 end
416 end
417
417
418 def edit_tag(view, tag_id, tag_name, custom_value, options={})
418 def edit_tag(view, tag_id, tag_name, custom_value, options={})
419 view.text_field_tag(tag_name, custom_value.value, options.merge(:id => tag_id, :size => 10)) +
419 view.text_field_tag(tag_name, custom_value.value, options.merge(:id => tag_id, :size => 10)) +
420 view.calendar_for(tag_id)
420 view.calendar_for(tag_id)
421 end
421 end
422
422
423 def bulk_edit_tag(view, tag_id, tag_name, custom_field, objects, value, options={})
423 def bulk_edit_tag(view, tag_id, tag_name, custom_field, objects, value, options={})
424 view.text_field_tag(tag_name, value, options.merge(:id => tag_id, :size => 10)) +
424 view.text_field_tag(tag_name, value, options.merge(:id => tag_id, :size => 10)) +
425 view.calendar_for(tag_id) +
425 view.calendar_for(tag_id) +
426 bulk_clear_tag(view, tag_id, tag_name, custom_field, value)
426 bulk_clear_tag(view, tag_id, tag_name, custom_field, value)
427 end
427 end
428
428
429 def query_filter_options(custom_field, query)
429 def query_filter_options(custom_field, query)
430 {:type => :date}
430 {:type => :date}
431 end
431 end
432
432
433 def group_statement(custom_field)
433 def group_statement(custom_field)
434 order_statement(custom_field)
434 order_statement(custom_field)
435 end
435 end
436 end
436 end
437
437
438 class List < Base
438 class List < Base
439 self.multiple_supported = true
439 self.multiple_supported = true
440 field_attributes :edit_tag_style
440 field_attributes :edit_tag_style
441
441
442 def edit_tag(view, tag_id, tag_name, custom_value, options={})
442 def edit_tag(view, tag_id, tag_name, custom_value, options={})
443 if custom_value.custom_field.edit_tag_style == 'check_box'
443 if custom_value.custom_field.edit_tag_style == 'check_box'
444 check_box_edit_tag(view, tag_id, tag_name, custom_value, options)
444 check_box_edit_tag(view, tag_id, tag_name, custom_value, options)
445 else
445 else
446 select_edit_tag(view, tag_id, tag_name, custom_value, options)
446 select_edit_tag(view, tag_id, tag_name, custom_value, options)
447 end
447 end
448 end
448 end
449
449
450 def bulk_edit_tag(view, tag_id, tag_name, custom_field, objects, value, options={})
450 def bulk_edit_tag(view, tag_id, tag_name, custom_field, objects, value, options={})
451 opts = []
451 opts = []
452 opts << [l(:label_no_change_option), ''] unless custom_field.multiple?
452 opts << [l(:label_no_change_option), ''] unless custom_field.multiple?
453 opts << [l(:label_none), '__none__'] unless custom_field.is_required?
453 opts << [l(:label_none), '__none__'] unless custom_field.is_required?
454 opts += possible_values_options(custom_field, objects)
454 opts += possible_values_options(custom_field, objects)
455 view.select_tag(tag_name, view.options_for_select(opts, value), options.merge(:multiple => custom_field.multiple?))
455 view.select_tag(tag_name, view.options_for_select(opts, value), options.merge(:multiple => custom_field.multiple?))
456 end
456 end
457
457
458 def query_filter_options(custom_field, query)
458 def query_filter_options(custom_field, query)
459 {:type => :list_optional, :values => possible_values_options(custom_field, query.project)}
459 {:type => :list_optional, :values => possible_values_options(custom_field, query.project)}
460 end
460 end
461
461
462 protected
462 protected
463
463
464 # Renders the edit tag as a select tag
464 # Renders the edit tag as a select tag
465 def select_edit_tag(view, tag_id, tag_name, custom_value, options={})
465 def select_edit_tag(view, tag_id, tag_name, custom_value, options={})
466 blank_option = ''.html_safe
466 blank_option = ''.html_safe
467 unless custom_value.custom_field.multiple?
467 unless custom_value.custom_field.multiple?
468 if custom_value.custom_field.is_required?
468 if custom_value.custom_field.is_required?
469 unless custom_value.custom_field.default_value.present?
469 unless custom_value.custom_field.default_value.present?
470 blank_option = view.content_tag('option', "--- #{l(:actionview_instancetag_blank_option)} ---", :value => '')
470 blank_option = view.content_tag('option', "--- #{l(:actionview_instancetag_blank_option)} ---", :value => '')
471 end
471 end
472 else
472 else
473 blank_option = view.content_tag('option', '&nbsp;'.html_safe, :value => '')
473 blank_option = view.content_tag('option', '&nbsp;'.html_safe, :value => '')
474 end
474 end
475 end
475 end
476 options_tags = blank_option + view.options_for_select(possible_custom_value_options(custom_value), custom_value.value)
476 options_tags = blank_option + view.options_for_select(possible_custom_value_options(custom_value), custom_value.value)
477 s = view.select_tag(tag_name, options_tags, options.merge(:id => tag_id, :multiple => custom_value.custom_field.multiple?))
477 s = view.select_tag(tag_name, options_tags, options.merge(:id => tag_id, :multiple => custom_value.custom_field.multiple?))
478 if custom_value.custom_field.multiple?
478 if custom_value.custom_field.multiple?
479 s << view.hidden_field_tag(tag_name, '')
479 s << view.hidden_field_tag(tag_name, '')
480 end
480 end
481 s
481 s
482 end
482 end
483
483
484 # Renders the edit tag as check box or radio tags
484 # Renders the edit tag as check box or radio tags
485 def check_box_edit_tag(view, tag_id, tag_name, custom_value, options={})
485 def check_box_edit_tag(view, tag_id, tag_name, custom_value, options={})
486 opts = []
486 opts = []
487 unless custom_value.custom_field.multiple? || custom_value.custom_field.is_required?
487 unless custom_value.custom_field.multiple? || custom_value.custom_field.is_required?
488 opts << ["(#{l(:label_none)})", '']
488 opts << ["(#{l(:label_none)})", '']
489 end
489 end
490 opts += possible_custom_value_options(custom_value)
490 opts += possible_custom_value_options(custom_value)
491 s = ''.html_safe
491 s = ''.html_safe
492 tag_method = custom_value.custom_field.multiple? ? :check_box_tag : :radio_button_tag
492 tag_method = custom_value.custom_field.multiple? ? :check_box_tag : :radio_button_tag
493 opts.each do |label, value|
493 opts.each do |label, value|
494 value ||= label
494 value ||= label
495 checked = (custom_value.value.is_a?(Array) && custom_value.value.include?(value)) || custom_value.value.to_s == value
495 checked = (custom_value.value.is_a?(Array) && custom_value.value.include?(value)) || custom_value.value.to_s == value
496 tag = view.send(tag_method, tag_name, value, checked, :id => tag_id)
496 tag = view.send(tag_method, tag_name, value, checked, :id => tag_id)
497 # set the id on the first tag only
497 # set the id on the first tag only
498 tag_id = nil
498 tag_id = nil
499 s << view.content_tag('label', tag + ' ' + label)
499 s << view.content_tag('label', tag + ' ' + label)
500 end
500 end
501 if custom_value.custom_field.multiple?
501 if custom_value.custom_field.multiple?
502 s << view.hidden_field_tag(tag_name, '')
502 s << view.hidden_field_tag(tag_name, '')
503 end
503 end
504 css = "#{options[:class]} check_box_group"
504 css = "#{options[:class]} check_box_group"
505 view.content_tag('span', s, options.merge(:class => css))
505 view.content_tag('span', s, options.merge(:class => css))
506 end
506 end
507 end
507 end
508
508
509 class ListFormat < List
509 class ListFormat < List
510 add 'list'
510 add 'list'
511 self.searchable_supported = true
511 self.searchable_supported = true
512 self.form_partial = 'custom_fields/formats/list'
512 self.form_partial = 'custom_fields/formats/list'
513
513
514 def possible_custom_value_options(custom_value)
514 def possible_custom_value_options(custom_value)
515 options = possible_values_options(custom_value.custom_field)
515 options = possible_values_options(custom_value.custom_field)
516 missing = [custom_value.value].flatten.reject(&:blank?) - options
516 missing = [custom_value.value].flatten.reject(&:blank?) - options
517 if missing.any?
517 if missing.any?
518 options += missing
518 options += missing
519 end
519 end
520 options
520 options
521 end
521 end
522
522
523 def possible_values_options(custom_field, object=nil)
523 def possible_values_options(custom_field, object=nil)
524 custom_field.possible_values
524 custom_field.possible_values
525 end
525 end
526
526
527 def validate_custom_field(custom_field)
527 def validate_custom_field(custom_field)
528 errors = []
528 errors = []
529 errors << [:possible_values, :blank] if custom_field.possible_values.blank?
529 errors << [:possible_values, :blank] if custom_field.possible_values.blank?
530 errors << [:possible_values, :invalid] unless custom_field.possible_values.is_a? Array
530 errors << [:possible_values, :invalid] unless custom_field.possible_values.is_a? Array
531 errors
531 errors
532 end
532 end
533
533
534 def validate_custom_value(custom_value)
534 def validate_custom_value(custom_value)
535 values = Array.wrap(custom_value.value).reject {|value| value.to_s == ''}
535 values = Array.wrap(custom_value.value).reject {|value| value.to_s == ''}
536 invalid_values = values - Array.wrap(custom_value.value_was) - custom_value.custom_field.possible_values
536 invalid_values = values - Array.wrap(custom_value.value_was) - custom_value.custom_field.possible_values
537 if invalid_values.any?
537 if invalid_values.any?
538 [::I18n.t('activerecord.errors.messages.inclusion')]
538 [::I18n.t('activerecord.errors.messages.inclusion')]
539 else
539 else
540 []
540 []
541 end
541 end
542 end
542 end
543
543
544 def group_statement(custom_field)
544 def group_statement(custom_field)
545 order_statement(custom_field)
545 order_statement(custom_field)
546 end
546 end
547 end
547 end
548
548
549 class BoolFormat < List
549 class BoolFormat < List
550 add 'bool'
550 add 'bool'
551 self.multiple_supported = false
551 self.multiple_supported = false
552 self.form_partial = 'custom_fields/formats/bool'
552 self.form_partial = 'custom_fields/formats/bool'
553
553
554 def label
554 def label
555 "label_boolean"
555 "label_boolean"
556 end
556 end
557
557
558 def cast_single_value(custom_field, value, customized=nil)
558 def cast_single_value(custom_field, value, customized=nil)
559 value == '1' ? true : false
559 value == '1' ? true : false
560 end
560 end
561
561
562 def possible_values_options(custom_field, object=nil)
562 def possible_values_options(custom_field, object=nil)
563 [[::I18n.t(:general_text_Yes), '1'], [::I18n.t(:general_text_No), '0']]
563 [[::I18n.t(:general_text_Yes), '1'], [::I18n.t(:general_text_No), '0']]
564 end
564 end
565
565
566 def group_statement(custom_field)
566 def group_statement(custom_field)
567 order_statement(custom_field)
567 order_statement(custom_field)
568 end
568 end
569
569
570 def edit_tag(view, tag_id, tag_name, custom_value, options={})
570 def edit_tag(view, tag_id, tag_name, custom_value, options={})
571 case custom_value.custom_field.edit_tag_style
571 case custom_value.custom_field.edit_tag_style
572 when 'check_box'
572 when 'check_box'
573 single_check_box_edit_tag(view, tag_id, tag_name, custom_value, options)
573 single_check_box_edit_tag(view, tag_id, tag_name, custom_value, options)
574 when 'radio'
574 when 'radio'
575 check_box_edit_tag(view, tag_id, tag_name, custom_value, options)
575 check_box_edit_tag(view, tag_id, tag_name, custom_value, options)
576 else
576 else
577 select_edit_tag(view, tag_id, tag_name, custom_value, options)
577 select_edit_tag(view, tag_id, tag_name, custom_value, options)
578 end
578 end
579 end
579 end
580
580
581 # Renders the edit tag as a simple check box
581 # Renders the edit tag as a simple check box
582 def single_check_box_edit_tag(view, tag_id, tag_name, custom_value, options={})
582 def single_check_box_edit_tag(view, tag_id, tag_name, custom_value, options={})
583 s = ''.html_safe
583 s = ''.html_safe
584 s << view.hidden_field_tag(tag_name, '0', :id => nil)
584 s << view.hidden_field_tag(tag_name, '0', :id => nil)
585 s << view.check_box_tag(tag_name, '1', custom_value.value.to_s == '1', :id => tag_id)
585 s << view.check_box_tag(tag_name, '1', custom_value.value.to_s == '1', :id => tag_id)
586 view.content_tag('span', s, options)
586 view.content_tag('span', s, options)
587 end
587 end
588 end
588 end
589
589
590 class RecordList < List
590 class RecordList < List
591 self.customized_class_names = %w(Issue TimeEntry Version Project)
591 self.customized_class_names = %w(Issue TimeEntry Version Project)
592
592
593 def cast_single_value(custom_field, value, customized=nil)
593 def cast_single_value(custom_field, value, customized=nil)
594 target_class.find_by_id(value.to_i) if value.present?
594 target_class.find_by_id(value.to_i) if value.present?
595 end
595 end
596
596
597 def target_class
597 def target_class
598 @target_class ||= self.class.name[/^(.*::)?(.+)Format$/, 2].constantize rescue nil
598 @target_class ||= self.class.name[/^(.*::)?(.+)Format$/, 2].constantize rescue nil
599 end
599 end
600
601 def reset_target_class
602 @target_class = nil
603 end
600
604
601 def possible_custom_value_options(custom_value)
605 def possible_custom_value_options(custom_value)
602 options = possible_values_options(custom_value.custom_field, custom_value.customized)
606 options = possible_values_options(custom_value.custom_field, custom_value.customized)
603 missing = [custom_value.value_was].flatten.reject(&:blank?) - options.map(&:last)
607 missing = [custom_value.value_was].flatten.reject(&:blank?) - options.map(&:last)
604 if missing.any?
608 if missing.any?
605 options += target_class.where(:id => missing.map(&:to_i)).map {|o| [o.to_s, o.id.to_s]}
609 options += target_class.where(:id => missing.map(&:to_i)).map {|o| [o.to_s, o.id.to_s]}
606 options.sort_by!(&:first)
610 options.sort_by!(&:first)
607 end
611 end
608 options
612 options
609 end
613 end
610
614
611 def order_statement(custom_field)
615 def order_statement(custom_field)
612 if target_class.respond_to?(:fields_for_order_statement)
616 if target_class.respond_to?(:fields_for_order_statement)
613 target_class.fields_for_order_statement(value_join_alias(custom_field))
617 target_class.fields_for_order_statement(value_join_alias(custom_field))
614 end
618 end
615 end
619 end
616
620
617 def group_statement(custom_field)
621 def group_statement(custom_field)
618 "COALESCE(#{join_alias custom_field}.value, '')"
622 "COALESCE(#{join_alias custom_field}.value, '')"
619 end
623 end
620
624
621 def join_for_order_statement(custom_field)
625 def join_for_order_statement(custom_field)
622 alias_name = join_alias(custom_field)
626 alias_name = join_alias(custom_field)
623
627
624 "LEFT OUTER JOIN #{CustomValue.table_name} #{alias_name}" +
628 "LEFT OUTER JOIN #{CustomValue.table_name} #{alias_name}" +
625 " ON #{alias_name}.customized_type = '#{custom_field.class.customized_class.base_class.name}'" +
629 " ON #{alias_name}.customized_type = '#{custom_field.class.customized_class.base_class.name}'" +
626 " AND #{alias_name}.customized_id = #{custom_field.class.customized_class.table_name}.id" +
630 " AND #{alias_name}.customized_id = #{custom_field.class.customized_class.table_name}.id" +
627 " AND #{alias_name}.custom_field_id = #{custom_field.id}" +
631 " AND #{alias_name}.custom_field_id = #{custom_field.id}" +
628 " AND (#{custom_field.visibility_by_project_condition})" +
632 " AND (#{custom_field.visibility_by_project_condition})" +
629 " AND #{alias_name}.value <> ''" +
633 " AND #{alias_name}.value <> ''" +
630 " AND #{alias_name}.id = (SELECT max(#{alias_name}_2.id) FROM #{CustomValue.table_name} #{alias_name}_2" +
634 " AND #{alias_name}.id = (SELECT max(#{alias_name}_2.id) FROM #{CustomValue.table_name} #{alias_name}_2" +
631 " WHERE #{alias_name}_2.customized_type = #{alias_name}.customized_type" +
635 " WHERE #{alias_name}_2.customized_type = #{alias_name}.customized_type" +
632 " AND #{alias_name}_2.customized_id = #{alias_name}.customized_id" +
636 " AND #{alias_name}_2.customized_id = #{alias_name}.customized_id" +
633 " AND #{alias_name}_2.custom_field_id = #{alias_name}.custom_field_id)" +
637 " AND #{alias_name}_2.custom_field_id = #{alias_name}.custom_field_id)" +
634 " LEFT OUTER JOIN #{target_class.table_name} #{value_join_alias custom_field}" +
638 " LEFT OUTER JOIN #{target_class.table_name} #{value_join_alias custom_field}" +
635 " ON CAST(CASE #{alias_name}.value WHEN '' THEN '0' ELSE #{alias_name}.value END AS decimal(30,0)) = #{value_join_alias custom_field}.id"
639 " ON CAST(CASE #{alias_name}.value WHEN '' THEN '0' ELSE #{alias_name}.value END AS decimal(30,0)) = #{value_join_alias custom_field}.id"
636 end
640 end
637
641
638 def value_join_alias(custom_field)
642 def value_join_alias(custom_field)
639 join_alias(custom_field) + "_" + custom_field.field_format
643 join_alias(custom_field) + "_" + custom_field.field_format
640 end
644 end
641 protected :value_join_alias
645 protected :value_join_alias
642 end
646 end
643
647
644 class UserFormat < RecordList
648 class UserFormat < RecordList
645 add 'user'
649 add 'user'
646 self.form_partial = 'custom_fields/formats/user'
650 self.form_partial = 'custom_fields/formats/user'
647 field_attributes :user_role
651 field_attributes :user_role
648
652
649 def possible_values_options(custom_field, object=nil)
653 def possible_values_options(custom_field, object=nil)
650 if object.is_a?(Array)
654 if object.is_a?(Array)
651 projects = object.map {|o| o.respond_to?(:project) ? o.project : nil}.compact.uniq
655 projects = object.map {|o| o.respond_to?(:project) ? o.project : nil}.compact.uniq
652 projects.map {|project| possible_values_options(custom_field, project)}.reduce(:&) || []
656 projects.map {|project| possible_values_options(custom_field, project)}.reduce(:&) || []
653 elsif object.respond_to?(:project) && object.project
657 elsif object.respond_to?(:project) && object.project
654 scope = object.project.users
658 scope = object.project.users
655 if custom_field.user_role.is_a?(Array)
659 if custom_field.user_role.is_a?(Array)
656 role_ids = custom_field.user_role.map(&:to_s).reject(&:blank?).map(&:to_i)
660 role_ids = custom_field.user_role.map(&:to_s).reject(&:blank?).map(&:to_i)
657 if role_ids.any?
661 if role_ids.any?
658 scope = scope.where("#{Member.table_name}.id IN (SELECT DISTINCT member_id FROM #{MemberRole.table_name} WHERE role_id IN (?))", role_ids)
662 scope = scope.where("#{Member.table_name}.id IN (SELECT DISTINCT member_id FROM #{MemberRole.table_name} WHERE role_id IN (?))", role_ids)
659 end
663 end
660 end
664 end
661 scope.sorted.collect {|u| [u.to_s, u.id.to_s]}
665 scope.sorted.collect {|u| [u.to_s, u.id.to_s]}
662 else
666 else
663 []
667 []
664 end
668 end
665 end
669 end
666
670
667 def before_custom_field_save(custom_field)
671 def before_custom_field_save(custom_field)
668 super
672 super
669 if custom_field.user_role.is_a?(Array)
673 if custom_field.user_role.is_a?(Array)
670 custom_field.user_role.map!(&:to_s).reject!(&:blank?)
674 custom_field.user_role.map!(&:to_s).reject!(&:blank?)
671 end
675 end
672 end
676 end
673 end
677 end
674
678
675 class VersionFormat < RecordList
679 class VersionFormat < RecordList
676 add 'version'
680 add 'version'
677 self.form_partial = 'custom_fields/formats/version'
681 self.form_partial = 'custom_fields/formats/version'
678 field_attributes :version_status
682 field_attributes :version_status
679
683
680 def possible_values_options(custom_field, object=nil)
684 def possible_values_options(custom_field, object=nil)
681 if object.is_a?(Array)
685 if object.is_a?(Array)
682 projects = object.map {|o| o.respond_to?(:project) ? o.project : nil}.compact.uniq
686 projects = object.map {|o| o.respond_to?(:project) ? o.project : nil}.compact.uniq
683 projects.map {|project| possible_values_options(custom_field, project)}.reduce(:&) || []
687 projects.map {|project| possible_values_options(custom_field, project)}.reduce(:&) || []
684 elsif object.respond_to?(:project) && object.project
688 elsif object.respond_to?(:project) && object.project
685 scope = object.project.shared_versions
689 scope = object.project.shared_versions
686 if custom_field.version_status.is_a?(Array)
690 if custom_field.version_status.is_a?(Array)
687 statuses = custom_field.version_status.map(&:to_s).reject(&:blank?)
691 statuses = custom_field.version_status.map(&:to_s).reject(&:blank?)
688 if statuses.any?
692 if statuses.any?
689 scope = scope.where(:status => statuses.map(&:to_s))
693 scope = scope.where(:status => statuses.map(&:to_s))
690 end
694 end
691 end
695 end
692 scope.sort.collect {|u| [u.to_s, u.id.to_s]}
696 scope.sort.collect {|u| [u.to_s, u.id.to_s]}
693 else
697 else
694 []
698 []
695 end
699 end
696 end
700 end
697
701
698 def before_custom_field_save(custom_field)
702 def before_custom_field_save(custom_field)
699 super
703 super
700 if custom_field.version_status.is_a?(Array)
704 if custom_field.version_status.is_a?(Array)
701 custom_field.version_status.map!(&:to_s).reject!(&:blank?)
705 custom_field.version_status.map!(&:to_s).reject!(&:blank?)
702 end
706 end
703 end
707 end
704 end
708 end
705 end
709 end
706 end
710 end
@@ -1,194 +1,202
1 module ObjectHelpers
1 module ObjectHelpers
2 def User.generate!(attributes={})
2 def User.generate!(attributes={})
3 @generated_user_login ||= 'user0'
3 @generated_user_login ||= 'user0'
4 @generated_user_login.succ!
4 @generated_user_login.succ!
5 user = User.new(attributes)
5 user = User.new(attributes)
6 user.login = @generated_user_login.dup if user.login.blank?
6 user.login = @generated_user_login.dup if user.login.blank?
7 user.mail = "#{@generated_user_login}@example.com" if user.mail.blank?
7 user.mail = "#{@generated_user_login}@example.com" if user.mail.blank?
8 user.firstname = "Bob" if user.firstname.blank?
8 user.firstname = "Bob" if user.firstname.blank?
9 user.lastname = "Doe" if user.lastname.blank?
9 user.lastname = "Doe" if user.lastname.blank?
10 yield user if block_given?
10 yield user if block_given?
11 user.save!
11 user.save!
12 user
12 user
13 end
13 end
14
14
15 def User.add_to_project(user, project, roles=nil)
15 def User.add_to_project(user, project, roles=nil)
16 roles = Role.find(1) if roles.nil?
16 roles = Role.find(1) if roles.nil?
17 roles = [roles] if roles.is_a?(Role)
17 roles = [roles] if roles.is_a?(Role)
18 Member.create!(:principal => user, :project => project, :roles => roles)
18 Member.create!(:principal => user, :project => project, :roles => roles)
19 end
19 end
20
20
21 def Group.generate!(attributes={})
21 def Group.generate!(attributes={})
22 @generated_group_name ||= 'Group 0'
22 @generated_group_name ||= 'Group 0'
23 @generated_group_name.succ!
23 @generated_group_name.succ!
24 group = Group.new(attributes)
24 group = Group.new(attributes)
25 group.name = @generated_group_name.dup if group.name.blank?
25 group.name = @generated_group_name.dup if group.name.blank?
26 yield group if block_given?
26 yield group if block_given?
27 group.save!
27 group.save!
28 group
28 group
29 end
29 end
30
30
31 def Project.generate!(attributes={})
31 def Project.generate!(attributes={})
32 @generated_project_identifier ||= 'project-0000'
32 @generated_project_identifier ||= 'project-0000'
33 @generated_project_identifier.succ!
33 @generated_project_identifier.succ!
34 project = Project.new(attributes)
34 project = Project.new(attributes)
35 project.name = @generated_project_identifier.dup if project.name.blank?
35 project.name = @generated_project_identifier.dup if project.name.blank?
36 project.identifier = @generated_project_identifier.dup if project.identifier.blank?
36 project.identifier = @generated_project_identifier.dup if project.identifier.blank?
37 yield project if block_given?
37 yield project if block_given?
38 project.save!
38 project.save!
39 project
39 project
40 end
40 end
41
41
42 def Project.generate_with_parent!(parent, attributes={})
42 def Project.generate_with_parent!(parent, attributes={})
43 project = Project.generate!(attributes)
43 project = Project.generate!(attributes)
44 project.set_parent!(parent)
44 project.set_parent!(parent)
45 project
45 project
46 end
46 end
47
47
48 def Tracker.generate!(attributes={})
48 def Tracker.generate!(attributes={})
49 @generated_tracker_name ||= 'Tracker 0'
49 @generated_tracker_name ||= 'Tracker 0'
50 @generated_tracker_name.succ!
50 @generated_tracker_name.succ!
51 tracker = Tracker.new(attributes)
51 tracker = Tracker.new(attributes)
52 tracker.name = @generated_tracker_name.dup if tracker.name.blank?
52 tracker.name = @generated_tracker_name.dup if tracker.name.blank?
53 yield tracker if block_given?
53 yield tracker if block_given?
54 tracker.save!
54 tracker.save!
55 tracker
55 tracker
56 end
56 end
57
57
58 def Role.generate!(attributes={})
58 def Role.generate!(attributes={})
59 @generated_role_name ||= 'Role 0'
59 @generated_role_name ||= 'Role 0'
60 @generated_role_name.succ!
60 @generated_role_name.succ!
61 role = Role.new(attributes)
61 role = Role.new(attributes)
62 role.name = @generated_role_name.dup if role.name.blank?
62 role.name = @generated_role_name.dup if role.name.blank?
63 yield role if block_given?
63 yield role if block_given?
64 role.save!
64 role.save!
65 role
65 role
66 end
66 end
67
67
68 # Generates an unsaved Issue
68 # Generates an unsaved Issue
69 def Issue.generate(attributes={})
69 def Issue.generate(attributes={})
70 issue = Issue.new(attributes)
70 issue = Issue.new(attributes)
71 issue.project ||= Project.find(1)
71 issue.project ||= Project.find(1)
72 issue.tracker ||= issue.project.trackers.first
72 issue.tracker ||= issue.project.trackers.first
73 issue.subject = 'Generated' if issue.subject.blank?
73 issue.subject = 'Generated' if issue.subject.blank?
74 issue.author ||= User.find(2)
74 issue.author ||= User.find(2)
75 yield issue if block_given?
75 yield issue if block_given?
76 issue
76 issue
77 end
77 end
78
78
79 # Generates a saved Issue
79 # Generates a saved Issue
80 def Issue.generate!(attributes={}, &block)
80 def Issue.generate!(attributes={}, &block)
81 issue = Issue.generate(attributes, &block)
81 issue = Issue.generate(attributes, &block)
82 issue.save!
82 issue.save!
83 issue
83 issue
84 end
84 end
85
85
86 # Generates an issue with 2 children and a grandchild
86 # Generates an issue with 2 children and a grandchild
87 def Issue.generate_with_descendants!(attributes={})
87 def Issue.generate_with_descendants!(attributes={})
88 issue = Issue.generate!(attributes)
88 issue = Issue.generate!(attributes)
89 child = Issue.generate!(:project => issue.project, :subject => 'Child1', :parent_issue_id => issue.id)
89 child = Issue.generate!(:project => issue.project, :subject => 'Child1', :parent_issue_id => issue.id)
90 Issue.generate!(:project => issue.project, :subject => 'Child2', :parent_issue_id => issue.id)
90 Issue.generate!(:project => issue.project, :subject => 'Child2', :parent_issue_id => issue.id)
91 Issue.generate!(:project => issue.project, :subject => 'Child11', :parent_issue_id => child.id)
91 Issue.generate!(:project => issue.project, :subject => 'Child11', :parent_issue_id => child.id)
92 issue.reload
92 issue.reload
93 end
93 end
94
94
95 def Journal.generate!(attributes={})
95 def Journal.generate!(attributes={})
96 journal = Journal.new(attributes)
96 journal = Journal.new(attributes)
97 journal.user ||= User.first
97 journal.user ||= User.first
98 journal.journalized ||= Issue.first
98 journal.journalized ||= Issue.first
99 yield journal if block_given?
99 yield journal if block_given?
100 journal.save!
100 journal.save!
101 journal
101 journal
102 end
102 end
103
103
104 def Version.generate!(attributes={})
104 def Version.generate!(attributes={})
105 @generated_version_name ||= 'Version 0'
105 @generated_version_name ||= 'Version 0'
106 @generated_version_name.succ!
106 @generated_version_name.succ!
107 version = Version.new(attributes)
107 version = Version.new(attributes)
108 version.name = @generated_version_name.dup if version.name.blank?
108 version.name = @generated_version_name.dup if version.name.blank?
109 yield version if block_given?
109 yield version if block_given?
110 version.save!
110 version.save!
111 version
111 version
112 end
112 end
113
113
114 def TimeEntry.generate!(attributes={})
114 def TimeEntry.generate!(attributes={})
115 entry = TimeEntry.new(attributes)
115 entry = TimeEntry.new(attributes)
116 entry.user ||= User.find(2)
116 entry.user ||= User.find(2)
117 entry.issue ||= Issue.find(1) unless entry.project
117 entry.issue ||= Issue.find(1) unless entry.project
118 entry.project ||= entry.issue.project
118 entry.project ||= entry.issue.project
119 entry.activity ||= TimeEntryActivity.first
119 entry.activity ||= TimeEntryActivity.first
120 entry.spent_on ||= Date.today
120 entry.spent_on ||= Date.today
121 entry.hours ||= 1.0
121 entry.hours ||= 1.0
122 entry.save!
122 entry.save!
123 entry
123 entry
124 end
124 end
125
125
126 def AuthSource.generate!(attributes={})
126 def AuthSource.generate!(attributes={})
127 @generated_auth_source_name ||= 'Auth 0'
127 @generated_auth_source_name ||= 'Auth 0'
128 @generated_auth_source_name.succ!
128 @generated_auth_source_name.succ!
129 source = AuthSource.new(attributes)
129 source = AuthSource.new(attributes)
130 source.name = @generated_auth_source_name.dup if source.name.blank?
130 source.name = @generated_auth_source_name.dup if source.name.blank?
131 yield source if block_given?
131 yield source if block_given?
132 source.save!
132 source.save!
133 source
133 source
134 end
134 end
135
135
136 def Board.generate!(attributes={})
136 def Board.generate!(attributes={})
137 @generated_board_name ||= 'Forum 0'
137 @generated_board_name ||= 'Forum 0'
138 @generated_board_name.succ!
138 @generated_board_name.succ!
139 board = Board.new(attributes)
139 board = Board.new(attributes)
140 board.name = @generated_board_name.dup if board.name.blank?
140 board.name = @generated_board_name.dup if board.name.blank?
141 board.description = @generated_board_name.dup if board.description.blank?
141 board.description = @generated_board_name.dup if board.description.blank?
142 yield board if block_given?
142 yield board if block_given?
143 board.save!
143 board.save!
144 board
144 board
145 end
145 end
146
146
147 def Attachment.generate!(attributes={})
147 def Attachment.generate!(attributes={})
148 @generated_filename ||= 'testfile0'
148 @generated_filename ||= 'testfile0'
149 @generated_filename.succ!
149 @generated_filename.succ!
150 attributes = attributes.dup
150 attributes = attributes.dup
151 attachment = Attachment.new(attributes)
151 attachment = Attachment.new(attributes)
152 attachment.container ||= Issue.find(1)
152 attachment.container ||= Issue.find(1)
153 attachment.author ||= User.find(2)
153 attachment.author ||= User.find(2)
154 attachment.filename = @generated_filename.dup if attachment.filename.blank?
154 attachment.filename = @generated_filename.dup if attachment.filename.blank?
155 attachment.save!
155 attachment.save!
156 attachment
156 attachment
157 end
157 end
158
158
159 def CustomField.generate!(attributes={})
159 def CustomField.generate!(attributes={})
160 @generated_custom_field_name ||= 'Custom field 0'
160 @generated_custom_field_name ||= 'Custom field 0'
161 @generated_custom_field_name.succ!
161 @generated_custom_field_name.succ!
162 field = new(attributes)
162 field = new(attributes)
163 field.name = @generated_custom_field_name.dup if field.name.blank?
163 field.name = @generated_custom_field_name.dup if field.name.blank?
164 field.field_format = 'string' if field.field_format.blank?
164 field.field_format = 'string' if field.field_format.blank?
165 yield field if block_given?
165 yield field if block_given?
166 field.save!
166 field.save!
167 field
167 field
168 end
168 end
169
169
170 def Changeset.generate!(attributes={})
170 def Changeset.generate!(attributes={})
171 @generated_changeset_rev ||= '123456'
171 @generated_changeset_rev ||= '123456'
172 @generated_changeset_rev.succ!
172 @generated_changeset_rev.succ!
173 changeset = new(attributes)
173 changeset = new(attributes)
174 changeset.repository ||= Project.find(1).repository
174 changeset.repository ||= Project.find(1).repository
175 changeset.revision ||= @generated_changeset_rev
175 changeset.revision ||= @generated_changeset_rev
176 changeset.committed_on ||= Time.now
176 changeset.committed_on ||= Time.now
177 yield changeset if block_given?
177 yield changeset if block_given?
178 changeset.save!
178 changeset.save!
179 changeset
179 changeset
180 end
180 end
181
182 def Query.generate!(attributes={})
183 query = new(attributes)
184 query.name = "Generated query" if query.name.blank?
185 query.user ||= User.find(1)
186 query.save!
187 query
188 end
181 end
189 end
182
190
183 module IssueObjectHelpers
191 module IssueObjectHelpers
184 def close!
192 def close!
185 self.status = IssueStatus.where(:is_closed => true).first
193 self.status = IssueStatus.where(:is_closed => true).first
186 save!
194 save!
187 end
195 end
188
196
189 def generate_child!(attributes={})
197 def generate_child!(attributes={})
190 Issue.generate!(attributes.merge(:parent_issue_id => self.id))
198 Issue.generate!(attributes.merge(:parent_issue_id => self.id))
191 end
199 end
192 end
200 end
193
201
194 Issue.send :include, IssueObjectHelpers
202 Issue.send :include, IssueObjectHelpers
@@ -1,78 +1,94
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2014 Jean-Philippe Lang
2 # Copyright (C) 2006-2014 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 ProjectsHelperTest < ActionView::TestCase
20 class ProjectsHelperTest < ActionView::TestCase
21 include ApplicationHelper
21 include ApplicationHelper
22 include ProjectsHelper
22 include ProjectsHelper
23 include Redmine::I18n
23 include Redmine::I18n
24 include ERB::Util
24 include ERB::Util
25 include Rails.application.routes.url_helpers
25 include Rails.application.routes.url_helpers
26
26
27 fixtures :projects, :trackers, :issue_statuses, :issues,
27 fixtures :projects, :trackers, :issue_statuses, :issues,
28 :enumerations, :users, :issue_categories,
28 :enumerations, :users, :issue_categories,
29 :versions,
29 :versions,
30 :projects_trackers,
30 :projects_trackers,
31 :member_roles,
31 :member_roles,
32 :members,
32 :members,
33 :groups_users,
33 :groups_users,
34 :enabled_modules
34 :enabled_modules
35
35
36 def setup
36 def setup
37 super
37 super
38 set_language_if_valid('en')
38 set_language_if_valid('en')
39 User.current = nil
39 User.current = nil
40 end
40 end
41
41
42 def test_link_to_version_within_project
42 def test_link_to_version_within_project
43 @project = Project.find(2)
43 @project = Project.find(2)
44 User.current = User.find(1)
44 User.current = User.find(1)
45 assert_equal '<a href="/versions/5">Alpha</a>', link_to_version(Version.find(5))
45 assert_equal '<a href="/versions/5" title="07/01/2006">Alpha</a>', link_to_version(Version.find(5))
46 end
46 end
47
47
48 def test_link_to_version
48 def test_link_to_version
49 User.current = User.find(1)
49 User.current = User.find(1)
50 assert_equal '<a href="/versions/5">OnlineStore - Alpha</a>', link_to_version(Version.find(5))
50 assert_equal '<a href="/versions/5" title="07/01/2006">Alpha</a>', link_to_version(Version.find(5))
51 end
52
53 def test_link_to_version_without_effective_date
54 User.current = User.find(1)
55 version = Version.find(5)
56 version.effective_date = nil
57 assert_equal '<a href="/versions/5">Alpha</a>', link_to_version(version)
51 end
58 end
52
59
53 def test_link_to_private_version
60 def test_link_to_private_version
54 assert_equal 'OnlineStore - Alpha', link_to_version(Version.find(5))
61 assert_equal 'Alpha', link_to_version(Version.find(5))
55 end
62 end
56
63
57 def test_link_to_version_invalid_version
64 def test_link_to_version_invalid_version
58 assert_equal '', link_to_version(Object)
65 assert_equal '', link_to_version(Object)
59 end
66 end
60
67
61 def test_format_version_name_within_project
68 def test_format_version_name_within_project
62 @project = Project.find(1)
69 @project = Project.find(1)
63 assert_equal "0.1", format_version_name(Version.find(1))
70 assert_equal "0.1", format_version_name(Version.find(1))
64 end
71 end
65
72
66 def test_format_version_name
73 def test_format_version_name
67 assert_equal "eCookbook - 0.1", format_version_name(Version.find(1))
74 assert_equal "0.1", format_version_name(Version.find(1))
75 end
76
77 def test_format_version_name_for_shared_version_within_project_should_not_display_project_name
78 @project = Project.find(1)
79 version = Version.find(1)
80 version.sharing = 'system'
81 assert_equal "0.1", format_version_name(version)
68 end
82 end
69
83
70 def test_format_version_name_for_system_version
84 def test_format_version_name_for_shared_version_should_display_project_name
71 assert_equal "OnlineStore - Systemwide visible version", format_version_name(Version.find(7))
85 version = Version.find(1)
86 version.sharing = 'system'
87 assert_equal "eCookbook - 0.1", format_version_name(version)
72 end
88 end
73
89
74 def test_version_options_for_select_with_no_versions
90 def test_version_options_for_select_with_no_versions
75 assert_equal '', version_options_for_select([])
91 assert_equal '', version_options_for_select([])
76 assert_equal '', version_options_for_select([], Version.find(1))
92 assert_equal '', version_options_for_select([], Version.find(1))
77 end
93 end
78 end
94 end
@@ -1,337 +1,348
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2014 Jean-Philippe Lang
2 # Copyright (C) 2006-2014 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 ProjectCopyTest < ActiveSupport::TestCase
20 class ProjectCopyTest < ActiveSupport::TestCase
21 fixtures :projects, :trackers, :issue_statuses, :issues,
21 fixtures :projects, :trackers, :issue_statuses, :issues,
22 :journals, :journal_details,
22 :journals, :journal_details,
23 :enumerations, :users, :issue_categories,
23 :enumerations, :users, :issue_categories,
24 :projects_trackers,
24 :projects_trackers,
25 :custom_fields,
25 :custom_fields,
26 :custom_fields_projects,
26 :custom_fields_projects,
27 :custom_fields_trackers,
27 :custom_fields_trackers,
28 :custom_values,
28 :custom_values,
29 :roles,
29 :roles,
30 :member_roles,
30 :member_roles,
31 :members,
31 :members,
32 :enabled_modules,
32 :enabled_modules,
33 :versions,
33 :versions,
34 :wikis, :wiki_pages, :wiki_contents, :wiki_content_versions,
34 :wikis, :wiki_pages, :wiki_contents, :wiki_content_versions,
35 :groups_users,
35 :groups_users,
36 :boards, :messages,
36 :boards, :messages,
37 :repositories,
37 :repositories,
38 :news, :comments,
38 :news, :comments,
39 :documents
39 :documents
40
40
41 def setup
41 def setup
42 ProjectCustomField.destroy_all
42 ProjectCustomField.destroy_all
43 @source_project = Project.find(2)
43 @source_project = Project.find(2)
44 @project = Project.new(:name => 'Copy Test', :identifier => 'copy-test')
44 @project = Project.new(:name => 'Copy Test', :identifier => 'copy-test')
45 @project.trackers = @source_project.trackers
45 @project.trackers = @source_project.trackers
46 @project.enabled_module_names = @source_project.enabled_modules.collect(&:name)
46 @project.enabled_module_names = @source_project.enabled_modules.collect(&:name)
47 end
47 end
48
48
49 test "#copy should copy issues" do
49 test "#copy should copy issues" do
50 @source_project.issues << Issue.generate!(:status => IssueStatus.find_by_name('Closed'),
50 @source_project.issues << Issue.generate!(:status => IssueStatus.find_by_name('Closed'),
51 :subject => "copy issue status",
51 :subject => "copy issue status",
52 :tracker_id => 1,
52 :tracker_id => 1,
53 :assigned_to_id => 2,
53 :assigned_to_id => 2,
54 :project_id => @source_project.id)
54 :project_id => @source_project.id)
55 assert @project.valid?
55 assert @project.valid?
56 assert @project.issues.empty?
56 assert @project.issues.empty?
57 assert @project.copy(@source_project)
57 assert @project.copy(@source_project)
58
58
59 assert_equal @source_project.issues.size, @project.issues.size
59 assert_equal @source_project.issues.size, @project.issues.size
60 @project.issues.each do |issue|
60 @project.issues.each do |issue|
61 assert issue.valid?
61 assert issue.valid?
62 assert ! issue.assigned_to.blank?
62 assert ! issue.assigned_to.blank?
63 assert_equal @project, issue.project
63 assert_equal @project, issue.project
64 end
64 end
65
65
66 copied_issue = @project.issues.where(:subject => "copy issue status").first
66 copied_issue = @project.issues.where(:subject => "copy issue status").first
67 assert copied_issue
67 assert copied_issue
68 assert copied_issue.status
68 assert copied_issue.status
69 assert_equal "Closed", copied_issue.status.name
69 assert_equal "Closed", copied_issue.status.name
70 end
70 end
71
71
72 test "#copy should copy issues custom values" do
72 test "#copy should copy issues custom values" do
73 field = IssueCustomField.generate!(:is_for_all => true, :trackers => Tracker.all)
73 field = IssueCustomField.generate!(:is_for_all => true, :trackers => Tracker.all)
74 issue = Issue.generate!(:project => @source_project, :subject => 'Custom field copy')
74 issue = Issue.generate!(:project => @source_project, :subject => 'Custom field copy')
75 issue.custom_field_values = {field.id => 'custom'}
75 issue.custom_field_values = {field.id => 'custom'}
76 issue.save!
76 issue.save!
77 assert_equal 'custom', issue.reload.custom_field_value(field)
77 assert_equal 'custom', issue.reload.custom_field_value(field)
78
78
79 assert @project.copy(@source_project)
79 assert @project.copy(@source_project)
80 copy = @project.issues.find_by_subject('Custom field copy')
80 copy = @project.issues.find_by_subject('Custom field copy')
81 assert copy
81 assert copy
82 assert_equal 'custom', copy.reload.custom_field_value(field)
82 assert_equal 'custom', copy.reload.custom_field_value(field)
83 end
83 end
84
84
85 test "#copy should copy issues assigned to a locked version" do
85 test "#copy should copy issues assigned to a locked version" do
86 User.current = User.find(1)
86 User.current = User.find(1)
87 assigned_version = Version.generate!(:name => "Assigned Issues")
87 assigned_version = Version.generate!(:name => "Assigned Issues")
88 @source_project.versions << assigned_version
88 @source_project.versions << assigned_version
89 Issue.generate!(:project => @source_project,
89 Issue.generate!(:project => @source_project,
90 :fixed_version_id => assigned_version.id,
90 :fixed_version_id => assigned_version.id,
91 :subject => "copy issues assigned to a locked version")
91 :subject => "copy issues assigned to a locked version")
92 assigned_version.update_attribute :status, 'locked'
92 assigned_version.update_attribute :status, 'locked'
93
93
94 assert @project.copy(@source_project)
94 assert @project.copy(@source_project)
95 @project.reload
95 @project.reload
96 copied_issue = @project.issues.where(:subject => "copy issues assigned to a locked version").first
96 copied_issue = @project.issues.where(:subject => "copy issues assigned to a locked version").first
97
97
98 assert copied_issue
98 assert copied_issue
99 assert copied_issue.fixed_version
99 assert copied_issue.fixed_version
100 assert_equal "Assigned Issues", copied_issue.fixed_version.name # Same name
100 assert_equal "Assigned Issues", copied_issue.fixed_version.name # Same name
101 assert_equal 'locked', copied_issue.fixed_version.status
101 assert_equal 'locked', copied_issue.fixed_version.status
102 end
102 end
103
103
104 test "#copy should change the new issues to use the copied version" do
104 test "#copy should change the new issues to use the copied version" do
105 User.current = User.find(1)
105 User.current = User.find(1)
106 assigned_version = Version.generate!(:name => "Assigned Issues", :status => 'open')
106 assigned_version = Version.generate!(:name => "Assigned Issues", :status => 'open')
107 @source_project.versions << assigned_version
107 @source_project.versions << assigned_version
108 assert_equal 3, @source_project.versions.size
108 assert_equal 3, @source_project.versions.size
109 Issue.generate!(:project => @source_project,
109 Issue.generate!(:project => @source_project,
110 :fixed_version_id => assigned_version.id,
110 :fixed_version_id => assigned_version.id,
111 :subject => "change the new issues to use the copied version")
111 :subject => "change the new issues to use the copied version")
112
112
113 assert @project.copy(@source_project)
113 assert @project.copy(@source_project)
114 @project.reload
114 @project.reload
115 copied_issue = @project.issues.where(:subject => "change the new issues to use the copied version").first
115 copied_issue = @project.issues.where(:subject => "change the new issues to use the copied version").first
116
116
117 assert copied_issue
117 assert copied_issue
118 assert copied_issue.fixed_version
118 assert copied_issue.fixed_version
119 assert_equal "Assigned Issues", copied_issue.fixed_version.name # Same name
119 assert_equal "Assigned Issues", copied_issue.fixed_version.name # Same name
120 assert_not_equal assigned_version.id, copied_issue.fixed_version.id # Different record
120 assert_not_equal assigned_version.id, copied_issue.fixed_version.id # Different record
121 end
121 end
122
122
123 test "#copy should keep target shared versions from other project" do
123 test "#copy should keep target shared versions from other project" do
124 assigned_version = Version.generate!(:name => "Assigned Issues", :status => 'open', :project_id => 1, :sharing => 'system')
124 assigned_version = Version.generate!(:name => "Assigned Issues", :status => 'open', :project_id => 1, :sharing => 'system')
125 issue = Issue.generate!(:project => @source_project,
125 issue = Issue.generate!(:project => @source_project,
126 :fixed_version => assigned_version,
126 :fixed_version => assigned_version,
127 :subject => "keep target shared versions")
127 :subject => "keep target shared versions")
128
128
129 assert @project.copy(@source_project)
129 assert @project.copy(@source_project)
130 @project.reload
130 @project.reload
131 copied_issue = @project.issues.where(:subject => "keep target shared versions").first
131 copied_issue = @project.issues.where(:subject => "keep target shared versions").first
132
132
133 assert copied_issue
133 assert copied_issue
134 assert_equal assigned_version, copied_issue.fixed_version
134 assert_equal assigned_version, copied_issue.fixed_version
135 end
135 end
136
136
137 test "#copy should copy issue relations" do
137 test "#copy should copy issue relations" do
138 Setting.cross_project_issue_relations = '1'
138 Setting.cross_project_issue_relations = '1'
139
139
140 second_issue = Issue.generate!(:status_id => 5,
140 second_issue = Issue.generate!(:status_id => 5,
141 :subject => "copy issue relation",
141 :subject => "copy issue relation",
142 :tracker_id => 1,
142 :tracker_id => 1,
143 :assigned_to_id => 2,
143 :assigned_to_id => 2,
144 :project_id => @source_project.id)
144 :project_id => @source_project.id)
145 source_relation = IssueRelation.create!(:issue_from => Issue.find(4),
145 source_relation = IssueRelation.create!(:issue_from => Issue.find(4),
146 :issue_to => second_issue,
146 :issue_to => second_issue,
147 :relation_type => "relates")
147 :relation_type => "relates")
148 source_relation_cross_project = IssueRelation.create!(:issue_from => Issue.find(1),
148 source_relation_cross_project = IssueRelation.create!(:issue_from => Issue.find(1),
149 :issue_to => second_issue,
149 :issue_to => second_issue,
150 :relation_type => "duplicates")
150 :relation_type => "duplicates")
151
151
152 assert @project.copy(@source_project)
152 assert @project.copy(@source_project)
153 assert_equal @source_project.issues.count, @project.issues.count
153 assert_equal @source_project.issues.count, @project.issues.count
154 copied_issue = @project.issues.find_by_subject("Issue on project 2") # Was #4
154 copied_issue = @project.issues.find_by_subject("Issue on project 2") # Was #4
155 copied_second_issue = @project.issues.find_by_subject("copy issue relation")
155 copied_second_issue = @project.issues.find_by_subject("copy issue relation")
156
156
157 # First issue with a relation on project
157 # First issue with a relation on project
158 assert_equal 1, copied_issue.relations.size, "Relation not copied"
158 assert_equal 1, copied_issue.relations.size, "Relation not copied"
159 copied_relation = copied_issue.relations.first
159 copied_relation = copied_issue.relations.first
160 assert_equal "relates", copied_relation.relation_type
160 assert_equal "relates", copied_relation.relation_type
161 assert_equal copied_second_issue.id, copied_relation.issue_to_id
161 assert_equal copied_second_issue.id, copied_relation.issue_to_id
162 assert_not_equal source_relation.id, copied_relation.id
162 assert_not_equal source_relation.id, copied_relation.id
163
163
164 # Second issue with a cross project relation
164 # Second issue with a cross project relation
165 assert_equal 2, copied_second_issue.relations.size, "Relation not copied"
165 assert_equal 2, copied_second_issue.relations.size, "Relation not copied"
166 copied_relation = copied_second_issue.relations.select {|r| r.relation_type == 'duplicates'}.first
166 copied_relation = copied_second_issue.relations.select {|r| r.relation_type == 'duplicates'}.first
167 assert_equal "duplicates", copied_relation.relation_type
167 assert_equal "duplicates", copied_relation.relation_type
168 assert_equal 1, copied_relation.issue_from_id, "Cross project relation not kept"
168 assert_equal 1, copied_relation.issue_from_id, "Cross project relation not kept"
169 assert_not_equal source_relation_cross_project.id, copied_relation.id
169 assert_not_equal source_relation_cross_project.id, copied_relation.id
170 end
170 end
171
171
172 test "#copy should copy issue attachments" do
172 test "#copy should copy issue attachments" do
173 issue = Issue.generate!(:subject => "copy with attachment", :tracker_id => 1, :project_id => @source_project.id)
173 issue = Issue.generate!(:subject => "copy with attachment", :tracker_id => 1, :project_id => @source_project.id)
174 Attachment.create!(:container => issue, :file => uploaded_test_file("testfile.txt", "text/plain"), :author_id => 1)
174 Attachment.create!(:container => issue, :file => uploaded_test_file("testfile.txt", "text/plain"), :author_id => 1)
175 @source_project.issues << issue
175 @source_project.issues << issue
176 assert @project.copy(@source_project)
176 assert @project.copy(@source_project)
177
177
178 copied_issue = @project.issues.where(:subject => "copy with attachment").first
178 copied_issue = @project.issues.where(:subject => "copy with attachment").first
179 assert_not_nil copied_issue
179 assert_not_nil copied_issue
180 assert_equal 1, copied_issue.attachments.count, "Attachment not copied"
180 assert_equal 1, copied_issue.attachments.count, "Attachment not copied"
181 assert_equal "testfile.txt", copied_issue.attachments.first.filename
181 assert_equal "testfile.txt", copied_issue.attachments.first.filename
182 end
182 end
183
183
184 test "#copy should copy memberships" do
184 test "#copy should copy memberships" do
185 assert @project.valid?
185 assert @project.valid?
186 assert @project.members.empty?
186 assert @project.members.empty?
187 assert @project.copy(@source_project)
187 assert @project.copy(@source_project)
188
188
189 assert_equal @source_project.memberships.size, @project.memberships.size
189 assert_equal @source_project.memberships.size, @project.memberships.size
190 @project.memberships.each do |membership|
190 @project.memberships.each do |membership|
191 assert membership
191 assert membership
192 assert_equal @project, membership.project
192 assert_equal @project, membership.project
193 end
193 end
194 end
194 end
195
195
196 test "#copy should copy memberships with groups and additional roles" do
196 test "#copy should copy memberships with groups and additional roles" do
197 group = Group.create!(:lastname => "Copy group")
197 group = Group.create!(:lastname => "Copy group")
198 user = User.find(7)
198 user = User.find(7)
199 group.users << user
199 group.users << user
200 # group role
200 # group role
201 Member.create!(:project_id => @source_project.id, :principal => group, :role_ids => [2])
201 Member.create!(:project_id => @source_project.id, :principal => group, :role_ids => [2])
202 member = Member.find_by_user_id_and_project_id(user.id, @source_project.id)
202 member = Member.find_by_user_id_and_project_id(user.id, @source_project.id)
203 # additional role
203 # additional role
204 member.role_ids = [1]
204 member.role_ids = [1]
205
205
206 assert @project.copy(@source_project)
206 assert @project.copy(@source_project)
207 member = Member.find_by_user_id_and_project_id(user.id, @project.id)
207 member = Member.find_by_user_id_and_project_id(user.id, @project.id)
208 assert_not_nil member
208 assert_not_nil member
209 assert_equal [1, 2], member.role_ids.sort
209 assert_equal [1, 2], member.role_ids.sort
210 end
210 end
211
211
212 test "#copy should copy project specific queries" do
212 test "#copy should copy project specific queries" do
213 assert @project.valid?
213 assert @project.valid?
214 assert @project.queries.empty?
214 assert @project.queries.empty?
215 assert @project.copy(@source_project)
215 assert @project.copy(@source_project)
216
216
217 assert_equal @source_project.queries.size, @project.queries.size
217 assert_equal @source_project.queries.size, @project.queries.size
218 @project.queries.each do |query|
218 @project.queries.each do |query|
219 assert query
219 assert query
220 assert_equal @project, query.project
220 assert_equal @project, query.project
221 end
221 end
222 assert_equal @source_project.queries.map(&:user_id).sort, @project.queries.map(&:user_id).sort
222 assert_equal @source_project.queries.map(&:user_id).sort, @project.queries.map(&:user_id).sort
223 end
223 end
224
224
225 def test_copy_should_copy_queries_roles_visibility
226 source = Project.generate!
227 target = Project.new(:name => 'Copy Test', :identifier => 'copy-test')
228 IssueQuery.generate!(:project => source, :visibility => Query::VISIBILITY_ROLES, :roles => Role.where(:id => [1, 3]).to_a)
229
230 assert target.copy(source)
231 assert_equal 1, target.queries.size
232 query = target.queries.first
233 assert_equal [1, 3], query.role_ids.sort
234 end
235
225 test "#copy should copy versions" do
236 test "#copy should copy versions" do
226 @source_project.versions << Version.generate!
237 @source_project.versions << Version.generate!
227 @source_project.versions << Version.generate!
238 @source_project.versions << Version.generate!
228
239
229 assert @project.versions.empty?
240 assert @project.versions.empty?
230 assert @project.copy(@source_project)
241 assert @project.copy(@source_project)
231
242
232 assert_equal @source_project.versions.size, @project.versions.size
243 assert_equal @source_project.versions.size, @project.versions.size
233 @project.versions.each do |version|
244 @project.versions.each do |version|
234 assert version
245 assert version
235 assert_equal @project, version.project
246 assert_equal @project, version.project
236 end
247 end
237 end
248 end
238
249
239 test "#copy should copy wiki" do
250 test "#copy should copy wiki" do
240 assert_difference 'Wiki.count' do
251 assert_difference 'Wiki.count' do
241 assert @project.copy(@source_project)
252 assert @project.copy(@source_project)
242 end
253 end
243
254
244 assert @project.wiki
255 assert @project.wiki
245 assert_not_equal @source_project.wiki, @project.wiki
256 assert_not_equal @source_project.wiki, @project.wiki
246 assert_equal "Start page", @project.wiki.start_page
257 assert_equal "Start page", @project.wiki.start_page
247 end
258 end
248
259
249 test "#copy should copy wiki without wiki module" do
260 test "#copy should copy wiki without wiki module" do
250 project = Project.new(:name => 'Copy Test', :identifier => 'copy-test', :enabled_module_names => [])
261 project = Project.new(:name => 'Copy Test', :identifier => 'copy-test', :enabled_module_names => [])
251 assert_difference 'Wiki.count' do
262 assert_difference 'Wiki.count' do
252 assert project.copy(@source_project)
263 assert project.copy(@source_project)
253 end
264 end
254
265
255 assert project.wiki
266 assert project.wiki
256 end
267 end
257
268
258 test "#copy should copy wiki pages and content with hierarchy" do
269 test "#copy should copy wiki pages and content with hierarchy" do
259 assert_difference 'WikiPage.count', @source_project.wiki.pages.size do
270 assert_difference 'WikiPage.count', @source_project.wiki.pages.size do
260 assert @project.copy(@source_project)
271 assert @project.copy(@source_project)
261 end
272 end
262
273
263 assert @project.wiki
274 assert @project.wiki
264 assert_equal @source_project.wiki.pages.size, @project.wiki.pages.size
275 assert_equal @source_project.wiki.pages.size, @project.wiki.pages.size
265
276
266 @project.wiki.pages.each do |wiki_page|
277 @project.wiki.pages.each do |wiki_page|
267 assert wiki_page.content
278 assert wiki_page.content
268 assert !@source_project.wiki.pages.include?(wiki_page)
279 assert !@source_project.wiki.pages.include?(wiki_page)
269 end
280 end
270
281
271 parent = @project.wiki.find_page('Parent_page')
282 parent = @project.wiki.find_page('Parent_page')
272 child1 = @project.wiki.find_page('Child_page_1')
283 child1 = @project.wiki.find_page('Child_page_1')
273 child2 = @project.wiki.find_page('Child_page_2')
284 child2 = @project.wiki.find_page('Child_page_2')
274 assert_equal parent, child1.parent
285 assert_equal parent, child1.parent
275 assert_equal parent, child2.parent
286 assert_equal parent, child2.parent
276 end
287 end
277
288
278 test "#copy should copy issue categories" do
289 test "#copy should copy issue categories" do
279 assert @project.copy(@source_project)
290 assert @project.copy(@source_project)
280
291
281 assert_equal 2, @project.issue_categories.size
292 assert_equal 2, @project.issue_categories.size
282 @project.issue_categories.each do |issue_category|
293 @project.issue_categories.each do |issue_category|
283 assert !@source_project.issue_categories.include?(issue_category)
294 assert !@source_project.issue_categories.include?(issue_category)
284 end
295 end
285 end
296 end
286
297
287 test "#copy should copy boards" do
298 test "#copy should copy boards" do
288 assert @project.copy(@source_project)
299 assert @project.copy(@source_project)
289
300
290 assert_equal 1, @project.boards.size
301 assert_equal 1, @project.boards.size
291 @project.boards.each do |board|
302 @project.boards.each do |board|
292 assert !@source_project.boards.include?(board)
303 assert !@source_project.boards.include?(board)
293 end
304 end
294 end
305 end
295
306
296 test "#copy should change the new issues to use the copied issue categories" do
307 test "#copy should change the new issues to use the copied issue categories" do
297 issue = Issue.find(4)
308 issue = Issue.find(4)
298 issue.update_attribute(:category_id, 3)
309 issue.update_attribute(:category_id, 3)
299
310
300 assert @project.copy(@source_project)
311 assert @project.copy(@source_project)
301
312
302 @project.issues.each do |issue|
313 @project.issues.each do |issue|
303 assert issue.category
314 assert issue.category
304 assert_equal "Stock management", issue.category.name # Same name
315 assert_equal "Stock management", issue.category.name # Same name
305 assert_not_equal IssueCategory.find(3), issue.category # Different record
316 assert_not_equal IssueCategory.find(3), issue.category # Different record
306 end
317 end
307 end
318 end
308
319
309 test "#copy should limit copy with :only option" do
320 test "#copy should limit copy with :only option" do
310 assert @project.members.empty?
321 assert @project.members.empty?
311 assert @project.issue_categories.empty?
322 assert @project.issue_categories.empty?
312 assert @source_project.issues.any?
323 assert @source_project.issues.any?
313
324
314 assert @project.copy(@source_project, :only => ['members', 'issue_categories'])
325 assert @project.copy(@source_project, :only => ['members', 'issue_categories'])
315
326
316 assert @project.members.any?
327 assert @project.members.any?
317 assert @project.issue_categories.any?
328 assert @project.issue_categories.any?
318 assert @project.issues.empty?
329 assert @project.issues.empty?
319 end
330 end
320
331
321 test "#copy should copy subtasks" do
332 test "#copy should copy subtasks" do
322 source = Project.generate!(:tracker_ids => [1])
333 source = Project.generate!(:tracker_ids => [1])
323 issue = Issue.generate_with_descendants!(:project => source)
334 issue = Issue.generate_with_descendants!(:project => source)
324 project = Project.new(:name => 'Copy', :identifier => 'copy', :tracker_ids => [1])
335 project = Project.new(:name => 'Copy', :identifier => 'copy', :tracker_ids => [1])
325
336
326 assert_difference 'Project.count' do
337 assert_difference 'Project.count' do
327 assert_difference 'Issue.count', 1+issue.descendants.count do
338 assert_difference 'Issue.count', 1+issue.descendants.count do
328 assert project.copy(source.reload)
339 assert project.copy(source.reload)
329 end
340 end
330 end
341 end
331 copy = Issue.where(:parent_id => nil).order("id DESC").first
342 copy = Issue.where(:parent_id => nil).order("id DESC").first
332 assert_equal project, copy.project
343 assert_equal project, copy.project
333 assert_equal issue.descendants.count, copy.descendants.count
344 assert_equal issue.descendants.count, copy.descendants.count
334 child_copy = copy.children.detect {|c| c.subject == 'Child1'}
345 child_copy = copy.children.detect {|c| c.subject == 'Child1'}
335 assert child_copy.descendants.any?
346 assert child_copy.descendants.any?
336 end
347 end
337 end
348 end
General Comments 0
You need to be logged in to leave comments. Login now