##// END OF EJS Templates
Merged r9719, r9726, r9727 from trunk....
Jean-Philippe Lang -
r9546:bc945d78a625
parent child
Show More
@@ -1,75 +1,75
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class ActivitiesController < ApplicationController
19 19 menu_item :activity
20 20 before_filter :find_optional_project
21 21 accept_rss_auth :index
22 22
23 23 def index
24 24 @days = Setting.activity_days_default.to_i
25 25
26 26 if params[:from]
27 27 begin; @date_to = params[:from].to_date + 1; rescue; end
28 28 end
29 29
30 30 @date_to ||= Date.today + 1
31 31 @date_from = @date_to - @days
32 32 @with_subprojects = params[:with_subprojects].nil? ? Setting.display_subprojects_issues? : (params[:with_subprojects] == '1')
33 33 @author = (params[:user_id].blank? ? nil : User.active.find(params[:user_id]))
34 34
35 35 @activity = Redmine::Activity::Fetcher.new(User.current, :project => @project,
36 36 :with_subprojects => @with_subprojects,
37 37 :author => @author)
38 38 @activity.scope_select {|t| !params["show_#{t}"].nil?}
39 39 @activity.scope = (@author.nil? ? :default : :all) if @activity.scope.empty?
40 40
41 41 events = @activity.events(@date_from, @date_to)
42 42
43 43 if events.empty? || stale?(:etag => [@activity.scope, @date_to, @date_from, @with_subprojects, @author, events.first, User.current, current_language])
44 44 respond_to do |format|
45 45 format.html {
46 @events_by_day = events.group_by(&:event_date)
46 @events_by_day = events.group_by {|event| User.current.time_to_date(event.event_datetime)}
47 47 render :layout => false if request.xhr?
48 48 }
49 49 format.atom {
50 50 title = l(:label_activity)
51 51 if @author
52 52 title = @author.name
53 53 elsif @activity.scope.size == 1
54 54 title = l("label_#{@activity.scope.first.singularize}_plural")
55 55 end
56 56 render_feed(events, :title => "#{@project || Setting.app_title}: #{title}")
57 57 }
58 58 end
59 59 end
60 60
61 61 rescue ActiveRecord::RecordNotFound
62 62 render_404
63 63 end
64 64
65 65 private
66 66
67 67 # TODO: refactor, duplicated in projects_controller
68 68 def find_optional_project
69 69 return true unless params[:id]
70 70 @project = Project.find(params[:id])
71 71 authorize
72 72 rescue ActiveRecord::RecordNotFound
73 73 render_404
74 74 end
75 75 end
@@ -1,1207 +1,1207
1 1 # encoding: utf-8
2 2 #
3 3 # Redmine - project management software
4 4 # Copyright (C) 2006-2012 Jean-Philippe Lang
5 5 #
6 6 # This program is free software; you can redistribute it and/or
7 7 # modify it under the terms of the GNU General Public License
8 8 # as published by the Free Software Foundation; either version 2
9 9 # of the License, or (at your option) any later version.
10 10 #
11 11 # This program is distributed in the hope that it will be useful,
12 12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 14 # GNU General Public License for more details.
15 15 #
16 16 # You should have received a copy of the GNU General Public License
17 17 # along with this program; if not, write to the Free Software
18 18 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19 19
20 20 require 'forwardable'
21 21 require 'cgi'
22 22
23 23 module ApplicationHelper
24 24 include Redmine::WikiFormatting::Macros::Definitions
25 25 include Redmine::I18n
26 26 include GravatarHelper::PublicMethods
27 27
28 28 extend Forwardable
29 29 def_delegators :wiki_helper, :wikitoolbar_for, :heads_for_wiki_formatter
30 30
31 31 # Return true if user is authorized for controller/action, otherwise false
32 32 def authorize_for(controller, action)
33 33 User.current.allowed_to?({:controller => controller, :action => action}, @project)
34 34 end
35 35
36 36 # Display a link if user is authorized
37 37 #
38 38 # @param [String] name Anchor text (passed to link_to)
39 39 # @param [Hash] options Hash params. This will checked by authorize_for to see if the user is authorized
40 40 # @param [optional, Hash] html_options Options passed to link_to
41 41 # @param [optional, Hash] parameters_for_method_reference Extra parameters for link_to
42 42 def link_to_if_authorized(name, options = {}, html_options = nil, *parameters_for_method_reference)
43 43 link_to(name, options, html_options, *parameters_for_method_reference) if authorize_for(options[:controller] || params[:controller], options[:action])
44 44 end
45 45
46 46 # Display a link to remote if user is authorized
47 47 def link_to_remote_if_authorized(name, options = {}, html_options = nil)
48 48 url = options[:url] || {}
49 49 link_to_remote(name, options, html_options) if authorize_for(url[:controller] || params[:controller], url[:action])
50 50 end
51 51
52 52 # Displays a link to user's account page if active
53 53 def link_to_user(user, options={})
54 54 if user.is_a?(User)
55 55 name = h(user.name(options[:format]))
56 56 if user.active?
57 57 link_to name, :controller => 'users', :action => 'show', :id => user
58 58 else
59 59 name
60 60 end
61 61 else
62 62 h(user.to_s)
63 63 end
64 64 end
65 65
66 66 # Displays a link to +issue+ with its subject.
67 67 # Examples:
68 68 #
69 69 # link_to_issue(issue) # => Defect #6: This is the subject
70 70 # link_to_issue(issue, :truncate => 6) # => Defect #6: This i...
71 71 # link_to_issue(issue, :subject => false) # => Defect #6
72 72 # link_to_issue(issue, :project => true) # => Foo - Defect #6
73 73 #
74 74 def link_to_issue(issue, options={})
75 75 title = nil
76 76 subject = nil
77 77 if options[:subject] == false
78 78 title = truncate(issue.subject, :length => 60)
79 79 else
80 80 subject = issue.subject
81 81 if options[:truncate]
82 82 subject = truncate(subject, :length => options[:truncate])
83 83 end
84 84 end
85 85 s = link_to "#{h(issue.tracker)} ##{issue.id}", {:controller => "issues", :action => "show", :id => issue},
86 86 :class => issue.css_classes,
87 87 :title => title
88 88 s << h(": #{subject}") if subject
89 89 s = h("#{issue.project} - ") + s if options[:project]
90 90 s
91 91 end
92 92
93 93 # Generates a link to an attachment.
94 94 # Options:
95 95 # * :text - Link text (default to attachment filename)
96 96 # * :download - Force download (default: false)
97 97 def link_to_attachment(attachment, options={})
98 98 text = options.delete(:text) || attachment.filename
99 99 action = options.delete(:download) ? 'download' : 'show'
100 100 opt_only_path = {}
101 101 opt_only_path[:only_path] = (options[:only_path] == false ? false : true)
102 102 options.delete(:only_path)
103 103 link_to(h(text),
104 104 {:controller => 'attachments', :action => action,
105 105 :id => attachment, :filename => attachment.filename}.merge(opt_only_path),
106 106 options)
107 107 end
108 108
109 109 # Generates a link to a SCM revision
110 110 # Options:
111 111 # * :text - Link text (default to the formatted revision)
112 112 def link_to_revision(revision, repository, options={})
113 113 if repository.is_a?(Project)
114 114 repository = repository.repository
115 115 end
116 116 text = options.delete(:text) || format_revision(revision)
117 117 rev = revision.respond_to?(:identifier) ? revision.identifier : revision
118 118 link_to(
119 119 h(text),
120 120 {:controller => 'repositories', :action => 'revision', :id => repository.project, :repository_id => repository.identifier_param, :rev => rev},
121 121 :title => l(:label_revision_id, format_revision(revision))
122 122 )
123 123 end
124 124
125 125 # Generates a link to a message
126 126 def link_to_message(message, options={}, html_options = nil)
127 127 link_to(
128 128 h(truncate(message.subject, :length => 60)),
129 129 { :controller => 'messages', :action => 'show',
130 130 :board_id => message.board_id,
131 131 :id => message.root,
132 132 :r => (message.parent_id && message.id),
133 133 :anchor => (message.parent_id ? "message-#{message.id}" : nil)
134 134 }.merge(options),
135 135 html_options
136 136 )
137 137 end
138 138
139 139 # Generates a link to a project if active
140 140 # Examples:
141 141 #
142 142 # link_to_project(project) # => link to the specified project overview
143 143 # link_to_project(project, :action=>'settings') # => link to project settings
144 144 # link_to_project(project, {:only_path => false}, :class => "project") # => 3rd arg adds html options
145 145 # link_to_project(project, {}, :class => "project") # => html options with default url (project overview)
146 146 #
147 147 def link_to_project(project, options={}, html_options = nil)
148 148 if project.active?
149 149 url = {:controller => 'projects', :action => 'show', :id => project}.merge(options)
150 150 link_to(h(project), url, html_options)
151 151 else
152 152 h(project)
153 153 end
154 154 end
155 155
156 156 def toggle_link(name, id, options={})
157 157 onclick = "Element.toggle('#{id}'); "
158 158 onclick << (options[:focus] ? "Form.Element.focus('#{options[:focus]}'); " : "this.blur(); ")
159 159 onclick << "return false;"
160 160 link_to(name, "#", :onclick => onclick)
161 161 end
162 162
163 163 def image_to_function(name, function, html_options = {})
164 164 html_options.symbolize_keys!
165 165 tag(:input, html_options.merge({
166 166 :type => "image", :src => image_path(name),
167 167 :onclick => (html_options[:onclick] ? "#{html_options[:onclick]}; " : "") + "#{function};"
168 168 }))
169 169 end
170 170
171 171 def prompt_to_remote(name, text, param, url, html_options = {})
172 172 html_options[:onclick] = "promptToRemote('#{text}', '#{param}', '#{url_for(url)}'); return false;"
173 173 link_to name, {}, html_options
174 174 end
175 175
176 176 def format_activity_title(text)
177 177 h(truncate_single_line(text, :length => 100))
178 178 end
179 179
180 180 def format_activity_day(date)
181 date == Date.today ? l(:label_today).titleize : format_date(date)
181 date == User.current.today ? l(:label_today).titleize : format_date(date)
182 182 end
183 183
184 184 def format_activity_description(text)
185 185 h(truncate(text.to_s, :length => 120).gsub(%r{[\r\n]*<(pre|code)>.*$}m, '...')
186 186 ).gsub(/[\r\n]+/, "<br />").html_safe
187 187 end
188 188
189 189 def format_version_name(version)
190 190 if version.project == @project
191 191 h(version)
192 192 else
193 193 h("#{version.project} - #{version}")
194 194 end
195 195 end
196 196
197 197 def due_date_distance_in_words(date)
198 198 if date
199 199 l((date < Date.today ? :label_roadmap_overdue : :label_roadmap_due_in), distance_of_date_in_words(Date.today, date))
200 200 end
201 201 end
202 202
203 203 def render_page_hierarchy(pages, node=nil, options={})
204 204 content = ''
205 205 if pages[node]
206 206 content << "<ul class=\"pages-hierarchy\">\n"
207 207 pages[node].each do |page|
208 208 content << "<li>"
209 209 content << link_to(h(page.pretty_title), {:controller => 'wiki', :action => 'show', :project_id => page.project, :id => page.title},
210 210 :title => (options[:timestamp] && page.updated_on ? l(:label_updated_time, distance_of_time_in_words(Time.now, page.updated_on)) : nil))
211 211 content << "\n" + render_page_hierarchy(pages, page.id, options) if pages[page.id]
212 212 content << "</li>\n"
213 213 end
214 214 content << "</ul>\n"
215 215 end
216 216 content.html_safe
217 217 end
218 218
219 219 # Renders flash messages
220 220 def render_flash_messages
221 221 s = ''
222 222 flash.each do |k,v|
223 223 s << content_tag('div', v.html_safe, :class => "flash #{k}", :id => "flash_#{k}")
224 224 end
225 225 s.html_safe
226 226 end
227 227
228 228 # Renders tabs and their content
229 229 def render_tabs(tabs)
230 230 if tabs.any?
231 231 render :partial => 'common/tabs', :locals => {:tabs => tabs}
232 232 else
233 233 content_tag 'p', l(:label_no_data), :class => "nodata"
234 234 end
235 235 end
236 236
237 237 # Renders the project quick-jump box
238 238 def render_project_jump_box
239 239 return unless User.current.logged?
240 240 projects = User.current.memberships.collect(&:project).compact.uniq
241 241 if projects.any?
242 242 s = '<select onchange="if (this.value != \'\') { window.location = this.value; }">' +
243 243 "<option value=''>#{ l(:label_jump_to_a_project) }</option>" +
244 244 '<option value="" disabled="disabled">---</option>'
245 245 s << project_tree_options_for_select(projects, :selected => @project) do |p|
246 246 { :value => url_for(:controller => 'projects', :action => 'show', :id => p, :jump => current_menu_item) }
247 247 end
248 248 s << '</select>'
249 249 s.html_safe
250 250 end
251 251 end
252 252
253 253 def project_tree_options_for_select(projects, options = {})
254 254 s = ''
255 255 project_tree(projects) do |project, level|
256 256 name_prefix = (level > 0 ? ('&nbsp;' * 2 * level + '&#187; ').html_safe : '')
257 257 tag_options = {:value => project.id}
258 258 if project == options[:selected] || (options[:selected].respond_to?(:include?) && options[:selected].include?(project))
259 259 tag_options[:selected] = 'selected'
260 260 else
261 261 tag_options[:selected] = nil
262 262 end
263 263 tag_options.merge!(yield(project)) if block_given?
264 264 s << content_tag('option', name_prefix + h(project), tag_options)
265 265 end
266 266 s.html_safe
267 267 end
268 268
269 269 # Yields the given block for each project with its level in the tree
270 270 #
271 271 # Wrapper for Project#project_tree
272 272 def project_tree(projects, &block)
273 273 Project.project_tree(projects, &block)
274 274 end
275 275
276 276 def project_nested_ul(projects, &block)
277 277 s = ''
278 278 if projects.any?
279 279 ancestors = []
280 280 projects.sort_by(&:lft).each do |project|
281 281 if (ancestors.empty? || project.is_descendant_of?(ancestors.last))
282 282 s << "<ul>\n"
283 283 else
284 284 ancestors.pop
285 285 s << "</li>"
286 286 while (ancestors.any? && !project.is_descendant_of?(ancestors.last))
287 287 ancestors.pop
288 288 s << "</ul></li>\n"
289 289 end
290 290 end
291 291 s << "<li>"
292 292 s << yield(project).to_s
293 293 ancestors << project
294 294 end
295 295 s << ("</li></ul>\n" * ancestors.size)
296 296 end
297 297 s.html_safe
298 298 end
299 299
300 300 def principals_check_box_tags(name, principals)
301 301 s = ''
302 302 principals.sort.each do |principal|
303 303 s << "<label>#{ check_box_tag name, principal.id, false } #{h principal}</label>\n"
304 304 end
305 305 s.html_safe
306 306 end
307 307
308 308 # Returns a string for users/groups option tags
309 309 def principals_options_for_select(collection, selected=nil)
310 310 s = ''
311 311 if collection.include?(User.current)
312 312 s << content_tag('option', "<< #{l(:label_me)} >>".html_safe, :value => User.current.id)
313 313 end
314 314 groups = ''
315 315 collection.sort.each do |element|
316 316 selected_attribute = ' selected="selected"' if option_value_selected?(element, selected)
317 317 (element.is_a?(Group) ? groups : s) << %(<option value="#{element.id}"#{selected_attribute}>#{h element.name}</option>)
318 318 end
319 319 unless groups.empty?
320 320 s << %(<optgroup label="#{h(l(:label_group_plural))}">#{groups}</optgroup>)
321 321 end
322 322 s.html_safe
323 323 end
324 324
325 325 # Truncates and returns the string as a single line
326 326 def truncate_single_line(string, *args)
327 327 truncate(string.to_s, *args).gsub(%r{[\r\n]+}m, ' ')
328 328 end
329 329
330 330 # Truncates at line break after 250 characters or options[:length]
331 331 def truncate_lines(string, options={})
332 332 length = options[:length] || 250
333 333 if string.to_s =~ /\A(.{#{length}}.*?)$/m
334 334 "#{$1}..."
335 335 else
336 336 string
337 337 end
338 338 end
339 339
340 340 def anchor(text)
341 341 text.to_s.gsub(' ', '_')
342 342 end
343 343
344 344 def html_hours(text)
345 345 text.gsub(%r{(\d+)\.(\d+)}, '<span class="hours hours-int">\1</span><span class="hours hours-dec">.\2</span>').html_safe
346 346 end
347 347
348 348 def authoring(created, author, options={})
349 349 l(options[:label] || :label_added_time_by, :author => link_to_user(author), :age => time_tag(created)).html_safe
350 350 end
351 351
352 352 def time_tag(time)
353 353 text = distance_of_time_in_words(Time.now, time)
354 354 if @project
355 link_to(text, {:controller => 'activities', :action => 'index', :id => @project, :from => time.to_date}, :title => format_time(time))
355 link_to(text, {:controller => 'activities', :action => 'index', :id => @project, :from => User.current.time_to_date(time)}, :title => format_time(time))
356 356 else
357 357 content_tag('acronym', text, :title => format_time(time))
358 358 end
359 359 end
360 360
361 361 def syntax_highlight_lines(name, content)
362 362 lines = []
363 363 syntax_highlight(name, content).each_line { |line| lines << line }
364 364 lines
365 365 end
366 366
367 367 def syntax_highlight(name, content)
368 368 Redmine::SyntaxHighlighting.highlight_by_filename(content, name)
369 369 end
370 370
371 371 def to_path_param(path)
372 372 str = path.to_s.split(%r{[/\\]}).select{|p| !p.blank?}.join("/")
373 373 str.blank? ? nil : str
374 374 end
375 375
376 376 def pagination_links_full(paginator, count=nil, options={})
377 377 page_param = options.delete(:page_param) || :page
378 378 per_page_links = options.delete(:per_page_links)
379 379 url_param = params.dup
380 380
381 381 html = ''
382 382 if paginator.current.previous
383 383 # \xc2\xab(utf-8) = &#171;
384 384 html << link_to_content_update(
385 385 "\xc2\xab " + l(:label_previous),
386 386 url_param.merge(page_param => paginator.current.previous)) + ' '
387 387 end
388 388
389 389 html << (pagination_links_each(paginator, options) do |n|
390 390 link_to_content_update(n.to_s, url_param.merge(page_param => n))
391 391 end || '')
392 392
393 393 if paginator.current.next
394 394 # \xc2\xbb(utf-8) = &#187;
395 395 html << ' ' + link_to_content_update(
396 396 (l(:label_next) + " \xc2\xbb"),
397 397 url_param.merge(page_param => paginator.current.next))
398 398 end
399 399
400 400 unless count.nil?
401 401 html << " (#{paginator.current.first_item}-#{paginator.current.last_item}/#{count})"
402 402 if per_page_links != false && links = per_page_links(paginator.items_per_page, count)
403 403 html << " | #{links}"
404 404 end
405 405 end
406 406
407 407 html.html_safe
408 408 end
409 409
410 410 def per_page_links(selected=nil, item_count=nil)
411 411 values = Setting.per_page_options_array
412 412 if item_count && values.any?
413 413 if item_count > values.first
414 414 max = values.detect {|value| value >= item_count} || item_count
415 415 else
416 416 max = item_count
417 417 end
418 418 values = values.select {|value| value <= max || value == selected}
419 419 end
420 420 if values.empty? || (values.size == 1 && values.first == selected)
421 421 return nil
422 422 end
423 423 links = values.collect do |n|
424 424 n == selected ? n : link_to_content_update(n, params.merge(:per_page => n))
425 425 end
426 426 l(:label_display_per_page, links.join(', '))
427 427 end
428 428
429 429 def reorder_links(name, url, method = :post)
430 430 link_to(image_tag('2uparrow.png', :alt => l(:label_sort_highest)),
431 431 url.merge({"#{name}[move_to]" => 'highest'}),
432 432 :method => method, :title => l(:label_sort_highest)) +
433 433 link_to(image_tag('1uparrow.png', :alt => l(:label_sort_higher)),
434 434 url.merge({"#{name}[move_to]" => 'higher'}),
435 435 :method => method, :title => l(:label_sort_higher)) +
436 436 link_to(image_tag('1downarrow.png', :alt => l(:label_sort_lower)),
437 437 url.merge({"#{name}[move_to]" => 'lower'}),
438 438 :method => method, :title => l(:label_sort_lower)) +
439 439 link_to(image_tag('2downarrow.png', :alt => l(:label_sort_lowest)),
440 440 url.merge({"#{name}[move_to]" => 'lowest'}),
441 441 :method => method, :title => l(:label_sort_lowest))
442 442 end
443 443
444 444 def breadcrumb(*args)
445 445 elements = args.flatten
446 446 elements.any? ? content_tag('p', (args.join(" \xc2\xbb ") + " \xc2\xbb ").html_safe, :class => 'breadcrumb') : nil
447 447 end
448 448
449 449 def other_formats_links(&block)
450 450 concat('<p class="other-formats">'.html_safe + l(:label_export_to))
451 451 yield Redmine::Views::OtherFormatsBuilder.new(self)
452 452 concat('</p>'.html_safe)
453 453 end
454 454
455 455 def page_header_title
456 456 if @project.nil? || @project.new_record?
457 457 h(Setting.app_title)
458 458 else
459 459 b = []
460 460 ancestors = (@project.root? ? [] : @project.ancestors.visible.all)
461 461 if ancestors.any?
462 462 root = ancestors.shift
463 463 b << link_to_project(root, {:jump => current_menu_item}, :class => 'root')
464 464 if ancestors.size > 2
465 465 b << "\xe2\x80\xa6"
466 466 ancestors = ancestors[-2, 2]
467 467 end
468 468 b += ancestors.collect {|p| link_to_project(p, {:jump => current_menu_item}, :class => 'ancestor') }
469 469 end
470 470 b << h(@project)
471 471 b.join(" \xc2\xbb ").html_safe
472 472 end
473 473 end
474 474
475 475 def html_title(*args)
476 476 if args.empty?
477 477 title = @html_title || []
478 478 title << @project.name if @project
479 479 title << Setting.app_title unless Setting.app_title == title.last
480 480 title.select {|t| !t.blank? }.join(' - ')
481 481 else
482 482 @html_title ||= []
483 483 @html_title += args
484 484 end
485 485 end
486 486
487 487 # Returns the theme, controller name, and action as css classes for the
488 488 # HTML body.
489 489 def body_css_classes
490 490 css = []
491 491 if theme = Redmine::Themes.theme(Setting.ui_theme)
492 492 css << 'theme-' + theme.name
493 493 end
494 494
495 495 css << 'controller-' + controller_name
496 496 css << 'action-' + action_name
497 497 css.join(' ')
498 498 end
499 499
500 500 def accesskey(s)
501 501 Redmine::AccessKeys.key_for s
502 502 end
503 503
504 504 # Formats text according to system settings.
505 505 # 2 ways to call this method:
506 506 # * with a String: textilizable(text, options)
507 507 # * with an object and one of its attribute: textilizable(issue, :description, options)
508 508 def textilizable(*args)
509 509 options = args.last.is_a?(Hash) ? args.pop : {}
510 510 case args.size
511 511 when 1
512 512 obj = options[:object]
513 513 text = args.shift
514 514 when 2
515 515 obj = args.shift
516 516 attr = args.shift
517 517 text = obj.send(attr).to_s
518 518 else
519 519 raise ArgumentError, 'invalid arguments to textilizable'
520 520 end
521 521 return '' if text.blank?
522 522 project = options[:project] || @project || (obj && obj.respond_to?(:project) ? obj.project : nil)
523 523 only_path = options.delete(:only_path) == false ? false : true
524 524
525 525 text = Redmine::WikiFormatting.to_html(Setting.text_formatting, text, :object => obj, :attribute => attr)
526 526
527 527 @parsed_headings = []
528 528 @heading_anchors = {}
529 529 @current_section = 0 if options[:edit_section_links]
530 530
531 531 parse_sections(text, project, obj, attr, only_path, options)
532 532 text = parse_non_pre_blocks(text) do |text|
533 533 [:parse_inline_attachments, :parse_wiki_links, :parse_redmine_links, :parse_macros].each do |method_name|
534 534 send method_name, text, project, obj, attr, only_path, options
535 535 end
536 536 end
537 537 parse_headings(text, project, obj, attr, only_path, options)
538 538
539 539 if @parsed_headings.any?
540 540 replace_toc(text, @parsed_headings)
541 541 end
542 542
543 543 text.html_safe
544 544 end
545 545
546 546 def parse_non_pre_blocks(text)
547 547 s = StringScanner.new(text)
548 548 tags = []
549 549 parsed = ''
550 550 while !s.eos?
551 551 s.scan(/(.*?)(<(\/)?(pre|code)(.*?)>|\z)/im)
552 552 text, full_tag, closing, tag = s[1], s[2], s[3], s[4]
553 553 if tags.empty?
554 554 yield text
555 555 end
556 556 parsed << text
557 557 if tag
558 558 if closing
559 559 if tags.last == tag.downcase
560 560 tags.pop
561 561 end
562 562 else
563 563 tags << tag.downcase
564 564 end
565 565 parsed << full_tag
566 566 end
567 567 end
568 568 # Close any non closing tags
569 569 while tag = tags.pop
570 570 parsed << "</#{tag}>"
571 571 end
572 572 parsed
573 573 end
574 574
575 575 def parse_inline_attachments(text, project, obj, attr, only_path, options)
576 576 # when using an image link, try to use an attachment, if possible
577 577 if options[:attachments] || (obj && obj.respond_to?(:attachments))
578 578 attachments = options[:attachments] || obj.attachments
579 579 text.gsub!(/src="([^\/"]+\.(bmp|gif|jpg|jpe|jpeg|png))"(\s+alt="([^"]*)")?/i) do |m|
580 580 filename, ext, alt, alttext = $1.downcase, $2, $3, $4
581 581 # search for the picture in attachments
582 582 if found = Attachment.latest_attach(attachments, filename)
583 583 image_url = url_for :only_path => only_path, :controller => 'attachments',
584 584 :action => 'download', :id => found
585 585 desc = found.description.to_s.gsub('"', '')
586 586 if !desc.blank? && alttext.blank?
587 587 alt = " title=\"#{desc}\" alt=\"#{desc}\""
588 588 end
589 589 "src=\"#{image_url}\"#{alt}"
590 590 else
591 591 m
592 592 end
593 593 end
594 594 end
595 595 end
596 596
597 597 # Wiki links
598 598 #
599 599 # Examples:
600 600 # [[mypage]]
601 601 # [[mypage|mytext]]
602 602 # wiki links can refer other project wikis, using project name or identifier:
603 603 # [[project:]] -> wiki starting page
604 604 # [[project:|mytext]]
605 605 # [[project:mypage]]
606 606 # [[project:mypage|mytext]]
607 607 def parse_wiki_links(text, project, obj, attr, only_path, options)
608 608 text.gsub!(/(!)?(\[\[([^\]\n\|]+)(\|([^\]\n\|]+))?\]\])/) do |m|
609 609 link_project = project
610 610 esc, all, page, title = $1, $2, $3, $5
611 611 if esc.nil?
612 612 if page =~ /^([^\:]+)\:(.*)$/
613 613 link_project = Project.find_by_identifier($1) || Project.find_by_name($1)
614 614 page = $2
615 615 title ||= $1 if page.blank?
616 616 end
617 617
618 618 if link_project && link_project.wiki
619 619 # extract anchor
620 620 anchor = nil
621 621 if page =~ /^(.+?)\#(.+)$/
622 622 page, anchor = $1, $2
623 623 end
624 624 anchor = sanitize_anchor_name(anchor) if anchor.present?
625 625 # check if page exists
626 626 wiki_page = link_project.wiki.find_page(page)
627 627 url = if anchor.present? && wiki_page.present? && (obj.is_a?(WikiContent) || obj.is_a?(WikiContent::Version)) && obj.page == wiki_page
628 628 "##{anchor}"
629 629 else
630 630 case options[:wiki_links]
631 631 when :local; "#{page.present? ? Wiki.titleize(page) : ''}.html" + (anchor.present? ? "##{anchor}" : '')
632 632 when :anchor; "##{page.present? ? Wiki.titleize(page) : title}" + (anchor.present? ? "_#{anchor}" : '') # used for single-file wiki export
633 633 else
634 634 wiki_page_id = page.present? ? Wiki.titleize(page) : nil
635 635 parent = wiki_page.nil? && obj.is_a?(WikiContent) && obj.page && project == link_project ? obj.page.title : nil
636 636 url_for(:only_path => only_path, :controller => 'wiki', :action => 'show', :project_id => link_project,
637 637 :id => wiki_page_id, :anchor => anchor, :parent => parent)
638 638 end
639 639 end
640 640 link_to(title.present? ? title.html_safe : h(page), url, :class => ('wiki-page' + (wiki_page ? '' : ' new')))
641 641 else
642 642 # project or wiki doesn't exist
643 643 all
644 644 end
645 645 else
646 646 all
647 647 end
648 648 end
649 649 end
650 650
651 651 # Redmine links
652 652 #
653 653 # Examples:
654 654 # Issues:
655 655 # #52 -> Link to issue #52
656 656 # Changesets:
657 657 # r52 -> Link to revision 52
658 658 # commit:a85130f -> Link to scmid starting with a85130f
659 659 # Documents:
660 660 # document#17 -> Link to document with id 17
661 661 # document:Greetings -> Link to the document with title "Greetings"
662 662 # document:"Some document" -> Link to the document with title "Some document"
663 663 # Versions:
664 664 # version#3 -> Link to version with id 3
665 665 # version:1.0.0 -> Link to version named "1.0.0"
666 666 # version:"1.0 beta 2" -> Link to version named "1.0 beta 2"
667 667 # Attachments:
668 668 # attachment:file.zip -> Link to the attachment of the current object named file.zip
669 669 # Source files:
670 670 # source:some/file -> Link to the file located at /some/file in the project's repository
671 671 # source:some/file@52 -> Link to the file's revision 52
672 672 # source:some/file#L120 -> Link to line 120 of the file
673 673 # source:some/file@52#L120 -> Link to line 120 of the file's revision 52
674 674 # export:some/file -> Force the download of the file
675 675 # Forum messages:
676 676 # message#1218 -> Link to message with id 1218
677 677 #
678 678 # Links can refer other objects from other projects, using project identifier:
679 679 # identifier:r52
680 680 # identifier:document:"Some document"
681 681 # identifier:version:1.0.0
682 682 # identifier:source:some/file
683 683 def parse_redmine_links(text, project, obj, attr, only_path, options)
684 684 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|
685 685 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
686 686 link = nil
687 687 if project_identifier
688 688 project = Project.visible.find_by_identifier(project_identifier)
689 689 end
690 690 if esc.nil?
691 691 if prefix.nil? && sep == 'r'
692 692 if project
693 693 repository = nil
694 694 if repo_identifier
695 695 repository = project.repositories.detect {|repo| repo.identifier == repo_identifier}
696 696 else
697 697 repository = project.repository
698 698 end
699 699 # project.changesets.visible raises an SQL error because of a double join on repositories
700 700 if repository && (changeset = Changeset.visible.find_by_repository_id_and_revision(repository.id, identifier))
701 701 link = link_to(h("#{project_prefix}#{repo_prefix}r#{identifier}"), {:only_path => only_path, :controller => 'repositories', :action => 'revision', :id => project, :repository_id => repository.identifier_param, :rev => changeset.revision},
702 702 :class => 'changeset',
703 703 :title => truncate_single_line(changeset.comments, :length => 100))
704 704 end
705 705 end
706 706 elsif sep == '#'
707 707 oid = identifier.to_i
708 708 case prefix
709 709 when nil
710 710 if issue = Issue.visible.find_by_id(oid, :include => :status)
711 711 anchor = comment_id ? "note-#{comment_id}" : nil
712 712 link = link_to("##{oid}", {:only_path => only_path, :controller => 'issues', :action => 'show', :id => oid, :anchor => anchor},
713 713 :class => issue.css_classes,
714 714 :title => "#{truncate(issue.subject, :length => 100)} (#{issue.status.name})")
715 715 end
716 716 when 'document'
717 717 if document = Document.visible.find_by_id(oid)
718 718 link = link_to h(document.title), {:only_path => only_path, :controller => 'documents', :action => 'show', :id => document},
719 719 :class => 'document'
720 720 end
721 721 when 'version'
722 722 if version = Version.visible.find_by_id(oid)
723 723 link = link_to h(version.name), {:only_path => only_path, :controller => 'versions', :action => 'show', :id => version},
724 724 :class => 'version'
725 725 end
726 726 when 'message'
727 727 if message = Message.visible.find_by_id(oid, :include => :parent)
728 728 link = link_to_message(message, {:only_path => only_path}, :class => 'message')
729 729 end
730 730 when 'forum'
731 731 if board = Board.visible.find_by_id(oid)
732 732 link = link_to h(board.name), {:only_path => only_path, :controller => 'boards', :action => 'show', :id => board, :project_id => board.project},
733 733 :class => 'board'
734 734 end
735 735 when 'news'
736 736 if news = News.visible.find_by_id(oid)
737 737 link = link_to h(news.title), {:only_path => only_path, :controller => 'news', :action => 'show', :id => news},
738 738 :class => 'news'
739 739 end
740 740 when 'project'
741 741 if p = Project.visible.find_by_id(oid)
742 742 link = link_to_project(p, {:only_path => only_path}, :class => 'project')
743 743 end
744 744 end
745 745 elsif sep == ':'
746 746 # removes the double quotes if any
747 747 name = identifier.gsub(%r{^"(.*)"$}, "\\1")
748 748 case prefix
749 749 when 'document'
750 750 if project && document = project.documents.visible.find_by_title(name)
751 751 link = link_to h(document.title), {:only_path => only_path, :controller => 'documents', :action => 'show', :id => document},
752 752 :class => 'document'
753 753 end
754 754 when 'version'
755 755 if project && version = project.versions.visible.find_by_name(name)
756 756 link = link_to h(version.name), {:only_path => only_path, :controller => 'versions', :action => 'show', :id => version},
757 757 :class => 'version'
758 758 end
759 759 when 'forum'
760 760 if project && board = project.boards.visible.find_by_name(name)
761 761 link = link_to h(board.name), {:only_path => only_path, :controller => 'boards', :action => 'show', :id => board, :project_id => board.project},
762 762 :class => 'board'
763 763 end
764 764 when 'news'
765 765 if project && news = project.news.visible.find_by_title(name)
766 766 link = link_to h(news.title), {:only_path => only_path, :controller => 'news', :action => 'show', :id => news},
767 767 :class => 'news'
768 768 end
769 769 when 'commit', 'source', 'export'
770 770 if project
771 771 repository = nil
772 772 if name =~ %r{^(([a-z0-9\-]+)\|)(.+)$}
773 773 repo_prefix, repo_identifier, name = $1, $2, $3
774 774 repository = project.repositories.detect {|repo| repo.identifier == repo_identifier}
775 775 else
776 776 repository = project.repository
777 777 end
778 778 if prefix == 'commit'
779 779 if repository && (changeset = Changeset.visible.find(:first, :conditions => ["repository_id = ? AND scmid LIKE ?", repository.id, "#{name}%"]))
780 780 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},
781 781 :class => 'changeset',
782 782 :title => truncate_single_line(h(changeset.comments), :length => 100)
783 783 end
784 784 else
785 785 if repository && User.current.allowed_to?(:browse_repository, project)
786 786 name =~ %r{^[/\\]*(.*?)(@([0-9a-f]+))?(#(L\d+))?$}
787 787 path, rev, anchor = $1, $3, $5
788 788 link = link_to h("#{project_prefix}#{prefix}:#{repo_prefix}#{name}"), {:controller => 'repositories', :action => 'entry', :id => project, :repository_id => repository.identifier_param,
789 789 :path => to_path_param(path),
790 790 :rev => rev,
791 791 :anchor => anchor,
792 792 :format => (prefix == 'export' ? 'raw' : nil)},
793 793 :class => (prefix == 'export' ? 'source download' : 'source')
794 794 end
795 795 end
796 796 repo_prefix = nil
797 797 end
798 798 when 'attachment'
799 799 attachments = options[:attachments] || (obj && obj.respond_to?(:attachments) ? obj.attachments : nil)
800 800 if attachments && attachment = attachments.detect {|a| a.filename == name }
801 801 link = link_to h(attachment.filename), {:only_path => only_path, :controller => 'attachments', :action => 'download', :id => attachment},
802 802 :class => 'attachment'
803 803 end
804 804 when 'project'
805 805 if p = Project.visible.find(:first, :conditions => ["identifier = :s OR LOWER(name) = :s", {:s => name.downcase}])
806 806 link = link_to_project(p, {:only_path => only_path}, :class => 'project')
807 807 end
808 808 end
809 809 end
810 810 end
811 811 (leading + (link || "#{project_prefix}#{prefix}#{repo_prefix}#{sep}#{identifier}#{comment_suffix}"))
812 812 end
813 813 end
814 814
815 815 HEADING_RE = /(<h(1|2|3|4)( [^>]+)?>(.+?)<\/h(1|2|3|4)>)/i unless const_defined?(:HEADING_RE)
816 816
817 817 def parse_sections(text, project, obj, attr, only_path, options)
818 818 return unless options[:edit_section_links]
819 819 text.gsub!(HEADING_RE) do
820 820 heading = $1
821 821 @current_section += 1
822 822 if @current_section > 1
823 823 content_tag('div',
824 824 link_to(image_tag('edit.png'), options[:edit_section_links].merge(:section => @current_section)),
825 825 :class => 'contextual',
826 826 :title => l(:button_edit_section)) + heading.html_safe
827 827 else
828 828 heading
829 829 end
830 830 end
831 831 end
832 832
833 833 # Headings and TOC
834 834 # Adds ids and links to headings unless options[:headings] is set to false
835 835 def parse_headings(text, project, obj, attr, only_path, options)
836 836 return if options[:headings] == false
837 837
838 838 text.gsub!(HEADING_RE) do
839 839 level, attrs, content = $2.to_i, $3, $4
840 840 item = strip_tags(content).strip
841 841 anchor = sanitize_anchor_name(item)
842 842 # used for single-file wiki export
843 843 anchor = "#{obj.page.title}_#{anchor}" if options[:wiki_links] == :anchor && (obj.is_a?(WikiContent) || obj.is_a?(WikiContent::Version))
844 844 @heading_anchors[anchor] ||= 0
845 845 idx = (@heading_anchors[anchor] += 1)
846 846 if idx > 1
847 847 anchor = "#{anchor}-#{idx}"
848 848 end
849 849 @parsed_headings << [level, anchor, item]
850 850 "<a name=\"#{anchor}\"></a>\n<h#{level} #{attrs}>#{content}<a href=\"##{anchor}\" class=\"wiki-anchor\">&para;</a></h#{level}>"
851 851 end
852 852 end
853 853
854 854 MACROS_RE = /
855 855 (!)? # escaping
856 856 (
857 857 \{\{ # opening tag
858 858 ([\w]+) # macro name
859 859 (\(([^\}]*)\))? # optional arguments
860 860 \}\} # closing tag
861 861 )
862 862 /x unless const_defined?(:MACROS_RE)
863 863
864 864 # Macros substitution
865 865 def parse_macros(text, project, obj, attr, only_path, options)
866 866 text.gsub!(MACROS_RE) do
867 867 esc, all, macro = $1, $2, $3.downcase
868 868 args = ($5 || '').split(',').each(&:strip)
869 869 if esc.nil?
870 870 begin
871 871 exec_macro(macro, obj, args)
872 872 rescue => e
873 873 "<div class=\"flash error\">Error executing the <strong>#{macro}</strong> macro (#{e})</div>"
874 874 end || all
875 875 else
876 876 all
877 877 end
878 878 end
879 879 end
880 880
881 881 TOC_RE = /<p>\{\{([<>]?)toc\}\}<\/p>/i unless const_defined?(:TOC_RE)
882 882
883 883 # Renders the TOC with given headings
884 884 def replace_toc(text, headings)
885 885 text.gsub!(TOC_RE) do
886 886 if headings.empty?
887 887 ''
888 888 else
889 889 div_class = 'toc'
890 890 div_class << ' right' if $1 == '>'
891 891 div_class << ' left' if $1 == '<'
892 892 out = "<ul class=\"#{div_class}\"><li>"
893 893 root = headings.map(&:first).min
894 894 current = root
895 895 started = false
896 896 headings.each do |level, anchor, item|
897 897 if level > current
898 898 out << '<ul><li>' * (level - current)
899 899 elsif level < current
900 900 out << "</li></ul>\n" * (current - level) + "</li><li>"
901 901 elsif started
902 902 out << '</li><li>'
903 903 end
904 904 out << "<a href=\"##{anchor}\">#{item}</a>"
905 905 current = level
906 906 started = true
907 907 end
908 908 out << '</li></ul>' * (current - root)
909 909 out << '</li></ul>'
910 910 end
911 911 end
912 912 end
913 913
914 914 # Same as Rails' simple_format helper without using paragraphs
915 915 def simple_format_without_paragraph(text)
916 916 text.to_s.
917 917 gsub(/\r\n?/, "\n"). # \r\n and \r -> \n
918 918 gsub(/\n\n+/, "<br /><br />"). # 2+ newline -> 2 br
919 919 gsub(/([^\n]\n)(?=[^\n])/, '\1<br />'). # 1 newline -> br
920 920 html_safe
921 921 end
922 922
923 923 def lang_options_for_select(blank=true)
924 924 (blank ? [["(auto)", ""]] : []) +
925 925 valid_languages.collect{|lang| [ ll(lang.to_s, :general_lang_name), lang.to_s]}.sort{|x,y| x.last <=> y.last }
926 926 end
927 927
928 928 def label_tag_for(name, option_tags = nil, options = {})
929 929 label_text = l(("field_"+field.to_s.gsub(/\_id$/, "")).to_sym) + (options.delete(:required) ? @template.content_tag("span", " *", :class => "required"): "")
930 930 content_tag("label", label_text)
931 931 end
932 932
933 933 def labelled_tabular_form_for(*args, &proc)
934 934 ActiveSupport::Deprecation.warn "ApplicationHelper#labelled_tabular_form_for is deprecated and will be removed in Redmine 1.5. Use #labelled_form_for instead."
935 935 args << {} unless args.last.is_a?(Hash)
936 936 options = args.last
937 937 options[:html] ||= {}
938 938 options[:html][:class] = 'tabular' unless options[:html].has_key?(:class)
939 939 options.merge!({:builder => Redmine::Views::LabelledFormBuilder})
940 940 form_for(*args, &proc)
941 941 end
942 942
943 943 def labelled_form_for(*args, &proc)
944 944 args << {} unless args.last.is_a?(Hash)
945 945 options = args.last
946 946 if args.first.is_a?(Symbol)
947 947 options.merge!(:as => args.shift)
948 948 end
949 949 options.merge!({:builder => Redmine::Views::LabelledFormBuilder})
950 950 form_for(*args, &proc)
951 951 end
952 952
953 953 def labelled_fields_for(*args, &proc)
954 954 args << {} unless args.last.is_a?(Hash)
955 955 options = args.last
956 956 options.merge!({:builder => Redmine::Views::LabelledFormBuilder})
957 957 fields_for(*args, &proc)
958 958 end
959 959
960 960 def labelled_remote_form_for(*args, &proc)
961 961 args << {} unless args.last.is_a?(Hash)
962 962 options = args.last
963 963 options.merge!({:builder => Redmine::Views::LabelledFormBuilder, :remote => true})
964 964 form_for(*args, &proc)
965 965 end
966 966
967 967 def error_messages_for(*objects)
968 968 html = ""
969 969 objects = objects.map {|o| o.is_a?(String) ? instance_variable_get("@#{o}") : o}.compact
970 970 errors = objects.map {|o| o.errors.full_messages}.flatten
971 971 if errors.any?
972 972 html << "<div id='errorExplanation'><ul>\n"
973 973 errors.each do |error|
974 974 html << "<li>#{h error}</li>\n"
975 975 end
976 976 html << "</ul></div>\n"
977 977 end
978 978 html.html_safe
979 979 end
980 980
981 981 def back_url_hidden_field_tag
982 982 back_url = params[:back_url] || request.env['HTTP_REFERER']
983 983 back_url = CGI.unescape(back_url.to_s)
984 984 hidden_field_tag('back_url', CGI.escape(back_url), :id => nil) unless back_url.blank?
985 985 end
986 986
987 987 def check_all_links(form_name)
988 988 link_to_function(l(:button_check_all), "checkAll('#{form_name}', true)") +
989 989 " | ".html_safe +
990 990 link_to_function(l(:button_uncheck_all), "checkAll('#{form_name}', false)")
991 991 end
992 992
993 993 def progress_bar(pcts, options={})
994 994 pcts = [pcts, pcts] unless pcts.is_a?(Array)
995 995 pcts = pcts.collect(&:round)
996 996 pcts[1] = pcts[1] - pcts[0]
997 997 pcts << (100 - pcts[1] - pcts[0])
998 998 width = options[:width] || '100px;'
999 999 legend = options[:legend] || ''
1000 1000 content_tag('table',
1001 1001 content_tag('tr',
1002 1002 (pcts[0] > 0 ? content_tag('td', '', :style => "width: #{pcts[0]}%;", :class => 'closed') : ''.html_safe) +
1003 1003 (pcts[1] > 0 ? content_tag('td', '', :style => "width: #{pcts[1]}%;", :class => 'done') : ''.html_safe) +
1004 1004 (pcts[2] > 0 ? content_tag('td', '', :style => "width: #{pcts[2]}%;", :class => 'todo') : ''.html_safe)
1005 1005 ), :class => 'progress', :style => "width: #{width};").html_safe +
1006 1006 content_tag('p', legend, :class => 'pourcent').html_safe
1007 1007 end
1008 1008
1009 1009 def checked_image(checked=true)
1010 1010 if checked
1011 1011 image_tag 'toggle_check.png'
1012 1012 end
1013 1013 end
1014 1014
1015 1015 def context_menu(url)
1016 1016 unless @context_menu_included
1017 1017 content_for :header_tags do
1018 1018 javascript_include_tag('context_menu') +
1019 1019 stylesheet_link_tag('context_menu')
1020 1020 end
1021 1021 if l(:direction) == 'rtl'
1022 1022 content_for :header_tags do
1023 1023 stylesheet_link_tag('context_menu_rtl')
1024 1024 end
1025 1025 end
1026 1026 @context_menu_included = true
1027 1027 end
1028 1028 javascript_tag "new ContextMenu('#{ url_for(url) }')"
1029 1029 end
1030 1030
1031 1031 def calendar_for(field_id)
1032 1032 include_calendar_headers_tags
1033 1033 image_tag("calendar.png", {:id => "#{field_id}_trigger",:class => "calendar-trigger"}) +
1034 1034 javascript_tag("Calendar.setup({inputField : '#{field_id}', ifFormat : '%Y-%m-%d', button : '#{field_id}_trigger' });")
1035 1035 end
1036 1036
1037 1037 def include_calendar_headers_tags
1038 1038 unless @calendar_headers_tags_included
1039 1039 @calendar_headers_tags_included = true
1040 1040 content_for :header_tags do
1041 1041 start_of_week = case Setting.start_of_week.to_i
1042 1042 when 1
1043 1043 'Calendar._FD = 1;' # Monday
1044 1044 when 7
1045 1045 'Calendar._FD = 0;' # Sunday
1046 1046 when 6
1047 1047 'Calendar._FD = 6;' # Saturday
1048 1048 else
1049 1049 '' # use language
1050 1050 end
1051 1051
1052 1052 javascript_include_tag('calendar/calendar') +
1053 1053 javascript_include_tag("calendar/lang/calendar-#{current_language.to_s.downcase}.js") +
1054 1054 javascript_tag(start_of_week) +
1055 1055 javascript_include_tag('calendar/calendar-setup') +
1056 1056 stylesheet_link_tag('calendar')
1057 1057 end
1058 1058 end
1059 1059 end
1060 1060
1061 1061 # Overrides Rails' stylesheet_link_tag with themes and plugins support.
1062 1062 # Examples:
1063 1063 # stylesheet_link_tag('styles') # => picks styles.css from the current theme or defaults
1064 1064 # stylesheet_link_tag('styles', :plugin => 'foo) # => picks styles.css from plugin's assets
1065 1065 #
1066 1066 def stylesheet_link_tag(*sources)
1067 1067 options = sources.last.is_a?(Hash) ? sources.pop : {}
1068 1068 plugin = options.delete(:plugin)
1069 1069 sources = sources.map do |source|
1070 1070 if plugin
1071 1071 "/plugin_assets/#{plugin}/stylesheets/#{source}"
1072 1072 elsif current_theme && current_theme.stylesheets.include?(source)
1073 1073 current_theme.stylesheet_path(source)
1074 1074 else
1075 1075 source
1076 1076 end
1077 1077 end
1078 1078 super sources, options
1079 1079 end
1080 1080
1081 1081 # Overrides Rails' image_tag with themes and plugins support.
1082 1082 # Examples:
1083 1083 # image_tag('image.png') # => picks image.png from the current theme or defaults
1084 1084 # image_tag('image.png', :plugin => 'foo) # => picks image.png from plugin's assets
1085 1085 #
1086 1086 def image_tag(source, options={})
1087 1087 if plugin = options.delete(:plugin)
1088 1088 source = "/plugin_assets/#{plugin}/images/#{source}"
1089 1089 elsif current_theme && current_theme.images.include?(source)
1090 1090 source = current_theme.image_path(source)
1091 1091 end
1092 1092 super source, options
1093 1093 end
1094 1094
1095 1095 # Overrides Rails' javascript_include_tag with plugins support
1096 1096 # Examples:
1097 1097 # javascript_include_tag('scripts') # => picks scripts.js from defaults
1098 1098 # javascript_include_tag('scripts', :plugin => 'foo) # => picks scripts.js from plugin's assets
1099 1099 #
1100 1100 def javascript_include_tag(*sources)
1101 1101 options = sources.last.is_a?(Hash) ? sources.pop : {}
1102 1102 if plugin = options.delete(:plugin)
1103 1103 sources = sources.map do |source|
1104 1104 if plugin
1105 1105 "/plugin_assets/#{plugin}/javascripts/#{source}"
1106 1106 else
1107 1107 source
1108 1108 end
1109 1109 end
1110 1110 end
1111 1111 super sources, options
1112 1112 end
1113 1113
1114 1114 def content_for(name, content = nil, &block)
1115 1115 @has_content ||= {}
1116 1116 @has_content[name] = true
1117 1117 super(name, content, &block)
1118 1118 end
1119 1119
1120 1120 def has_content?(name)
1121 1121 (@has_content && @has_content[name]) || false
1122 1122 end
1123 1123
1124 1124 def sidebar_content?
1125 1125 has_content?(:sidebar) || view_layouts_base_sidebar_hook_response.present?
1126 1126 end
1127 1127
1128 1128 def view_layouts_base_sidebar_hook_response
1129 1129 @view_layouts_base_sidebar_hook_response ||= call_hook(:view_layouts_base_sidebar)
1130 1130 end
1131 1131
1132 1132 def email_delivery_enabled?
1133 1133 !!ActionMailer::Base.perform_deliveries
1134 1134 end
1135 1135
1136 1136 # Returns the avatar image tag for the given +user+ if avatars are enabled
1137 1137 # +user+ can be a User or a string that will be scanned for an email address (eg. 'joe <joe@foo.bar>')
1138 1138 def avatar(user, options = { })
1139 1139 if Setting.gravatar_enabled?
1140 1140 options.merge!({:ssl => (request && request.ssl?), :default => Setting.gravatar_default})
1141 1141 email = nil
1142 1142 if user.respond_to?(:mail)
1143 1143 email = user.mail
1144 1144 elsif user.to_s =~ %r{<(.+?)>}
1145 1145 email = $1
1146 1146 end
1147 1147 return gravatar(email.to_s.downcase, options) unless email.blank? rescue nil
1148 1148 else
1149 1149 ''
1150 1150 end
1151 1151 end
1152 1152
1153 1153 def sanitize_anchor_name(anchor)
1154 1154 anchor.gsub(%r{[^\w\s\-]}, '').gsub(%r{\s+(\-+\s*)?}, '-')
1155 1155 end
1156 1156
1157 1157 # Returns the javascript tags that are included in the html layout head
1158 1158 def javascript_heads
1159 1159 tags = javascript_include_tag('prototype', 'effects', 'dragdrop', 'controls', 'rails', 'application')
1160 1160 unless User.current.pref.warn_on_leaving_unsaved == '0'
1161 1161 tags << "\n".html_safe + javascript_tag("Event.observe(window, 'load', function(){ new WarnLeavingUnsaved('#{escape_javascript( l(:text_warn_on_leaving_unsaved) )}'); });")
1162 1162 end
1163 1163 tags
1164 1164 end
1165 1165
1166 1166 def favicon
1167 1167 "<link rel='shortcut icon' href='#{image_path('/favicon.ico')}' />".html_safe
1168 1168 end
1169 1169
1170 1170 def robot_exclusion_tag
1171 1171 '<meta name="robots" content="noindex,follow,noarchive" />'.html_safe
1172 1172 end
1173 1173
1174 1174 # Returns true if arg is expected in the API response
1175 1175 def include_in_api_response?(arg)
1176 1176 unless @included_in_api_response
1177 1177 param = params[:include]
1178 1178 @included_in_api_response = param.is_a?(Array) ? param.collect(&:to_s) : param.to_s.split(',')
1179 1179 @included_in_api_response.collect!(&:strip)
1180 1180 end
1181 1181 @included_in_api_response.include?(arg.to_s)
1182 1182 end
1183 1183
1184 1184 # Returns options or nil if nometa param or X-Redmine-Nometa header
1185 1185 # was set in the request
1186 1186 def api_meta(options)
1187 1187 if params[:nometa].present? || request.headers['X-Redmine-Nometa']
1188 1188 # compatibility mode for activeresource clients that raise
1189 1189 # an error when unserializing an array with attributes
1190 1190 nil
1191 1191 else
1192 1192 options
1193 1193 end
1194 1194 end
1195 1195
1196 1196 private
1197 1197
1198 1198 def wiki_helper
1199 1199 helper = Redmine::WikiFormatting.helper_for(Setting.text_formatting)
1200 1200 extend helper
1201 1201 return self
1202 1202 end
1203 1203
1204 1204 def link_to_content_update(text, url_params = {}, html_options = {})
1205 1205 link_to(text, url_params, html_options)
1206 1206 end
1207 1207 end
@@ -1,865 +1,871
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class QueryColumn
19 19 attr_accessor :name, :sortable, :groupable, :default_order
20 20 include Redmine::I18n
21 21
22 22 def initialize(name, options={})
23 23 self.name = name
24 24 self.sortable = options[:sortable]
25 25 self.groupable = options[:groupable] || false
26 26 if groupable == true
27 27 self.groupable = name.to_s
28 28 end
29 29 self.default_order = options[:default_order]
30 30 @caption_key = options[:caption] || "field_#{name}"
31 31 end
32 32
33 33 def caption
34 34 l(@caption_key)
35 35 end
36 36
37 37 # Returns true if the column is sortable, otherwise false
38 38 def sortable?
39 39 !@sortable.nil?
40 40 end
41 41
42 42 def sortable
43 43 @sortable.is_a?(Proc) ? @sortable.call : @sortable
44 44 end
45 45
46 46 def value(issue)
47 47 issue.send name
48 48 end
49 49
50 50 def css_classes
51 51 name
52 52 end
53 53 end
54 54
55 55 class QueryCustomFieldColumn < QueryColumn
56 56
57 57 def initialize(custom_field)
58 58 self.name = "cf_#{custom_field.id}".to_sym
59 59 self.sortable = custom_field.order_statement || false
60 60 if %w(list date bool int).include?(custom_field.field_format) && !custom_field.multiple?
61 61 self.groupable = custom_field.order_statement
62 62 end
63 63 self.groupable ||= false
64 64 @cf = custom_field
65 65 end
66 66
67 67 def caption
68 68 @cf.name
69 69 end
70 70
71 71 def custom_field
72 72 @cf
73 73 end
74 74
75 75 def value(issue)
76 76 cv = issue.custom_values.select {|v| v.custom_field_id == @cf.id}.collect {|v| @cf.cast_value(v.value)}
77 77 cv.size > 1 ? cv : cv.first
78 78 end
79 79
80 80 def css_classes
81 81 @css_classes ||= "#{name} #{@cf.field_format}"
82 82 end
83 83 end
84 84
85 85 class Query < ActiveRecord::Base
86 86 class StatementInvalid < ::ActiveRecord::StatementInvalid
87 87 end
88 88
89 89 belongs_to :project
90 90 belongs_to :user
91 91 serialize :filters
92 92 serialize :column_names
93 93 serialize :sort_criteria, Array
94 94
95 95 attr_protected :project_id, :user_id
96 96
97 97 validates_presence_of :name
98 98 validates_length_of :name, :maximum => 255
99 99 validate :validate_query_filters
100 100
101 101 @@operators = { "=" => :label_equals,
102 102 "!" => :label_not_equals,
103 103 "o" => :label_open_issues,
104 104 "c" => :label_closed_issues,
105 105 "!*" => :label_none,
106 106 "*" => :label_all,
107 107 ">=" => :label_greater_or_equal,
108 108 "<=" => :label_less_or_equal,
109 109 "><" => :label_between,
110 110 "<t+" => :label_in_less_than,
111 111 ">t+" => :label_in_more_than,
112 112 "t+" => :label_in,
113 113 "t" => :label_today,
114 114 "w" => :label_this_week,
115 115 ">t-" => :label_less_than_ago,
116 116 "<t-" => :label_more_than_ago,
117 117 "t-" => :label_ago,
118 118 "~" => :label_contains,
119 119 "!~" => :label_not_contains }
120 120
121 121 cattr_reader :operators
122 122
123 123 @@operators_by_filter_type = { :list => [ "=", "!" ],
124 124 :list_status => [ "o", "=", "!", "c", "*" ],
125 125 :list_optional => [ "=", "!", "!*", "*" ],
126 126 :list_subprojects => [ "*", "!*", "=" ],
127 127 :date => [ "=", ">=", "<=", "><", "<t+", ">t+", "t+", "t", "w", ">t-", "<t-", "t-", "!*", "*" ],
128 128 :date_past => [ "=", ">=", "<=", "><", ">t-", "<t-", "t-", "t", "w", "!*", "*" ],
129 129 :string => [ "=", "~", "!", "!~", "!*", "*" ],
130 130 :text => [ "~", "!~", "!*", "*" ],
131 131 :integer => [ "=", ">=", "<=", "><", "!*", "*" ],
132 132 :float => [ "=", ">=", "<=", "><", "!*", "*" ] }
133 133
134 134 cattr_reader :operators_by_filter_type
135 135
136 136 @@available_columns = [
137 137 QueryColumn.new(:project, :sortable => "#{Project.table_name}.name", :groupable => true),
138 138 QueryColumn.new(:tracker, :sortable => "#{Tracker.table_name}.position", :groupable => true),
139 139 QueryColumn.new(:parent, :sortable => ["#{Issue.table_name}.root_id", "#{Issue.table_name}.lft ASC"], :default_order => 'desc', :caption => :field_parent_issue),
140 140 QueryColumn.new(:status, :sortable => "#{IssueStatus.table_name}.position", :groupable => true),
141 141 QueryColumn.new(:priority, :sortable => "#{IssuePriority.table_name}.position", :default_order => 'desc', :groupable => true),
142 142 QueryColumn.new(:subject, :sortable => "#{Issue.table_name}.subject"),
143 143 QueryColumn.new(:author, :sortable => lambda {User.fields_for_order_statement("authors")}, :groupable => true),
144 144 QueryColumn.new(:assigned_to, :sortable => lambda {User.fields_for_order_statement}, :groupable => true),
145 145 QueryColumn.new(:updated_on, :sortable => "#{Issue.table_name}.updated_on", :default_order => 'desc'),
146 146 QueryColumn.new(:category, :sortable => "#{IssueCategory.table_name}.name", :groupable => true),
147 147 QueryColumn.new(:fixed_version, :sortable => ["#{Version.table_name}.effective_date", "#{Version.table_name}.name"], :default_order => 'desc', :groupable => true),
148 148 QueryColumn.new(:start_date, :sortable => "#{Issue.table_name}.start_date"),
149 149 QueryColumn.new(:due_date, :sortable => "#{Issue.table_name}.due_date"),
150 150 QueryColumn.new(:estimated_hours, :sortable => "#{Issue.table_name}.estimated_hours"),
151 151 QueryColumn.new(:done_ratio, :sortable => "#{Issue.table_name}.done_ratio", :groupable => true),
152 152 QueryColumn.new(:created_on, :sortable => "#{Issue.table_name}.created_on", :default_order => 'desc'),
153 153 ]
154 154 cattr_reader :available_columns
155 155
156 156 scope :visible, lambda {|*args|
157 157 user = args.shift || User.current
158 158 base = Project.allowed_to_condition(user, :view_issues, *args)
159 159 user_id = user.logged? ? user.id : 0
160 160 {
161 161 :conditions => ["(#{table_name}.project_id IS NULL OR (#{base})) AND (#{table_name}.is_public = ? OR #{table_name}.user_id = ?)", true, user_id],
162 162 :include => :project
163 163 }
164 164 }
165 165
166 166 def initialize(attributes=nil, *args)
167 167 super attributes
168 168 self.filters ||= { 'status_id' => {:operator => "o", :values => [""]} }
169 169 @is_for_all = project.nil?
170 170 end
171 171
172 172 def validate_query_filters
173 173 filters.each_key do |field|
174 174 if values_for(field)
175 175 case type_for(field)
176 176 when :integer
177 177 add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && !v.match(/^\d+$/) }
178 178 when :float
179 179 add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && !v.match(/^\d+(\.\d*)?$/) }
180 180 when :date, :date_past
181 181 case operator_for(field)
182 182 when "=", ">=", "<=", "><"
183 183 add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && (!v.match(/^\d{4}-\d{2}-\d{2}$/) || (Date.parse(v) rescue nil).nil?) }
184 184 when ">t-", "<t-", "t-"
185 185 add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && !v.match(/^\d+$/) }
186 186 end
187 187 end
188 188 end
189 189
190 190 add_filter_error(field, :blank) unless
191 191 # filter requires one or more values
192 192 (values_for(field) and !values_for(field).first.blank?) or
193 193 # filter doesn't require any value
194 194 ["o", "c", "!*", "*", "t", "w"].include? operator_for(field)
195 195 end if filters
196 196 end
197 197
198 198 def add_filter_error(field, message)
199 199 m = label_for(field) + " " + l(message, :scope => 'activerecord.errors.messages')
200 200 errors.add(:base, m)
201 201 end
202 202
203 203 # Returns true if the query is visible to +user+ or the current user.
204 204 def visible?(user=User.current)
205 205 (project.nil? || user.allowed_to?(:view_issues, project)) && (self.is_public? || self.user_id == user.id)
206 206 end
207 207
208 208 def editable_by?(user)
209 209 return false unless user
210 210 # Admin can edit them all and regular users can edit their private queries
211 211 return true if user.admin? || (!is_public && self.user_id == user.id)
212 212 # Members can not edit public queries that are for all project (only admin is allowed to)
213 213 is_public && !@is_for_all && user.allowed_to?(:manage_public_queries, project)
214 214 end
215 215
216 216 def available_filters
217 217 return @available_filters if @available_filters
218 218
219 219 trackers = project.nil? ? Tracker.find(:all, :order => 'position') : project.rolled_up_trackers
220 220
221 221 @available_filters = { "status_id" => { :type => :list_status, :order => 1, :values => IssueStatus.find(:all, :order => 'position').collect{|s| [s.name, s.id.to_s] } },
222 222 "tracker_id" => { :type => :list, :order => 2, :values => trackers.collect{|s| [s.name, s.id.to_s] } },
223 223 "priority_id" => { :type => :list, :order => 3, :values => IssuePriority.all.collect{|s| [s.name, s.id.to_s] } },
224 224 "subject" => { :type => :text, :order => 8 },
225 225 "created_on" => { :type => :date_past, :order => 9 },
226 226 "updated_on" => { :type => :date_past, :order => 10 },
227 227 "start_date" => { :type => :date, :order => 11 },
228 228 "due_date" => { :type => :date, :order => 12 },
229 229 "estimated_hours" => { :type => :float, :order => 13 },
230 230 "done_ratio" => { :type => :integer, :order => 14 }}
231 231
232 232 principals = []
233 233 if project
234 234 principals += project.principals.sort
235 235 unless project.leaf?
236 236 subprojects = project.descendants.visible.all
237 237 if subprojects.any?
238 238 @available_filters["subproject_id"] = { :type => :list_subprojects, :order => 13, :values => subprojects.collect{|s| [s.name, s.id.to_s] } }
239 239 principals += Principal.member_of(subprojects)
240 240 end
241 241 end
242 242 else
243 243 all_projects = Project.visible.all
244 244 if all_projects.any?
245 245 # members of visible projects
246 246 principals += Principal.member_of(all_projects)
247 247
248 248 # project filter
249 249 project_values = []
250 250 if User.current.logged? && User.current.memberships.any?
251 251 project_values << ["<< #{l(:label_my_projects).downcase} >>", "mine"]
252 252 end
253 253 Project.project_tree(all_projects) do |p, level|
254 254 prefix = (level > 0 ? ('--' * level + ' ') : '')
255 255 project_values << ["#{prefix}#{p.name}", p.id.to_s]
256 256 end
257 257 @available_filters["project_id"] = { :type => :list, :order => 1, :values => project_values} unless project_values.empty?
258 258 end
259 259 end
260 260 principals.uniq!
261 261 principals.sort!
262 262 users = principals.select {|p| p.is_a?(User)}
263 263
264 264 assigned_to_values = []
265 265 assigned_to_values << ["<< #{l(:label_me)} >>", "me"] if User.current.logged?
266 266 assigned_to_values += (Setting.issue_group_assignment? ? principals : users).collect{|s| [s.name, s.id.to_s] }
267 267 @available_filters["assigned_to_id"] = { :type => :list_optional, :order => 4, :values => assigned_to_values } unless assigned_to_values.empty?
268 268
269 269 author_values = []
270 270 author_values << ["<< #{l(:label_me)} >>", "me"] if User.current.logged?
271 271 author_values += users.collect{|s| [s.name, s.id.to_s] }
272 272 @available_filters["author_id"] = { :type => :list, :order => 5, :values => author_values } unless author_values.empty?
273 273
274 274 group_values = Group.all.collect {|g| [g.name, g.id.to_s] }
275 275 @available_filters["member_of_group"] = { :type => :list_optional, :order => 6, :values => group_values } unless group_values.empty?
276 276
277 277 role_values = Role.givable.collect {|r| [r.name, r.id.to_s] }
278 278 @available_filters["assigned_to_role"] = { :type => :list_optional, :order => 7, :values => role_values } unless role_values.empty?
279 279
280 280 if User.current.logged?
281 281 @available_filters["watcher_id"] = { :type => :list, :order => 15, :values => [["<< #{l(:label_me)} >>", "me"]] }
282 282 end
283 283
284 284 if project
285 285 # project specific filters
286 286 categories = project.issue_categories.all
287 287 unless categories.empty?
288 288 @available_filters["category_id"] = { :type => :list_optional, :order => 6, :values => categories.collect{|s| [s.name, s.id.to_s] } }
289 289 end
290 290 versions = project.shared_versions.all
291 291 unless versions.empty?
292 292 @available_filters["fixed_version_id"] = { :type => :list_optional, :order => 7, :values => versions.sort.collect{|s| ["#{s.project.name} - #{s.name}", s.id.to_s] } }
293 293 end
294 294 add_custom_fields_filters(project.all_issue_custom_fields)
295 295 else
296 296 # global filters for cross project issue list
297 297 system_shared_versions = Version.visible.find_all_by_sharing('system')
298 298 unless system_shared_versions.empty?
299 299 @available_filters["fixed_version_id"] = { :type => :list_optional, :order => 7, :values => system_shared_versions.sort.collect{|s| ["#{s.project.name} - #{s.name}", s.id.to_s] } }
300 300 end
301 301 add_custom_fields_filters(IssueCustomField.find(:all, :conditions => {:is_filter => true, :is_for_all => true}))
302 302 end
303 303 @available_filters
304 304 end
305 305
306 306 def add_filter(field, operator, values)
307 307 # values must be an array
308 308 return unless values.nil? || values.is_a?(Array)
309 309 # check if field is defined as an available filter
310 310 if available_filters.has_key? field
311 311 filter_options = available_filters[field]
312 312 # check if operator is allowed for that filter
313 313 #if @@operators_by_filter_type[filter_options[:type]].include? operator
314 314 # allowed_values = values & ([""] + (filter_options[:values] || []).collect {|val| val[1]})
315 315 # filters[field] = {:operator => operator, :values => allowed_values } if (allowed_values.first and !allowed_values.first.empty?) or ["o", "c", "!*", "*", "t"].include? operator
316 316 #end
317 317 filters[field] = {:operator => operator, :values => (values || [''])}
318 318 end
319 319 end
320 320
321 321 def add_short_filter(field, expression)
322 322 return unless expression && available_filters.has_key?(field)
323 323 field_type = available_filters[field][:type]
324 324 @@operators_by_filter_type[field_type].sort.reverse.detect do |operator|
325 325 next unless expression =~ /^#{Regexp.escape(operator)}(.*)$/
326 326 add_filter field, operator, $1.present? ? $1.split('|') : ['']
327 327 end || add_filter(field, '=', expression.split('|'))
328 328 end
329 329
330 330 # Add multiple filters using +add_filter+
331 331 def add_filters(fields, operators, values)
332 332 if fields.is_a?(Array) && operators.is_a?(Hash) && (values.nil? || values.is_a?(Hash))
333 333 fields.each do |field|
334 334 add_filter(field, operators[field], values && values[field])
335 335 end
336 336 end
337 337 end
338 338
339 339 def has_filter?(field)
340 340 filters and filters[field]
341 341 end
342 342
343 343 def type_for(field)
344 344 available_filters[field][:type] if available_filters.has_key?(field)
345 345 end
346 346
347 347 def operator_for(field)
348 348 has_filter?(field) ? filters[field][:operator] : nil
349 349 end
350 350
351 351 def values_for(field)
352 352 has_filter?(field) ? filters[field][:values] : nil
353 353 end
354 354
355 355 def value_for(field, index=0)
356 356 (values_for(field) || [])[index]
357 357 end
358 358
359 359 def label_for(field)
360 360 label = available_filters[field][:name] if available_filters.has_key?(field)
361 361 label ||= l("field_#{field.to_s.gsub(/_id$/, '')}", :default => field)
362 362 end
363 363
364 364 def available_columns
365 365 return @available_columns if @available_columns
366 366 @available_columns = ::Query.available_columns.dup
367 367 @available_columns += (project ?
368 368 project.all_issue_custom_fields :
369 369 IssueCustomField.find(:all)
370 370 ).collect {|cf| QueryCustomFieldColumn.new(cf) }
371 371
372 372 if User.current.allowed_to?(:view_time_entries, project, :global => true)
373 373 index = nil
374 374 @available_columns.each_with_index {|column, i| index = i if column.name == :estimated_hours}
375 375 index = (index ? index + 1 : -1)
376 376 # insert the column after estimated_hours or at the end
377 377 @available_columns.insert index, QueryColumn.new(:spent_hours,
378 378 :sortable => "(SELECT COALESCE(SUM(hours), 0) FROM #{TimeEntry.table_name} WHERE #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id)",
379 379 :default_order => 'desc',
380 380 :caption => :label_spent_time
381 381 )
382 382 end
383 383 @available_columns
384 384 end
385 385
386 386 def self.available_columns=(v)
387 387 self.available_columns = (v)
388 388 end
389 389
390 390 def self.add_available_column(column)
391 391 self.available_columns << (column) if column.is_a?(QueryColumn)
392 392 end
393 393
394 394 # Returns an array of columns that can be used to group the results
395 395 def groupable_columns
396 396 available_columns.select {|c| c.groupable}
397 397 end
398 398
399 399 # Returns a Hash of columns and the key for sorting
400 400 def sortable_columns
401 401 {'id' => "#{Issue.table_name}.id"}.merge(available_columns.inject({}) {|h, column|
402 402 h[column.name.to_s] = column.sortable
403 403 h
404 404 })
405 405 end
406 406
407 407 def columns
408 408 # preserve the column_names order
409 409 (has_default_columns? ? default_columns_names : column_names).collect do |name|
410 410 available_columns.find { |col| col.name == name }
411 411 end.compact
412 412 end
413 413
414 414 def default_columns_names
415 415 @default_columns_names ||= begin
416 416 default_columns = Setting.issue_list_default_columns.map(&:to_sym)
417 417
418 418 project.present? ? default_columns : [:project] | default_columns
419 419 end
420 420 end
421 421
422 422 def column_names=(names)
423 423 if names
424 424 names = names.select {|n| n.is_a?(Symbol) || !n.blank? }
425 425 names = names.collect {|n| n.is_a?(Symbol) ? n : n.to_sym }
426 426 # Set column_names to nil if default columns
427 427 if names == default_columns_names
428 428 names = nil
429 429 end
430 430 end
431 431 write_attribute(:column_names, names)
432 432 end
433 433
434 434 def has_column?(column)
435 435 column_names && column_names.include?(column.is_a?(QueryColumn) ? column.name : column)
436 436 end
437 437
438 438 def has_default_columns?
439 439 column_names.nil? || column_names.empty?
440 440 end
441 441
442 442 def sort_criteria=(arg)
443 443 c = []
444 444 if arg.is_a?(Hash)
445 445 arg = arg.keys.sort.collect {|k| arg[k]}
446 446 end
447 447 c = arg.select {|k,o| !k.to_s.blank?}.slice(0,3).collect {|k,o| [k.to_s, o == 'desc' ? o : 'asc']}
448 448 write_attribute(:sort_criteria, c)
449 449 end
450 450
451 451 def sort_criteria
452 452 read_attribute(:sort_criteria) || []
453 453 end
454 454
455 455 def sort_criteria_key(arg)
456 456 sort_criteria && sort_criteria[arg] && sort_criteria[arg].first
457 457 end
458 458
459 459 def sort_criteria_order(arg)
460 460 sort_criteria && sort_criteria[arg] && sort_criteria[arg].last
461 461 end
462 462
463 463 # Returns the SQL sort order that should be prepended for grouping
464 464 def group_by_sort_order
465 465 if grouped? && (column = group_by_column)
466 466 column.sortable.is_a?(Array) ?
467 467 column.sortable.collect {|s| "#{s} #{column.default_order}"}.join(',') :
468 468 "#{column.sortable} #{column.default_order}"
469 469 end
470 470 end
471 471
472 472 # Returns true if the query is a grouped query
473 473 def grouped?
474 474 !group_by_column.nil?
475 475 end
476 476
477 477 def group_by_column
478 478 groupable_columns.detect {|c| c.groupable && c.name.to_s == group_by}
479 479 end
480 480
481 481 def group_by_statement
482 482 group_by_column.try(:groupable)
483 483 end
484 484
485 485 def project_statement
486 486 project_clauses = []
487 487 if project && !project.descendants.active.empty?
488 488 ids = [project.id]
489 489 if has_filter?("subproject_id")
490 490 case operator_for("subproject_id")
491 491 when '='
492 492 # include the selected subprojects
493 493 ids += values_for("subproject_id").each(&:to_i)
494 494 when '!*'
495 495 # main project only
496 496 else
497 497 # all subprojects
498 498 ids += project.descendants.collect(&:id)
499 499 end
500 500 elsif Setting.display_subprojects_issues?
501 501 ids += project.descendants.collect(&:id)
502 502 end
503 503 project_clauses << "#{Project.table_name}.id IN (%s)" % ids.join(',')
504 504 elsif project
505 505 project_clauses << "#{Project.table_name}.id = %d" % project.id
506 506 end
507 507 project_clauses.any? ? project_clauses.join(' AND ') : nil
508 508 end
509 509
510 510 def statement
511 511 # filters clauses
512 512 filters_clauses = []
513 513 filters.each_key do |field|
514 514 next if field == "subproject_id"
515 515 v = values_for(field).clone
516 516 next unless v and !v.empty?
517 517 operator = operator_for(field)
518 518
519 519 # "me" value subsitution
520 520 if %w(assigned_to_id author_id watcher_id).include?(field)
521 521 if v.delete("me")
522 522 if User.current.logged?
523 523 v.push(User.current.id.to_s)
524 524 v += User.current.group_ids.map(&:to_s) if field == 'assigned_to_id'
525 525 else
526 526 v.push("0")
527 527 end
528 528 end
529 529 end
530 530
531 531 if field == 'project_id'
532 532 if v.delete('mine')
533 533 v += User.current.memberships.map(&:project_id).map(&:to_s)
534 534 end
535 535 end
536 536
537 537 if field =~ /^cf_(\d+)$/
538 538 # custom field
539 539 filters_clauses << sql_for_custom_field(field, operator, v, $1)
540 540 elsif respond_to?("sql_for_#{field}_field")
541 541 # specific statement
542 542 filters_clauses << send("sql_for_#{field}_field", field, operator, v)
543 543 else
544 544 # regular field
545 545 filters_clauses << '(' + sql_for_field(field, operator, v, Issue.table_name, field) + ')'
546 546 end
547 547 end if filters and valid?
548 548
549 549 filters_clauses << project_statement
550 550 filters_clauses.reject!(&:blank?)
551 551
552 552 filters_clauses.any? ? filters_clauses.join(' AND ') : nil
553 553 end
554 554
555 555 # Returns the issue count
556 556 def issue_count
557 557 Issue.visible.count(:include => [:status, :project], :conditions => statement)
558 558 rescue ::ActiveRecord::StatementInvalid => e
559 559 raise StatementInvalid.new(e.message)
560 560 end
561 561
562 562 # Returns the issue count by group or nil if query is not grouped
563 563 def issue_count_by_group
564 564 r = nil
565 565 if grouped?
566 566 begin
567 567 # Rails3 will raise an (unexpected) RecordNotFound if there's only a nil group value
568 568 r = Issue.visible.count(:group => group_by_statement, :include => [:status, :project], :conditions => statement)
569 569 rescue ActiveRecord::RecordNotFound
570 570 r = {nil => issue_count}
571 571 end
572 572 c = group_by_column
573 573 if c.is_a?(QueryCustomFieldColumn)
574 574 r = r.keys.inject({}) {|h, k| h[c.custom_field.cast_value(k)] = r[k]; h}
575 575 end
576 576 end
577 577 r
578 578 rescue ::ActiveRecord::StatementInvalid => e
579 579 raise StatementInvalid.new(e.message)
580 580 end
581 581
582 582 # Returns the issues
583 583 # Valid options are :order, :offset, :limit, :include, :conditions
584 584 def issues(options={})
585 585 order_option = [group_by_sort_order, options[:order]].reject {|s| s.blank?}.join(',')
586 586 order_option = nil if order_option.blank?
587 587
588 588 joins = (order_option && order_option.include?('authors')) ? "LEFT OUTER JOIN users authors ON authors.id = #{Issue.table_name}.author_id" : nil
589 589
590 590 issues = Issue.visible.scoped(:conditions => options[:conditions]).find :all, :include => ([:status, :project] + (options[:include] || [])).uniq,
591 591 :conditions => statement,
592 592 :order => order_option,
593 593 :joins => joins,
594 594 :limit => options[:limit],
595 595 :offset => options[:offset]
596 596
597 597 if has_column?(:spent_hours)
598 598 Issue.load_visible_spent_hours(issues)
599 599 end
600 600 issues
601 601 rescue ::ActiveRecord::StatementInvalid => e
602 602 raise StatementInvalid.new(e.message)
603 603 end
604 604
605 605 # Returns the issues ids
606 606 def issue_ids(options={})
607 607 order_option = [group_by_sort_order, options[:order]].reject {|s| s.blank?}.join(',')
608 608 order_option = nil if order_option.blank?
609 609
610 610 joins = (order_option && order_option.include?('authors')) ? "LEFT OUTER JOIN users authors ON authors.id = #{Issue.table_name}.author_id" : nil
611 611
612 612 Issue.visible.scoped(:conditions => options[:conditions]).scoped(:include => ([:status, :project] + (options[:include] || [])).uniq,
613 613 :conditions => statement,
614 614 :order => order_option,
615 615 :joins => joins,
616 616 :limit => options[:limit],
617 617 :offset => options[:offset]).find_ids
618 618 rescue ::ActiveRecord::StatementInvalid => e
619 619 raise StatementInvalid.new(e.message)
620 620 end
621 621
622 622 # Returns the journals
623 623 # Valid options are :order, :offset, :limit
624 624 def journals(options={})
625 625 Journal.visible.find :all, :include => [:details, :user, {:issue => [:project, :author, :tracker, :status]}],
626 626 :conditions => statement,
627 627 :order => options[:order],
628 628 :limit => options[:limit],
629 629 :offset => options[:offset]
630 630 rescue ::ActiveRecord::StatementInvalid => e
631 631 raise StatementInvalid.new(e.message)
632 632 end
633 633
634 634 # Returns the versions
635 635 # Valid options are :conditions
636 636 def versions(options={})
637 637 Version.visible.scoped(:conditions => options[:conditions]).find :all, :include => :project, :conditions => project_statement
638 638 rescue ::ActiveRecord::StatementInvalid => e
639 639 raise StatementInvalid.new(e.message)
640 640 end
641 641
642 642 def sql_for_watcher_id_field(field, operator, value)
643 643 db_table = Watcher.table_name
644 644 "#{Issue.table_name}.id #{ operator == '=' ? 'IN' : 'NOT IN' } (SELECT #{db_table}.watchable_id FROM #{db_table} WHERE #{db_table}.watchable_type='Issue' AND " +
645 645 sql_for_field(field, '=', value, db_table, 'user_id') + ')'
646 646 end
647 647
648 648 def sql_for_member_of_group_field(field, operator, value)
649 649 if operator == '*' # Any group
650 650 groups = Group.all
651 651 operator = '=' # Override the operator since we want to find by assigned_to
652 652 elsif operator == "!*"
653 653 groups = Group.all
654 654 operator = '!' # Override the operator since we want to find by assigned_to
655 655 else
656 656 groups = Group.find_all_by_id(value)
657 657 end
658 658 groups ||= []
659 659
660 660 members_of_groups = groups.inject([]) {|user_ids, group|
661 661 if group && group.user_ids.present?
662 662 user_ids << group.user_ids
663 663 end
664 664 user_ids.flatten.uniq.compact
665 665 }.sort.collect(&:to_s)
666 666
667 667 '(' + sql_for_field("assigned_to_id", operator, members_of_groups, Issue.table_name, "assigned_to_id", false) + ')'
668 668 end
669 669
670 670 def sql_for_assigned_to_role_field(field, operator, value)
671 671 case operator
672 672 when "*", "!*" # Member / Not member
673 673 sw = operator == "!*" ? 'NOT' : ''
674 674 nl = operator == "!*" ? "#{Issue.table_name}.assigned_to_id IS NULL OR" : ''
675 675 "(#{nl} #{Issue.table_name}.assigned_to_id #{sw} IN (SELECT DISTINCT #{Member.table_name}.user_id FROM #{Member.table_name}" +
676 676 " WHERE #{Member.table_name}.project_id = #{Issue.table_name}.project_id))"
677 677 when "=", "!"
678 678 role_cond = value.any? ?
679 679 "#{MemberRole.table_name}.role_id IN (" + value.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + ")" :
680 680 "1=0"
681 681
682 682 sw = operator == "!" ? 'NOT' : ''
683 683 nl = operator == "!" ? "#{Issue.table_name}.assigned_to_id IS NULL OR" : ''
684 684 "(#{nl} #{Issue.table_name}.assigned_to_id #{sw} IN (SELECT DISTINCT #{Member.table_name}.user_id FROM #{Member.table_name}, #{MemberRole.table_name}" +
685 685 " WHERE #{Member.table_name}.project_id = #{Issue.table_name}.project_id AND #{Member.table_name}.id = #{MemberRole.table_name}.member_id AND #{role_cond}))"
686 686 end
687 687 end
688 688
689 689 private
690 690
691 691 def sql_for_custom_field(field, operator, value, custom_field_id)
692 692 db_table = CustomValue.table_name
693 693 db_field = 'value'
694 694 filter = @available_filters[field]
695 695 if filter && filter[:format] == 'user'
696 696 if value.delete('me')
697 697 value.push User.current.id.to_s
698 698 end
699 699 end
700 700 not_in = nil
701 701 if operator == '!'
702 702 # Makes ! operator work for custom fields with multiple values
703 703 operator = '='
704 704 not_in = 'NOT'
705 705 end
706 706 "#{Issue.table_name}.id #{not_in} IN (SELECT #{Issue.table_name}.id FROM #{Issue.table_name} LEFT OUTER JOIN #{db_table} ON #{db_table}.customized_type='Issue' AND #{db_table}.customized_id=#{Issue.table_name}.id AND #{db_table}.custom_field_id=#{custom_field_id} WHERE " +
707 707 sql_for_field(field, operator, value, db_table, db_field, true) + ')'
708 708 end
709 709
710 710 # Helper method to generate the WHERE sql for a +field+, +operator+ and a +value+
711 711 def sql_for_field(field, operator, value, db_table, db_field, is_custom_filter=false)
712 712 sql = ''
713 713 case operator
714 714 when "="
715 715 if value.any?
716 716 case type_for(field)
717 717 when :date, :date_past
718 718 sql = date_clause(db_table, db_field, (Date.parse(value.first) rescue nil), (Date.parse(value.first) rescue nil))
719 719 when :integer
720 720 if is_custom_filter
721 721 sql = "(#{db_table}.#{db_field} <> '' AND CAST(#{db_table}.#{db_field} AS decimal(60,3)) = #{value.first.to_i})"
722 722 else
723 723 sql = "#{db_table}.#{db_field} = #{value.first.to_i}"
724 724 end
725 725 when :float
726 726 if is_custom_filter
727 727 sql = "(#{db_table}.#{db_field} <> '' AND CAST(#{db_table}.#{db_field} AS decimal(60,3)) BETWEEN #{value.first.to_f - 1e-5} AND #{value.first.to_f + 1e-5})"
728 728 else
729 729 sql = "#{db_table}.#{db_field} BETWEEN #{value.first.to_f - 1e-5} AND #{value.first.to_f + 1e-5}"
730 730 end
731 731 else
732 732 sql = "#{db_table}.#{db_field} IN (" + value.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + ")"
733 733 end
734 734 else
735 735 # IN an empty set
736 736 sql = "1=0"
737 737 end
738 738 when "!"
739 739 if value.any?
740 740 sql = "(#{db_table}.#{db_field} IS NULL OR #{db_table}.#{db_field} NOT IN (" + value.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + "))"
741 741 else
742 742 # NOT IN an empty set
743 743 sql = "1=1"
744 744 end
745 745 when "!*"
746 746 sql = "#{db_table}.#{db_field} IS NULL"
747 747 sql << " OR #{db_table}.#{db_field} = ''" if is_custom_filter
748 748 when "*"
749 749 sql = "#{db_table}.#{db_field} IS NOT NULL"
750 750 sql << " AND #{db_table}.#{db_field} <> ''" if is_custom_filter
751 751 when ">="
752 752 if [:date, :date_past].include?(type_for(field))
753 753 sql = date_clause(db_table, db_field, (Date.parse(value.first) rescue nil), nil)
754 754 else
755 755 if is_custom_filter
756 756 sql = "(#{db_table}.#{db_field} <> '' AND CAST(#{db_table}.#{db_field} AS decimal(60,3)) >= #{value.first.to_f})"
757 757 else
758 758 sql = "#{db_table}.#{db_field} >= #{value.first.to_f}"
759 759 end
760 760 end
761 761 when "<="
762 762 if [:date, :date_past].include?(type_for(field))
763 763 sql = date_clause(db_table, db_field, nil, (Date.parse(value.first) rescue nil))
764 764 else
765 765 if is_custom_filter
766 766 sql = "(#{db_table}.#{db_field} <> '' AND CAST(#{db_table}.#{db_field} AS decimal(60,3)) <= #{value.first.to_f})"
767 767 else
768 768 sql = "#{db_table}.#{db_field} <= #{value.first.to_f}"
769 769 end
770 770 end
771 771 when "><"
772 772 if [:date, :date_past].include?(type_for(field))
773 773 sql = date_clause(db_table, db_field, (Date.parse(value[0]) rescue nil), (Date.parse(value[1]) rescue nil))
774 774 else
775 775 if is_custom_filter
776 776 sql = "(#{db_table}.#{db_field} <> '' AND CAST(#{db_table}.#{db_field} AS decimal(60,3)) BETWEEN #{value[0].to_f} AND #{value[1].to_f})"
777 777 else
778 778 sql = "#{db_table}.#{db_field} BETWEEN #{value[0].to_f} AND #{value[1].to_f}"
779 779 end
780 780 end
781 781 when "o"
782 782 sql = "#{Issue.table_name}.status_id IN (SELECT id FROM #{IssueStatus.table_name} WHERE is_closed=#{connection.quoted_false})" if field == "status_id"
783 783 when "c"
784 784 sql = "#{Issue.table_name}.status_id IN (SELECT id FROM #{IssueStatus.table_name} WHERE is_closed=#{connection.quoted_true})" if field == "status_id"
785 785 when ">t-"
786 786 sql = relative_date_clause(db_table, db_field, - value.first.to_i, 0)
787 787 when "<t-"
788 788 sql = relative_date_clause(db_table, db_field, nil, - value.first.to_i)
789 789 when "t-"
790 790 sql = relative_date_clause(db_table, db_field, - value.first.to_i, - value.first.to_i)
791 791 when ">t+"
792 792 sql = relative_date_clause(db_table, db_field, value.first.to_i, nil)
793 793 when "<t+"
794 794 sql = relative_date_clause(db_table, db_field, 0, value.first.to_i)
795 795 when "t+"
796 796 sql = relative_date_clause(db_table, db_field, value.first.to_i, value.first.to_i)
797 797 when "t"
798 798 sql = relative_date_clause(db_table, db_field, 0, 0)
799 799 when "w"
800 800 first_day_of_week = l(:general_first_day_of_week).to_i
801 801 day_of_week = Date.today.cwday
802 802 days_ago = (day_of_week >= first_day_of_week ? day_of_week - first_day_of_week : day_of_week + 7 - first_day_of_week)
803 803 sql = relative_date_clause(db_table, db_field, - days_ago, - days_ago + 6)
804 804 when "~"
805 805 sql = "LOWER(#{db_table}.#{db_field}) LIKE '%#{connection.quote_string(value.first.to_s.downcase)}%'"
806 806 when "!~"
807 807 sql = "LOWER(#{db_table}.#{db_field}) NOT LIKE '%#{connection.quote_string(value.first.to_s.downcase)}%'"
808 808 else
809 809 raise "Unknown query operator #{operator}"
810 810 end
811 811
812 812 return sql
813 813 end
814 814
815 815 def add_custom_fields_filters(custom_fields)
816 816 @available_filters ||= {}
817 817
818 818 custom_fields.select(&:is_filter?).each do |field|
819 819 case field.field_format
820 820 when "text"
821 821 options = { :type => :text, :order => 20 }
822 822 when "list"
823 823 options = { :type => :list_optional, :values => field.possible_values, :order => 20}
824 824 when "date"
825 825 options = { :type => :date, :order => 20 }
826 826 when "bool"
827 827 options = { :type => :list, :values => [[l(:general_text_yes), "1"], [l(:general_text_no), "0"]], :order => 20 }
828 828 when "int"
829 829 options = { :type => :integer, :order => 20 }
830 830 when "float"
831 831 options = { :type => :float, :order => 20 }
832 832 when "user", "version"
833 833 next unless project
834 834 values = field.possible_values_options(project)
835 835 if User.current.logged? && field.field_format == 'user'
836 836 values.unshift ["<< #{l(:label_me)} >>", "me"]
837 837 end
838 838 options = { :type => :list_optional, :values => values, :order => 20}
839 839 else
840 840 options = { :type => :string, :order => 20 }
841 841 end
842 842 @available_filters["cf_#{field.id}"] = options.merge({ :name => field.name, :format => field.field_format })
843 843 end
844 844 end
845 845
846 846 # Returns a SQL clause for a date or datetime field.
847 847 def date_clause(table, field, from, to)
848 848 s = []
849 849 if from
850 850 from_yesterday = from - 1
851 from_yesterday_utc = Time.gm(from_yesterday.year, from_yesterday.month, from_yesterday.day)
852 s << ("#{table}.#{field} > '%s'" % [connection.quoted_date(from_yesterday_utc.end_of_day)])
851 from_yesterday_time = Time.local(from_yesterday.year, from_yesterday.month, from_yesterday.day)
852 if self.class.default_timezone == :utc
853 from_yesterday_time = from_yesterday_time.utc
854 end
855 s << ("#{table}.#{field} > '%s'" % [connection.quoted_date(from_yesterday_time.end_of_day)])
853 856 end
854 857 if to
855 to_utc = Time.gm(to.year, to.month, to.day)
856 s << ("#{table}.#{field} <= '%s'" % [connection.quoted_date(to_utc.end_of_day)])
858 to_time = Time.local(to.year, to.month, to.day)
859 if self.class.default_timezone == :utc
860 to_time = to_time.utc
861 end
862 s << ("#{table}.#{field} <= '%s'" % [connection.quoted_date(to_time.end_of_day)])
857 863 end
858 864 s.join(' AND ')
859 865 end
860 866
861 867 # Returns a SQL clause for a date or datetime field using relative dates.
862 868 def relative_date_clause(table, field, days_from, days_to)
863 869 date_clause(table, field, (days_from ? Date.today + days_from : nil), (days_to ? Date.today + days_to : nil))
864 870 end
865 871 end
@@ -1,657 +1,666
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 require "digest/sha1"
19 19
20 20 class User < Principal
21 21 include Redmine::SafeAttributes
22 22
23 23 # Account statuses
24 24 STATUS_ANONYMOUS = 0
25 25 STATUS_ACTIVE = 1
26 26 STATUS_REGISTERED = 2
27 27 STATUS_LOCKED = 3
28 28
29 29 # Different ways of displaying/sorting users
30 30 USER_FORMATS = {
31 31 :firstname_lastname => {:string => '#{firstname} #{lastname}', :order => %w(firstname lastname id)},
32 32 :firstname => {:string => '#{firstname}', :order => %w(firstname id)},
33 33 :lastname_firstname => {:string => '#{lastname} #{firstname}', :order => %w(lastname firstname id)},
34 34 :lastname_coma_firstname => {:string => '#{lastname}, #{firstname}', :order => %w(lastname firstname id)},
35 35 :username => {:string => '#{login}', :order => %w(login id)},
36 36 }
37 37
38 38 MAIL_NOTIFICATION_OPTIONS = [
39 39 ['all', :label_user_mail_option_all],
40 40 ['selected', :label_user_mail_option_selected],
41 41 ['only_my_events', :label_user_mail_option_only_my_events],
42 42 ['only_assigned', :label_user_mail_option_only_assigned],
43 43 ['only_owner', :label_user_mail_option_only_owner],
44 44 ['none', :label_user_mail_option_none]
45 45 ]
46 46
47 47 has_and_belongs_to_many :groups, :after_add => Proc.new {|user, group| group.user_added(user)},
48 48 :after_remove => Proc.new {|user, group| group.user_removed(user)}
49 49 has_many :changesets, :dependent => :nullify
50 50 has_one :preference, :dependent => :destroy, :class_name => 'UserPreference'
51 51 has_one :rss_token, :class_name => 'Token', :conditions => "action='feeds'"
52 52 has_one :api_token, :class_name => 'Token', :conditions => "action='api'"
53 53 belongs_to :auth_source
54 54
55 55 # Active non-anonymous users scope
56 56 scope :active, :conditions => "#{User.table_name}.status = #{STATUS_ACTIVE}"
57 57 scope :logged, :conditions => "#{User.table_name}.status <> #{STATUS_ANONYMOUS}"
58 58 scope :status, lambda {|arg| arg.blank? ? {} : {:conditions => {:status => arg.to_i}} }
59 59
60 60 acts_as_customizable
61 61
62 62 attr_accessor :password, :password_confirmation
63 63 attr_accessor :last_before_login_on
64 64 # Prevents unauthorized assignments
65 65 attr_protected :login, :admin, :password, :password_confirmation, :hashed_password
66 66
67 67 LOGIN_LENGTH_LIMIT = 60
68 68 MAIL_LENGTH_LIMIT = 60
69 69
70 70 validates_presence_of :login, :firstname, :lastname, :mail, :if => Proc.new { |user| !user.is_a?(AnonymousUser) }
71 71 validates_uniqueness_of :login, :if => Proc.new { |user| user.login_changed? && user.login.present? }, :case_sensitive => false
72 72 validates_uniqueness_of :mail, :if => Proc.new { |user| !user.mail.blank? }, :case_sensitive => false
73 73 # Login must contain lettres, numbers, underscores only
74 74 validates_format_of :login, :with => /^[a-z0-9_\-@\.]*$/i
75 75 validates_length_of :login, :maximum => LOGIN_LENGTH_LIMIT
76 76 validates_length_of :firstname, :lastname, :maximum => 30
77 77 validates_format_of :mail, :with => /^([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})$/i, :allow_blank => true
78 78 validates_length_of :mail, :maximum => MAIL_LENGTH_LIMIT, :allow_nil => true
79 79 validates_confirmation_of :password, :allow_nil => true
80 80 validates_inclusion_of :mail_notification, :in => MAIL_NOTIFICATION_OPTIONS.collect(&:first), :allow_blank => true
81 81 validate :validate_password_length
82 82
83 83 before_create :set_mail_notification
84 84 before_save :update_hashed_password
85 85 before_destroy :remove_references_before_destroy
86 86
87 87 scope :in_group, lambda {|group|
88 88 group_id = group.is_a?(Group) ? group.id : group.to_i
89 89 { :conditions => ["#{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] }
90 90 }
91 91 scope :not_in_group, lambda {|group|
92 92 group_id = group.is_a?(Group) ? group.id : group.to_i
93 93 { :conditions => ["#{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] }
94 94 }
95 95
96 96 def set_mail_notification
97 97 self.mail_notification = Setting.default_notification_option if self.mail_notification.blank?
98 98 true
99 99 end
100 100
101 101 def update_hashed_password
102 102 # update hashed_password if password was set
103 103 if self.password && self.auth_source_id.blank?
104 104 salt_password(password)
105 105 end
106 106 end
107 107
108 108 def reload(*args)
109 109 @name = nil
110 110 @projects_by_role = nil
111 111 super
112 112 end
113 113
114 114 def mail=(arg)
115 115 write_attribute(:mail, arg.to_s.strip)
116 116 end
117 117
118 118 def identity_url=(url)
119 119 if url.blank?
120 120 write_attribute(:identity_url, '')
121 121 else
122 122 begin
123 123 write_attribute(:identity_url, OpenIdAuthentication.normalize_identifier(url))
124 124 rescue OpenIdAuthentication::InvalidOpenId
125 125 # Invlaid url, don't save
126 126 end
127 127 end
128 128 self.read_attribute(:identity_url)
129 129 end
130 130
131 131 # Returns the user that matches provided login and password, or nil
132 132 def self.try_to_login(login, password)
133 133 # Make sure no one can sign in with an empty password
134 134 return nil if password.to_s.empty?
135 135 user = find_by_login(login)
136 136 if user
137 137 # user is already in local database
138 138 return nil if !user.active?
139 139 if user.auth_source
140 140 # user has an external authentication method
141 141 return nil unless user.auth_source.authenticate(login, password)
142 142 else
143 143 # authentication with local password
144 144 return nil unless user.check_password?(password)
145 145 end
146 146 else
147 147 # user is not yet registered, try to authenticate with available sources
148 148 attrs = AuthSource.authenticate(login, password)
149 149 if attrs
150 150 user = new(attrs)
151 151 user.login = login
152 152 user.language = Setting.default_language
153 153 if user.save
154 154 user.reload
155 155 logger.info("User '#{user.login}' created from external auth source: #{user.auth_source.type} - #{user.auth_source.name}") if logger && user.auth_source
156 156 end
157 157 end
158 158 end
159 159 user.update_attribute(:last_login_on, Time.now) if user && !user.new_record?
160 160 user
161 161 rescue => text
162 162 raise text
163 163 end
164 164
165 165 # Returns the user who matches the given autologin +key+ or nil
166 166 def self.try_to_autologin(key)
167 167 tokens = Token.find_all_by_action_and_value('autologin', key)
168 168 # Make sure there's only 1 token that matches the key
169 169 if tokens.size == 1
170 170 token = tokens.first
171 171 if (token.created_on > Setting.autologin.to_i.day.ago) && token.user && token.user.active?
172 172 token.user.update_attribute(:last_login_on, Time.now)
173 173 token.user
174 174 end
175 175 end
176 176 end
177 177
178 178 def self.name_formatter(formatter = nil)
179 179 USER_FORMATS[formatter || Setting.user_format] || USER_FORMATS[:firstname_lastname]
180 180 end
181 181
182 182 # Returns an array of fields names than can be used to make an order statement for users
183 183 # according to how user names are displayed
184 184 # Examples:
185 185 #
186 186 # User.fields_for_order_statement => ['users.login', 'users.id']
187 187 # User.fields_for_order_statement('authors') => ['authors.login', 'authors.id']
188 188 def self.fields_for_order_statement(table=nil)
189 189 table ||= table_name
190 190 name_formatter[:order].map {|field| "#{table}.#{field}"}
191 191 end
192 192
193 193 # Return user's full name for display
194 194 def name(formatter = nil)
195 195 f = self.class.name_formatter(formatter)
196 196 if formatter
197 197 eval('"' + f[:string] + '"')
198 198 else
199 199 @name ||= eval('"' + f[:string] + '"')
200 200 end
201 201 end
202 202
203 203 def active?
204 204 self.status == STATUS_ACTIVE
205 205 end
206 206
207 207 def registered?
208 208 self.status == STATUS_REGISTERED
209 209 end
210 210
211 211 def locked?
212 212 self.status == STATUS_LOCKED
213 213 end
214 214
215 215 def activate
216 216 self.status = STATUS_ACTIVE
217 217 end
218 218
219 219 def register
220 220 self.status = STATUS_REGISTERED
221 221 end
222 222
223 223 def lock
224 224 self.status = STATUS_LOCKED
225 225 end
226 226
227 227 def activate!
228 228 update_attribute(:status, STATUS_ACTIVE)
229 229 end
230 230
231 231 def register!
232 232 update_attribute(:status, STATUS_REGISTERED)
233 233 end
234 234
235 235 def lock!
236 236 update_attribute(:status, STATUS_LOCKED)
237 237 end
238 238
239 239 # Returns true if +clear_password+ is the correct user's password, otherwise false
240 240 def check_password?(clear_password)
241 241 if auth_source_id.present?
242 242 auth_source.authenticate(self.login, clear_password)
243 243 else
244 244 User.hash_password("#{salt}#{User.hash_password clear_password}") == hashed_password
245 245 end
246 246 end
247 247
248 248 # Generates a random salt and computes hashed_password for +clear_password+
249 249 # The hashed password is stored in the following form: SHA1(salt + SHA1(password))
250 250 def salt_password(clear_password)
251 251 self.salt = User.generate_salt
252 252 self.hashed_password = User.hash_password("#{salt}#{User.hash_password clear_password}")
253 253 end
254 254
255 255 # Does the backend storage allow this user to change their password?
256 256 def change_password_allowed?
257 257 return true if auth_source.nil?
258 258 return auth_source.allow_password_changes?
259 259 end
260 260
261 261 # Generate and set a random password. Useful for automated user creation
262 262 # Based on Token#generate_token_value
263 263 #
264 264 def random_password
265 265 chars = ("a".."z").to_a + ("A".."Z").to_a + ("0".."9").to_a
266 266 password = ''
267 267 40.times { |i| password << chars[rand(chars.size-1)] }
268 268 self.password = password
269 269 self.password_confirmation = password
270 270 self
271 271 end
272 272
273 273 def pref
274 274 self.preference ||= UserPreference.new(:user => self)
275 275 end
276 276
277 277 def time_zone
278 278 @time_zone ||= (self.pref.time_zone.blank? ? nil : ActiveSupport::TimeZone[self.pref.time_zone])
279 279 end
280 280
281 281 def wants_comments_in_reverse_order?
282 282 self.pref[:comments_sorting] == 'desc'
283 283 end
284 284
285 285 # Return user's RSS key (a 40 chars long string), used to access feeds
286 286 def rss_key
287 287 if rss_token.nil?
288 288 create_rss_token(:action => 'feeds')
289 289 end
290 290 rss_token.value
291 291 end
292 292
293 293 # Return user's API key (a 40 chars long string), used to access the API
294 294 def api_key
295 295 if api_token.nil?
296 296 create_api_token(:action => 'api')
297 297 end
298 298 api_token.value
299 299 end
300 300
301 301 # Return an array of project ids for which the user has explicitly turned mail notifications on
302 302 def notified_projects_ids
303 303 @notified_projects_ids ||= memberships.select {|m| m.mail_notification?}.collect(&:project_id)
304 304 end
305 305
306 306 def notified_project_ids=(ids)
307 307 Member.update_all("mail_notification = #{connection.quoted_false}", ['user_id = ?', id])
308 308 Member.update_all("mail_notification = #{connection.quoted_true}", ['user_id = ? AND project_id IN (?)', id, ids]) if ids && !ids.empty?
309 309 @notified_projects_ids = nil
310 310 notified_projects_ids
311 311 end
312 312
313 313 def valid_notification_options
314 314 self.class.valid_notification_options(self)
315 315 end
316 316
317 317 # Only users that belong to more than 1 project can select projects for which they are notified
318 318 def self.valid_notification_options(user=nil)
319 319 # Note that @user.membership.size would fail since AR ignores
320 320 # :include association option when doing a count
321 321 if user.nil? || user.memberships.length < 1
322 322 MAIL_NOTIFICATION_OPTIONS.reject {|option| option.first == 'selected'}
323 323 else
324 324 MAIL_NOTIFICATION_OPTIONS
325 325 end
326 326 end
327 327
328 328 # Find a user account by matching the exact login and then a case-insensitive
329 329 # version. Exact matches will be given priority.
330 330 def self.find_by_login(login)
331 331 # First look for an exact match
332 332 user = all(:conditions => {:login => login}).detect {|u| u.login == login}
333 333 unless user
334 334 # Fail over to case-insensitive if none was found
335 335 user = first(:conditions => ["LOWER(login) = ?", login.to_s.downcase])
336 336 end
337 337 user
338 338 end
339 339
340 340 def self.find_by_rss_key(key)
341 341 token = Token.find_by_value(key)
342 342 token && token.user.active? ? token.user : nil
343 343 end
344 344
345 345 def self.find_by_api_key(key)
346 346 token = Token.find_by_action_and_value('api', key)
347 347 token && token.user.active? ? token.user : nil
348 348 end
349 349
350 350 # Makes find_by_mail case-insensitive
351 351 def self.find_by_mail(mail)
352 352 find(:first, :conditions => ["LOWER(mail) = ?", mail.to_s.downcase])
353 353 end
354 354
355 355 # Returns true if the default admin account can no longer be used
356 356 def self.default_admin_account_changed?
357 357 !User.active.find_by_login("admin").try(:check_password?, "admin")
358 358 end
359 359
360 360 def to_s
361 361 name
362 362 end
363 363
364 364 # Returns the current day according to user's time zone
365 365 def today
366 366 if time_zone.nil?
367 367 Date.today
368 368 else
369 369 Time.now.in_time_zone(time_zone).to_date
370 370 end
371 371 end
372 372
373 # Returns the day of +time+ according to user's time zone
374 def time_to_date(time)
375 if time_zone.nil?
376 time.to_date
377 else
378 time.in_time_zone(time_zone).to_date
379 end
380 end
381
373 382 def logged?
374 383 true
375 384 end
376 385
377 386 def anonymous?
378 387 !logged?
379 388 end
380 389
381 390 # Return user's roles for project
382 391 def roles_for_project(project)
383 392 roles = []
384 393 # No role on archived projects
385 394 return roles unless project && project.active?
386 395 if logged?
387 396 # Find project membership
388 397 membership = memberships.detect {|m| m.project_id == project.id}
389 398 if membership
390 399 roles = membership.roles
391 400 else
392 401 @role_non_member ||= Role.non_member
393 402 roles << @role_non_member
394 403 end
395 404 else
396 405 @role_anonymous ||= Role.anonymous
397 406 roles << @role_anonymous
398 407 end
399 408 roles
400 409 end
401 410
402 411 # Return true if the user is a member of project
403 412 def member_of?(project)
404 413 !roles_for_project(project).detect {|role| role.member?}.nil?
405 414 end
406 415
407 416 # Returns a hash of user's projects grouped by roles
408 417 def projects_by_role
409 418 return @projects_by_role if @projects_by_role
410 419
411 420 @projects_by_role = Hash.new {|h,k| h[k]=[]}
412 421 memberships.each do |membership|
413 422 membership.roles.each do |role|
414 423 @projects_by_role[role] << membership.project if membership.project
415 424 end
416 425 end
417 426 @projects_by_role.each do |role, projects|
418 427 projects.uniq!
419 428 end
420 429
421 430 @projects_by_role
422 431 end
423 432
424 433 # Returns true if user is arg or belongs to arg
425 434 def is_or_belongs_to?(arg)
426 435 if arg.is_a?(User)
427 436 self == arg
428 437 elsif arg.is_a?(Group)
429 438 arg.users.include?(self)
430 439 else
431 440 false
432 441 end
433 442 end
434 443
435 444 # Return true if the user is allowed to do the specified action on a specific context
436 445 # Action can be:
437 446 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
438 447 # * a permission Symbol (eg. :edit_project)
439 448 # Context can be:
440 449 # * a project : returns true if user is allowed to do the specified action on this project
441 450 # * an array of projects : returns true if user is allowed on every project
442 451 # * nil with options[:global] set : check if user has at least one role allowed for this action,
443 452 # or falls back to Non Member / Anonymous permissions depending if the user is logged
444 453 def allowed_to?(action, context, options={}, &block)
445 454 if context && context.is_a?(Project)
446 455 # No action allowed on archived projects
447 456 return false unless context.active?
448 457 # No action allowed on disabled modules
449 458 return false unless context.allows_to?(action)
450 459 # Admin users are authorized for anything else
451 460 return true if admin?
452 461
453 462 roles = roles_for_project(context)
454 463 return false unless roles
455 464 roles.detect {|role|
456 465 (context.is_public? || role.member?) &&
457 466 role.allowed_to?(action) &&
458 467 (block_given? ? yield(role, self) : true)
459 468 }
460 469 elsif context && context.is_a?(Array)
461 470 # Authorize if user is authorized on every element of the array
462 471 context.map do |project|
463 472 allowed_to?(action, project, options, &block)
464 473 end.inject do |memo,allowed|
465 474 memo && allowed
466 475 end
467 476 elsif options[:global]
468 477 # Admin users are always authorized
469 478 return true if admin?
470 479
471 480 # authorize if user has at least one role that has this permission
472 481 roles = memberships.collect {|m| m.roles}.flatten.uniq
473 482 roles << (self.logged? ? Role.non_member : Role.anonymous)
474 483 roles.detect {|role|
475 484 role.allowed_to?(action) &&
476 485 (block_given? ? yield(role, self) : true)
477 486 }
478 487 else
479 488 false
480 489 end
481 490 end
482 491
483 492 # Is the user allowed to do the specified action on any project?
484 493 # See allowed_to? for the actions and valid options.
485 494 def allowed_to_globally?(action, options, &block)
486 495 allowed_to?(action, nil, options.reverse_merge(:global => true), &block)
487 496 end
488 497
489 498 # Returns true if the user is allowed to delete his own account
490 499 def own_account_deletable?
491 500 Setting.unsubscribe? &&
492 501 (!admin? || User.active.first(:conditions => ["admin = ? AND id <> ?", true, id]).present?)
493 502 end
494 503
495 504 safe_attributes 'login',
496 505 'firstname',
497 506 'lastname',
498 507 'mail',
499 508 'mail_notification',
500 509 'language',
501 510 'custom_field_values',
502 511 'custom_fields',
503 512 'identity_url'
504 513
505 514 safe_attributes 'status',
506 515 'auth_source_id',
507 516 :if => lambda {|user, current_user| current_user.admin?}
508 517
509 518 safe_attributes 'group_ids',
510 519 :if => lambda {|user, current_user| current_user.admin? && !user.new_record?}
511 520
512 521 # Utility method to help check if a user should be notified about an
513 522 # event.
514 523 #
515 524 # TODO: only supports Issue events currently
516 525 def notify_about?(object)
517 526 case mail_notification
518 527 when 'all'
519 528 true
520 529 when 'selected'
521 530 # user receives notifications for created/assigned issues on unselected projects
522 531 if object.is_a?(Issue) && (object.author == self || is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.assigned_to_was))
523 532 true
524 533 else
525 534 false
526 535 end
527 536 when 'none'
528 537 false
529 538 when 'only_my_events'
530 539 if object.is_a?(Issue) && (object.author == self || is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.assigned_to_was))
531 540 true
532 541 else
533 542 false
534 543 end
535 544 when 'only_assigned'
536 545 if object.is_a?(Issue) && (is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.assigned_to_was))
537 546 true
538 547 else
539 548 false
540 549 end
541 550 when 'only_owner'
542 551 if object.is_a?(Issue) && object.author == self
543 552 true
544 553 else
545 554 false
546 555 end
547 556 else
548 557 false
549 558 end
550 559 end
551 560
552 561 def self.current=(user)
553 562 @current_user = user
554 563 end
555 564
556 565 def self.current
557 566 @current_user ||= User.anonymous
558 567 end
559 568
560 569 # Returns the anonymous user. If the anonymous user does not exist, it is created. There can be only
561 570 # one anonymous user per database.
562 571 def self.anonymous
563 572 anonymous_user = AnonymousUser.find(:first)
564 573 if anonymous_user.nil?
565 574 anonymous_user = AnonymousUser.create(:lastname => 'Anonymous', :firstname => '', :mail => '', :login => '', :status => 0)
566 575 raise 'Unable to create the anonymous user.' if anonymous_user.new_record?
567 576 end
568 577 anonymous_user
569 578 end
570 579
571 580 # Salts all existing unsalted passwords
572 581 # It changes password storage scheme from SHA1(password) to SHA1(salt + SHA1(password))
573 582 # This method is used in the SaltPasswords migration and is to be kept as is
574 583 def self.salt_unsalted_passwords!
575 584 transaction do
576 585 User.find_each(:conditions => "salt IS NULL OR salt = ''") do |user|
577 586 next if user.hashed_password.blank?
578 587 salt = User.generate_salt
579 588 hashed_password = User.hash_password("#{salt}#{user.hashed_password}")
580 589 User.update_all("salt = '#{salt}', hashed_password = '#{hashed_password}'", ["id = ?", user.id] )
581 590 end
582 591 end
583 592 end
584 593
585 594 protected
586 595
587 596 def validate_password_length
588 597 # Password length validation based on setting
589 598 if !password.nil? && password.size < Setting.password_min_length.to_i
590 599 errors.add(:password, :too_short, :count => Setting.password_min_length.to_i)
591 600 end
592 601 end
593 602
594 603 private
595 604
596 605 # Removes references that are not handled by associations
597 606 # Things that are not deleted are reassociated with the anonymous user
598 607 def remove_references_before_destroy
599 608 return if self.id.nil?
600 609
601 610 substitute = User.anonymous
602 611 Attachment.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
603 612 Comment.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
604 613 Issue.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
605 614 Issue.update_all 'assigned_to_id = NULL', ['assigned_to_id = ?', id]
606 615 Journal.update_all ['user_id = ?', substitute.id], ['user_id = ?', id]
607 616 JournalDetail.update_all ['old_value = ?', substitute.id.to_s], ["property = 'attr' AND prop_key = 'assigned_to_id' AND old_value = ?", id.to_s]
608 617 JournalDetail.update_all ['value = ?', substitute.id.to_s], ["property = 'attr' AND prop_key = 'assigned_to_id' AND value = ?", id.to_s]
609 618 Message.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
610 619 News.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
611 620 # Remove private queries and keep public ones
612 621 ::Query.delete_all ['user_id = ? AND is_public = ?', id, false]
613 622 ::Query.update_all ['user_id = ?', substitute.id], ['user_id = ?', id]
614 623 TimeEntry.update_all ['user_id = ?', substitute.id], ['user_id = ?', id]
615 624 Token.delete_all ['user_id = ?', id]
616 625 Watcher.delete_all ['user_id = ?', id]
617 626 WikiContent.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
618 627 WikiContent::Version.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
619 628 end
620 629
621 630 # Return password digest
622 631 def self.hash_password(clear_password)
623 632 Digest::SHA1.hexdigest(clear_password || "")
624 633 end
625 634
626 635 # Returns a 128bits random salt as a hex string (32 chars long)
627 636 def self.generate_salt
628 637 Redmine::Utils.random_hex(16)
629 638 end
630 639
631 640 end
632 641
633 642 class AnonymousUser < User
634 643 validate :validate_anonymous_uniqueness, :on => :create
635 644
636 645 def validate_anonymous_uniqueness
637 646 # There should be only one AnonymousUser in the database
638 647 errors.add :base, 'An anonymous user already exists.' if AnonymousUser.find(:first)
639 648 end
640 649
641 650 def available_custom_fields
642 651 []
643 652 end
644 653
645 654 # Overrides a few properties
646 655 def logged?; false end
647 656 def admin; false end
648 657 def name(*args); I18n.t(:label_user_anonymous) end
649 658 def mail; nil end
650 659 def time_zone; nil end
651 660 def rss_key; nil end
652 661
653 662 # Anonymous user can not be destroyed
654 663 def destroy
655 664 false
656 665 end
657 666 end
@@ -1,234 +1,234
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 require 'diff'
19 19 require 'enumerator'
20 20
21 21 class WikiPage < ActiveRecord::Base
22 22 include Redmine::SafeAttributes
23 23
24 24 belongs_to :wiki
25 25 has_one :content, :class_name => 'WikiContent', :foreign_key => 'page_id', :dependent => :destroy
26 26 acts_as_attachable :delete_permission => :delete_wiki_pages_attachments
27 27 acts_as_tree :dependent => :nullify, :order => 'title'
28 28
29 29 acts_as_watchable
30 30 acts_as_event :title => Proc.new {|o| "#{l(:label_wiki)}: #{o.title}"},
31 31 :description => :text,
32 32 :datetime => :created_on,
33 33 :url => Proc.new {|o| {:controller => 'wiki', :action => 'show', :project_id => o.wiki.project, :id => o.title}}
34 34
35 35 acts_as_searchable :columns => ['title', "#{WikiContent.table_name}.text"],
36 36 :include => [{:wiki => :project}, :content],
37 37 :permission => :view_wiki_pages,
38 38 :project_key => "#{Wiki.table_name}.project_id"
39 39
40 40 attr_accessor :redirect_existing_links
41 41
42 42 validates_presence_of :title
43 43 validates_format_of :title, :with => /^[^,\.\/\?\;\|\s]*$/
44 44 validates_uniqueness_of :title, :scope => :wiki_id, :case_sensitive => false
45 45 validates_associated :content
46 46
47 47 validate :validate_parent_title
48 48 before_destroy :remove_redirects
49 49 before_save :handle_redirects
50 50
51 51 # eager load information about last updates, without loading text
52 52 scope :with_updated_on, {
53 53 :select => "#{WikiPage.table_name}.*, #{WikiContent.table_name}.updated_on",
54 54 :joins => "LEFT JOIN #{WikiContent.table_name} ON #{WikiContent.table_name}.page_id = #{WikiPage.table_name}.id"
55 55 }
56 56
57 57 # Wiki pages that are protected by default
58 58 DEFAULT_PROTECTED_PAGES = %w(sidebar)
59 59
60 60 safe_attributes 'parent_id',
61 61 :if => lambda {|page, user| page.new_record? || user.allowed_to?(:rename_wiki_pages, page.project)}
62 62
63 63 def initialize(attributes=nil, *args)
64 64 super
65 65 if new_record? && DEFAULT_PROTECTED_PAGES.include?(title.to_s.downcase)
66 66 self.protected = true
67 67 end
68 68 end
69 69
70 70 def visible?(user=User.current)
71 71 !user.nil? && user.allowed_to?(:view_wiki_pages, project)
72 72 end
73 73
74 74 def title=(value)
75 75 value = Wiki.titleize(value)
76 76 @previous_title = read_attribute(:title) if @previous_title.blank?
77 77 write_attribute(:title, value)
78 78 end
79 79
80 80 def handle_redirects
81 81 self.title = Wiki.titleize(title)
82 82 # Manage redirects if the title has changed
83 83 if !@previous_title.blank? && (@previous_title != title) && !new_record?
84 84 # Update redirects that point to the old title
85 85 wiki.redirects.find_all_by_redirects_to(@previous_title).each do |r|
86 86 r.redirects_to = title
87 87 r.title == r.redirects_to ? r.destroy : r.save
88 88 end
89 89 # Remove redirects for the new title
90 90 wiki.redirects.find_all_by_title(title).each(&:destroy)
91 91 # Create a redirect to the new title
92 92 wiki.redirects << WikiRedirect.new(:title => @previous_title, :redirects_to => title) unless redirect_existing_links == "0"
93 93 @previous_title = nil
94 94 end
95 95 end
96 96
97 97 def remove_redirects
98 98 # Remove redirects to this page
99 99 wiki.redirects.find_all_by_redirects_to(title).each(&:destroy)
100 100 end
101 101
102 102 def pretty_title
103 103 WikiPage.pretty_title(title)
104 104 end
105 105
106 106 def content_for_version(version=nil)
107 107 result = content.versions.find_by_version(version.to_i) if version
108 108 result ||= content
109 109 result
110 110 end
111 111
112 112 def diff(version_to=nil, version_from=nil)
113 113 version_to = version_to ? version_to.to_i : self.content.version
114 114 version_from = version_from ? version_from.to_i : version_to - 1
115 115 version_to, version_from = version_from, version_to unless version_from < version_to
116 116
117 117 content_to = content.versions.find_by_version(version_to)
118 118 content_from = content.versions.find_by_version(version_from)
119 119
120 120 (content_to && content_from) ? WikiDiff.new(content_to, content_from) : nil
121 121 end
122 122
123 123 def annotate(version=nil)
124 124 version = version ? version.to_i : self.content.version
125 125 c = content.versions.find_by_version(version)
126 126 c ? WikiAnnotate.new(c) : nil
127 127 end
128 128
129 129 def self.pretty_title(str)
130 130 (str && str.is_a?(String)) ? str.tr('_', ' ') : str
131 131 end
132 132
133 133 def project
134 134 wiki.project
135 135 end
136 136
137 137 def text
138 138 content.text if content
139 139 end
140 140
141 141 def updated_on
142 142 unless @updated_on
143 143 if time = read_attribute(:updated_on)
144 144 # content updated_on was eager loaded with the page
145 145 begin
146 @updated_on = Time.zone ? Time.zone.parse(time.to_s) : Time.parse(time.to_s)
146 @updated_on = (self.class.default_timezone == :utc ? Time.parse(time.to_s).utc : Time.parse(time.to_s).localtime)
147 147 rescue
148 148 end
149 149 else
150 150 @updated_on = content && content.updated_on
151 151 end
152 152 end
153 153 @updated_on
154 154 end
155 155
156 156 # Returns true if usr is allowed to edit the page, otherwise false
157 157 def editable_by?(usr)
158 158 !protected? || usr.allowed_to?(:protect_wiki_pages, wiki.project)
159 159 end
160 160
161 161 def attachments_deletable?(usr=User.current)
162 162 editable_by?(usr) && super(usr)
163 163 end
164 164
165 165 def parent_title
166 166 @parent_title || (self.parent && self.parent.pretty_title)
167 167 end
168 168
169 169 def parent_title=(t)
170 170 @parent_title = t
171 171 parent_page = t.blank? ? nil : self.wiki.find_page(t)
172 172 self.parent = parent_page
173 173 end
174 174
175 175 protected
176 176
177 177 def validate_parent_title
178 178 errors.add(:parent_title, :invalid) if !@parent_title.blank? && parent.nil?
179 179 errors.add(:parent_title, :circular_dependency) if parent && (parent == self || parent.ancestors.include?(self))
180 180 errors.add(:parent_title, :not_same_project) if parent && (parent.wiki_id != wiki_id)
181 181 end
182 182 end
183 183
184 184 class WikiDiff < Redmine::Helpers::Diff
185 185 attr_reader :content_to, :content_from
186 186
187 187 def initialize(content_to, content_from)
188 188 @content_to = content_to
189 189 @content_from = content_from
190 190 super(content_to.text, content_from.text)
191 191 end
192 192 end
193 193
194 194 class WikiAnnotate
195 195 attr_reader :lines, :content
196 196
197 197 def initialize(content)
198 198 @content = content
199 199 current = content
200 200 current_lines = current.text.split(/\r?\n/)
201 201 @lines = current_lines.collect {|t| [nil, nil, t]}
202 202 positions = []
203 203 current_lines.size.times {|i| positions << i}
204 204 while (current.previous)
205 205 d = current.previous.text.split(/\r?\n/).diff(current.text.split(/\r?\n/)).diffs.flatten
206 206 d.each_slice(3) do |s|
207 207 sign, line = s[0], s[1]
208 208 if sign == '+' && positions[line] && positions[line] != -1
209 209 if @lines[positions[line]][0].nil?
210 210 @lines[positions[line]][0] = current.version
211 211 @lines[positions[line]][1] = current.author
212 212 end
213 213 end
214 214 end
215 215 d.each_slice(3) do |s|
216 216 sign, line = s[0], s[1]
217 217 if sign == '-'
218 218 positions.insert(line, -1)
219 219 else
220 220 positions[line] = nil
221 221 end
222 222 end
223 223 positions.compact!
224 224 # Stop if every line is annotated
225 225 break unless @lines.detect { |line| line[0].nil? }
226 226 current = current.previous
227 227 end
228 228 @lines.each { |line|
229 229 line[0] ||= current.version
230 230 # if the last known version is > 1 (eg. history was cleared), we don't know the author
231 231 line[1] ||= current.author if current.version == 1
232 232 }
233 233 end
234 234 end
@@ -1,58 +1,59
1 1 require File.expand_path('../boot', __FILE__)
2 2
3 3 require 'rails/all'
4 4
5 5 if defined?(Bundler)
6 6 # If you precompile assets before deploying to production, use this line
7 7 Bundler.require(*Rails.groups(:assets => %w(development test)))
8 8 # If you want your assets lazily compiled in production, use this line
9 9 # Bundler.require(:default, :assets, Rails.env)
10 10 end
11 11
12 12 module RedmineApp
13 13 class Application < Rails::Application
14 14 # Settings in config/environments/* take precedence over those specified here.
15 15 # Application configuration should go into files in config/initializers
16 16 # -- all .rb files in that directory are automatically loaded.
17 17
18 18 # Custom directories with classes and modules you want to be autoloadable.
19 19 config.autoload_paths += %W(#{config.root}/lib)
20 20
21 21 # Only load the plugins named here, in the order given (default is alphabetical).
22 22 # :all can be used as a placeholder for all plugins not explicitly named.
23 23 # config.plugins = [ :exception_notification, :ssl_requirement, :all ]
24 24
25 25 # Activate observers that should always be running.
26 26 config.active_record.observers = :message_observer, :issue_observer, :journal_observer, :news_observer, :document_observer, :wiki_content_observer, :comment_observer
27 27
28 28 config.active_record.store_full_sti_class = true
29 config.active_record.default_timezone = :local
29 30
30 31 # Set Time.zone default to the specified zone and make Active Record auto-convert to this zone.
31 32 # Run "rake -D time" for a list of tasks for finding time zone names. Default is UTC.
32 33 # config.time_zone = 'Central Time (US & Canada)'
33 34
34 35 # The default locale is :en and all translations from config/locales/*.rb,yml are auto loaded.
35 36 # config.i18n.load_path += Dir[Rails.root.join('my', 'locales', '*.{rb,yml}').to_s]
36 37 # config.i18n.default_locale = :de
37 38
38 39 # Configure the default encoding used in templates for Ruby 1.9.
39 40 config.encoding = "utf-8"
40 41
41 42 # Configure sensitive parameters which will be filtered from the log file.
42 43 config.filter_parameters += [:password]
43 44
44 45 # Enable the asset pipeline
45 46 config.assets.enabled = false
46 47
47 48 # Version of your assets, change this if you want to expire all your assets
48 49 config.assets.version = '1.0'
49 50
50 51 config.action_mailer.perform_deliveries = false
51 52
52 53 config.session_store :cookie_store, :key => '_redmine_session'
53 54
54 55 if File.exists?(File.join(File.dirname(__FILE__), 'additional_environment.rb'))
55 56 instance_eval File.read(File.join(File.dirname(__FILE__), 'additional_environment.rb'))
56 57 end
57 58 end
58 59 end
@@ -1,265 +1,265
1 1 ---
2 2 issues_001:
3 created_on: <%= 3.days.ago.to_date.to_s(:db) %>
3 created_on: <%= 3.days.ago.to_s(:db) %>
4 4 project_id: 1
5 updated_on: <%= 1.day.ago.to_date.to_s(:db) %>
5 updated_on: <%= 1.day.ago.to_s(:db) %>
6 6 priority_id: 4
7 7 subject: Can't print recipes
8 8 id: 1
9 9 fixed_version_id:
10 10 category_id: 1
11 11 description: Unable to print recipes
12 12 tracker_id: 1
13 13 assigned_to_id:
14 14 author_id: 2
15 15 status_id: 1
16 16 start_date: <%= 1.day.ago.to_date.to_s(:db) %>
17 17 due_date: <%= 10.day.from_now.to_date.to_s(:db) %>
18 18 root_id: 1
19 19 lft: 1
20 20 rgt: 2
21 21 lock_version: 3
22 22 issues_002:
23 23 created_on: 2006-07-19 21:04:21 +02:00
24 24 project_id: 1
25 25 updated_on: 2006-07-19 21:09:50 +02:00
26 26 priority_id: 5
27 27 subject: Add ingredients categories
28 28 id: 2
29 29 fixed_version_id: 2
30 30 category_id:
31 31 description: Ingredients of the recipe should be classified by categories
32 32 tracker_id: 2
33 33 assigned_to_id: 3
34 34 author_id: 2
35 35 status_id: 2
36 36 start_date: <%= 2.day.ago.to_date.to_s(:db) %>
37 37 due_date:
38 38 root_id: 2
39 39 lft: 1
40 40 rgt: 2
41 41 lock_version: 3
42 42 done_ratio: 30
43 43 issues_003:
44 44 created_on: 2006-07-19 21:07:27 +02:00
45 45 project_id: 1
46 46 updated_on: 2006-07-19 21:07:27 +02:00
47 47 priority_id: 4
48 48 subject: Error 281 when updating a recipe
49 49 id: 3
50 50 fixed_version_id:
51 51 category_id:
52 52 description: Error 281 is encountered when saving a recipe
53 53 tracker_id: 1
54 54 assigned_to_id: 3
55 55 author_id: 2
56 56 status_id: 1
57 57 start_date: <%= 15.day.ago.to_date.to_s(:db) %>
58 58 due_date: <%= 5.day.ago.to_date.to_s(:db) %>
59 59 root_id: 3
60 60 lft: 1
61 61 rgt: 2
62 62 issues_004:
63 created_on: <%= 5.days.ago.to_date.to_s(:db) %>
63 created_on: <%= 5.days.ago.to_s(:db) %>
64 64 project_id: 2
65 updated_on: <%= 2.days.ago.to_date.to_s(:db) %>
65 updated_on: <%= 2.days.ago.to_s(:db) %>
66 66 priority_id: 4
67 67 subject: Issue on project 2
68 68 id: 4
69 69 fixed_version_id:
70 70 category_id:
71 71 description: Issue on project 2
72 72 tracker_id: 1
73 73 assigned_to_id: 2
74 74 author_id: 2
75 75 status_id: 1
76 76 root_id: 4
77 77 lft: 1
78 78 rgt: 2
79 79 issues_005:
80 created_on: <%= 5.days.ago.to_date.to_s(:db) %>
80 created_on: <%= 5.days.ago.to_s(:db) %>
81 81 project_id: 3
82 updated_on: <%= 2.days.ago.to_date.to_s(:db) %>
82 updated_on: <%= 2.days.ago.to_s(:db) %>
83 83 priority_id: 4
84 84 subject: Subproject issue
85 85 id: 5
86 86 fixed_version_id:
87 87 category_id:
88 88 description: This is an issue on a cookbook subproject
89 89 tracker_id: 1
90 90 assigned_to_id:
91 91 author_id: 2
92 92 status_id: 1
93 93 root_id: 5
94 94 lft: 1
95 95 rgt: 2
96 96 issues_006:
97 created_on: <%= 1.minute.ago.to_date.to_s(:db) %>
97 created_on: <%= 1.minute.ago.to_s(:db) %>
98 98 project_id: 5
99 updated_on: <%= 1.minute.ago.to_date.to_s(:db) %>
99 updated_on: <%= 1.minute.ago.to_s(:db) %>
100 100 priority_id: 4
101 101 subject: Issue of a private subproject
102 102 id: 6
103 103 fixed_version_id:
104 104 category_id:
105 105 description: This is an issue of a private subproject of cookbook
106 106 tracker_id: 1
107 107 assigned_to_id:
108 108 author_id: 2
109 109 status_id: 1
110 110 start_date: <%= Date.today.to_s(:db) %>
111 111 due_date: <%= 1.days.from_now.to_date.to_s(:db) %>
112 112 root_id: 6
113 113 lft: 1
114 114 rgt: 2
115 115 issues_007:
116 created_on: <%= 10.days.ago.to_date.to_s(:db) %>
116 created_on: <%= 10.days.ago.to_s(:db) %>
117 117 project_id: 1
118 updated_on: <%= 10.days.ago.to_date.to_s(:db) %>
118 updated_on: <%= 10.days.ago.to_s(:db) %>
119 119 priority_id: 5
120 120 subject: Issue due today
121 121 id: 7
122 122 fixed_version_id:
123 123 category_id:
124 124 description: This is an issue that is due today
125 125 tracker_id: 1
126 126 assigned_to_id:
127 127 author_id: 2
128 128 status_id: 1
129 129 start_date: <%= 10.days.ago.to_s(:db) %>
130 130 due_date: <%= Date.today.to_s(:db) %>
131 131 lock_version: 0
132 132 root_id: 7
133 133 lft: 1
134 134 rgt: 2
135 135 issues_008:
136 created_on: <%= 10.days.ago.to_date.to_s(:db) %>
136 created_on: <%= 10.days.ago.to_s(:db) %>
137 137 project_id: 1
138 updated_on: <%= 10.days.ago.to_date.to_s(:db) %>
138 updated_on: <%= 10.days.ago.to_s(:db) %>
139 139 priority_id: 5
140 140 subject: Closed issue
141 141 id: 8
142 142 fixed_version_id:
143 143 category_id:
144 144 description: This is a closed issue.
145 145 tracker_id: 1
146 146 assigned_to_id:
147 147 author_id: 2
148 148 status_id: 5
149 149 start_date:
150 150 due_date:
151 151 lock_version: 0
152 152 root_id: 8
153 153 lft: 1
154 154 rgt: 2
155 155 issues_009:
156 created_on: <%= 1.minute.ago.to_date.to_s(:db) %>
156 created_on: <%= 1.minute.ago.to_s(:db) %>
157 157 project_id: 5
158 updated_on: <%= 1.minute.ago.to_date.to_s(:db) %>
158 updated_on: <%= 1.minute.ago.to_s(:db) %>
159 159 priority_id: 5
160 160 subject: Blocked Issue
161 161 id: 9
162 162 fixed_version_id:
163 163 category_id:
164 164 description: This is an issue that is blocked by issue #10
165 165 tracker_id: 1
166 166 assigned_to_id:
167 167 author_id: 2
168 168 status_id: 1
169 169 start_date: <%= Date.today.to_s(:db) %>
170 170 due_date: <%= 1.days.from_now.to_date.to_s(:db) %>
171 171 root_id: 9
172 172 lft: 1
173 173 rgt: 2
174 174 issues_010:
175 created_on: <%= 1.minute.ago.to_date.to_s(:db) %>
175 created_on: <%= 1.minute.ago.to_s(:db) %>
176 176 project_id: 5
177 updated_on: <%= 1.minute.ago.to_date.to_s(:db) %>
177 updated_on: <%= 1.minute.ago.to_s(:db) %>
178 178 priority_id: 5
179 179 subject: Issue Doing the Blocking
180 180 id: 10
181 181 fixed_version_id:
182 182 category_id:
183 183 description: This is an issue that blocks issue #9
184 184 tracker_id: 1
185 185 assigned_to_id:
186 186 author_id: 2
187 187 status_id: 1
188 188 start_date: <%= Date.today.to_s(:db) %>
189 189 due_date: <%= 1.days.from_now.to_date.to_s(:db) %>
190 190 root_id: 10
191 191 lft: 1
192 192 rgt: 2
193 193 issues_011:
194 created_on: <%= 3.days.ago.to_date.to_s(:db) %>
194 created_on: <%= 3.days.ago.to_s(:db) %>
195 195 project_id: 1
196 updated_on: <%= 1.day.ago.to_date.to_s(:db) %>
196 updated_on: <%= 1.day.ago.to_s(:db) %>
197 197 priority_id: 5
198 198 subject: Closed issue on a closed version
199 199 id: 11
200 200 fixed_version_id: 1
201 201 category_id: 1
202 202 description:
203 203 tracker_id: 1
204 204 assigned_to_id:
205 205 author_id: 2
206 206 status_id: 5
207 207 start_date: <%= 1.day.ago.to_date.to_s(:db) %>
208 208 due_date:
209 209 root_id: 11
210 210 lft: 1
211 211 rgt: 2
212 212 issues_012:
213 created_on: <%= 3.days.ago.to_date.to_s(:db) %>
213 created_on: <%= 3.days.ago.to_s(:db) %>
214 214 project_id: 1
215 updated_on: <%= 1.day.ago.to_date.to_s(:db) %>
215 updated_on: <%= 1.day.ago.to_s(:db) %>
216 216 priority_id: 5
217 217 subject: Closed issue on a locked version
218 218 id: 12
219 219 fixed_version_id: 2
220 220 category_id: 1
221 221 description:
222 222 tracker_id: 1
223 223 assigned_to_id:
224 224 author_id: 3
225 225 status_id: 5
226 226 start_date: <%= 1.day.ago.to_date.to_s(:db) %>
227 227 due_date:
228 228 root_id: 12
229 229 lft: 1
230 230 rgt: 2
231 231 issues_013:
232 created_on: <%= 5.days.ago.to_date.to_s(:db) %>
232 created_on: <%= 5.days.ago.to_s(:db) %>
233 233 project_id: 3
234 updated_on: <%= 2.days.ago.to_date.to_s(:db) %>
234 updated_on: <%= 2.days.ago.to_s(:db) %>
235 235 priority_id: 4
236 236 subject: Subproject issue two
237 237 id: 13
238 238 fixed_version_id:
239 239 category_id:
240 240 description: This is a second issue on a cookbook subproject
241 241 tracker_id: 1
242 242 assigned_to_id:
243 243 author_id: 2
244 244 status_id: 1
245 245 root_id: 13
246 246 lft: 1
247 247 rgt: 2
248 248 issues_014:
249 249 id: 14
250 created_on: <%= 15.days.ago.to_date.to_s(:db) %>
250 created_on: <%= 15.days.ago.to_s(:db) %>
251 251 project_id: 3
252 updated_on: <%= 15.days.ago.to_date.to_s(:db) %>
252 updated_on: <%= 15.days.ago.to_s(:db) %>
253 253 priority_id: 5
254 254 subject: Private issue on public project
255 255 fixed_version_id:
256 256 category_id:
257 257 description: This is a private issue
258 258 tracker_id: 1
259 259 assigned_to_id:
260 260 author_id: 2
261 261 status_id: 1
262 262 is_private: true
263 263 root_id: 14
264 264 lft: 1
265 265 rgt: 2
@@ -1,1027 +1,1058
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 require File.expand_path('../../test_helper', __FILE__)
19 19
20 20 class UserTest < ActiveSupport::TestCase
21 21 fixtures :users, :members, :projects, :roles, :member_roles, :auth_sources,
22 22 :trackers, :issue_statuses,
23 23 :projects_trackers,
24 24 :watchers,
25 25 :issue_categories, :enumerations, :issues,
26 26 :journals, :journal_details,
27 27 :groups_users,
28 28 :enabled_modules,
29 29 :workflows
30 30
31 31 def setup
32 32 @admin = User.find(1)
33 33 @jsmith = User.find(2)
34 34 @dlopper = User.find(3)
35 35 end
36 36
37 37 test 'object_daddy creation' do
38 38 User.generate!(:firstname => 'Testing connection')
39 39 User.generate!(:firstname => 'Testing connection')
40 40 assert_equal 2, User.count(:all, :conditions => {:firstname => 'Testing connection'})
41 41 end
42 42
43 43 def test_truth
44 44 assert_kind_of User, @jsmith
45 45 end
46 46
47 47 def test_mail_should_be_stripped
48 48 u = User.new
49 49 u.mail = " foo@bar.com "
50 50 assert_equal "foo@bar.com", u.mail
51 51 end
52 52
53 53 def test_mail_validation
54 54 u = User.new
55 55 u.mail = ''
56 56 assert !u.valid?
57 57 assert_include I18n.translate('activerecord.errors.messages.blank'), u.errors[:mail]
58 58 end
59 59
60 60 def test_login_length_validation
61 61 user = User.new(:firstname => "new", :lastname => "user", :mail => "newuser@somenet.foo")
62 62 user.login = "x" * (User::LOGIN_LENGTH_LIMIT+1)
63 63 assert !user.valid?
64 64
65 65 user.login = "x" * (User::LOGIN_LENGTH_LIMIT)
66 66 assert user.valid?
67 67 assert user.save
68 68 end
69 69
70 70 def test_create
71 71 user = User.new(:firstname => "new", :lastname => "user", :mail => "newuser@somenet.foo")
72 72
73 73 user.login = "jsmith"
74 74 user.password, user.password_confirmation = "password", "password"
75 75 # login uniqueness
76 76 assert !user.save
77 77 assert_equal 1, user.errors.count
78 78
79 79 user.login = "newuser"
80 80 user.password, user.password_confirmation = "passwd", "password"
81 81 # password confirmation
82 82 assert !user.save
83 83 assert_equal 1, user.errors.count
84 84
85 85 user.password, user.password_confirmation = "password", "password"
86 86 assert user.save
87 87 end
88 88
89 89 context "User#before_create" do
90 90 should "set the mail_notification to the default Setting" do
91 91 @user1 = User.generate!
92 92 assert_equal 'only_my_events', @user1.mail_notification
93 93
94 94 with_settings :default_notification_option => 'all' do
95 95 @user2 = User.generate!
96 96 assert_equal 'all', @user2.mail_notification
97 97 end
98 98 end
99 99 end
100 100
101 101 context "User.login" do
102 102 should "be case-insensitive." do
103 103 u = User.new(:firstname => "new", :lastname => "user", :mail => "newuser@somenet.foo")
104 104 u.login = 'newuser'
105 105 u.password, u.password_confirmation = "password", "password"
106 106 assert u.save
107 107
108 108 u = User.new(:firstname => "Similar", :lastname => "User", :mail => "similaruser@somenet.foo")
109 109 u.login = 'NewUser'
110 110 u.password, u.password_confirmation = "password", "password"
111 111 assert !u.save
112 112 assert_include I18n.translate('activerecord.errors.messages.taken'), u.errors[:login]
113 113 end
114 114 end
115 115
116 116 def test_mail_uniqueness_should_not_be_case_sensitive
117 117 u = User.new(:firstname => "new", :lastname => "user", :mail => "newuser@somenet.foo")
118 118 u.login = 'newuser1'
119 119 u.password, u.password_confirmation = "password", "password"
120 120 assert u.save
121 121
122 122 u = User.new(:firstname => "new", :lastname => "user", :mail => "newUser@Somenet.foo")
123 123 u.login = 'newuser2'
124 124 u.password, u.password_confirmation = "password", "password"
125 125 assert !u.save
126 126 assert_include I18n.translate('activerecord.errors.messages.taken'), u.errors[:mail]
127 127 end
128 128
129 129 def test_update
130 130 assert_equal "admin", @admin.login
131 131 @admin.login = "john"
132 132 assert @admin.save, @admin.errors.full_messages.join("; ")
133 133 @admin.reload
134 134 assert_equal "john", @admin.login
135 135 end
136 136
137 137 def test_update_should_not_fail_for_legacy_user_with_different_case_logins
138 138 u1 = User.new(:firstname => "new", :lastname => "user", :mail => "newuser1@somenet.foo")
139 139 u1.login = 'newuser1'
140 140 assert u1.save
141 141
142 142 u2 = User.new(:firstname => "new", :lastname => "user", :mail => "newuser2@somenet.foo")
143 143 u2.login = 'newuser1'
144 144 assert u2.save(:validate => false)
145 145
146 146 user = User.find(u2.id)
147 147 user.firstname = "firstname"
148 148 assert user.save, "Save failed"
149 149 end
150 150
151 151 def test_destroy_should_delete_members_and_roles
152 152 members = Member.find_all_by_user_id(2)
153 153 ms = members.size
154 154 rs = members.collect(&:roles).flatten.size
155 155
156 156 assert_difference 'Member.count', - ms do
157 157 assert_difference 'MemberRole.count', - rs do
158 158 User.find(2).destroy
159 159 end
160 160 end
161 161
162 162 assert_nil User.find_by_id(2)
163 163 assert Member.find_all_by_user_id(2).empty?
164 164 end
165 165
166 166 def test_destroy_should_update_attachments
167 167 attachment = Attachment.create!(:container => Project.find(1),
168 168 :file => uploaded_test_file("testfile.txt", "text/plain"),
169 169 :author_id => 2)
170 170
171 171 User.find(2).destroy
172 172 assert_nil User.find_by_id(2)
173 173 assert_equal User.anonymous, attachment.reload.author
174 174 end
175 175
176 176 def test_destroy_should_update_comments
177 177 comment = Comment.create!(
178 178 :commented => News.create!(:project_id => 1, :author_id => 1, :title => 'foo', :description => 'foo'),
179 179 :author => User.find(2),
180 180 :comments => 'foo'
181 181 )
182 182
183 183 User.find(2).destroy
184 184 assert_nil User.find_by_id(2)
185 185 assert_equal User.anonymous, comment.reload.author
186 186 end
187 187
188 188 def test_destroy_should_update_issues
189 189 issue = Issue.create!(:project_id => 1, :author_id => 2, :tracker_id => 1, :subject => 'foo')
190 190
191 191 User.find(2).destroy
192 192 assert_nil User.find_by_id(2)
193 193 assert_equal User.anonymous, issue.reload.author
194 194 end
195 195
196 196 def test_destroy_should_unassign_issues
197 197 issue = Issue.create!(:project_id => 1, :author_id => 1, :tracker_id => 1, :subject => 'foo', :assigned_to_id => 2)
198 198
199 199 User.find(2).destroy
200 200 assert_nil User.find_by_id(2)
201 201 assert_nil issue.reload.assigned_to
202 202 end
203 203
204 204 def test_destroy_should_update_journals
205 205 issue = Issue.create!(:project_id => 1, :author_id => 2, :tracker_id => 1, :subject => 'foo')
206 206 issue.init_journal(User.find(2), "update")
207 207 issue.save!
208 208
209 209 User.find(2).destroy
210 210 assert_nil User.find_by_id(2)
211 211 assert_equal User.anonymous, issue.journals.first.reload.user
212 212 end
213 213
214 214 def test_destroy_should_update_journal_details_old_value
215 215 issue = Issue.create!(:project_id => 1, :author_id => 1, :tracker_id => 1, :subject => 'foo', :assigned_to_id => 2)
216 216 issue.init_journal(User.find(1), "update")
217 217 issue.assigned_to_id = nil
218 218 assert_difference 'JournalDetail.count' do
219 219 issue.save!
220 220 end
221 221 journal_detail = JournalDetail.first(:order => 'id DESC')
222 222 assert_equal '2', journal_detail.old_value
223 223
224 224 User.find(2).destroy
225 225 assert_nil User.find_by_id(2)
226 226 assert_equal User.anonymous.id.to_s, journal_detail.reload.old_value
227 227 end
228 228
229 229 def test_destroy_should_update_journal_details_value
230 230 issue = Issue.create!(:project_id => 1, :author_id => 1, :tracker_id => 1, :subject => 'foo')
231 231 issue.init_journal(User.find(1), "update")
232 232 issue.assigned_to_id = 2
233 233 assert_difference 'JournalDetail.count' do
234 234 issue.save!
235 235 end
236 236 journal_detail = JournalDetail.first(:order => 'id DESC')
237 237 assert_equal '2', journal_detail.value
238 238
239 239 User.find(2).destroy
240 240 assert_nil User.find_by_id(2)
241 241 assert_equal User.anonymous.id.to_s, journal_detail.reload.value
242 242 end
243 243
244 244 def test_destroy_should_update_messages
245 245 board = Board.create!(:project_id => 1, :name => 'Board', :description => 'Board')
246 246 message = Message.create!(:board_id => board.id, :author_id => 2, :subject => 'foo', :content => 'foo')
247 247
248 248 User.find(2).destroy
249 249 assert_nil User.find_by_id(2)
250 250 assert_equal User.anonymous, message.reload.author
251 251 end
252 252
253 253 def test_destroy_should_update_news
254 254 news = News.create!(:project_id => 1, :author_id => 2, :title => 'foo', :description => 'foo')
255 255
256 256 User.find(2).destroy
257 257 assert_nil User.find_by_id(2)
258 258 assert_equal User.anonymous, news.reload.author
259 259 end
260 260
261 261 def test_destroy_should_delete_private_queries
262 262 query = Query.new(:name => 'foo', :is_public => false)
263 263 query.project_id = 1
264 264 query.user_id = 2
265 265 query.save!
266 266
267 267 User.find(2).destroy
268 268 assert_nil User.find_by_id(2)
269 269 assert_nil Query.find_by_id(query.id)
270 270 end
271 271
272 272 def test_destroy_should_update_public_queries
273 273 query = Query.new(:name => 'foo', :is_public => true)
274 274 query.project_id = 1
275 275 query.user_id = 2
276 276 query.save!
277 277
278 278 User.find(2).destroy
279 279 assert_nil User.find_by_id(2)
280 280 assert_equal User.anonymous, query.reload.user
281 281 end
282 282
283 283 def test_destroy_should_update_time_entries
284 284 entry = TimeEntry.new(:hours => '2', :spent_on => Date.today, :activity => TimeEntryActivity.create!(:name => 'foo'))
285 285 entry.project_id = 1
286 286 entry.user_id = 2
287 287 entry.save!
288 288
289 289 User.find(2).destroy
290 290 assert_nil User.find_by_id(2)
291 291 assert_equal User.anonymous, entry.reload.user
292 292 end
293 293
294 294 def test_destroy_should_delete_tokens
295 295 token = Token.create!(:user_id => 2, :value => 'foo')
296 296
297 297 User.find(2).destroy
298 298 assert_nil User.find_by_id(2)
299 299 assert_nil Token.find_by_id(token.id)
300 300 end
301 301
302 302 def test_destroy_should_delete_watchers
303 303 issue = Issue.create!(:project_id => 1, :author_id => 1, :tracker_id => 1, :subject => 'foo')
304 304 watcher = Watcher.create!(:user_id => 2, :watchable => issue)
305 305
306 306 User.find(2).destroy
307 307 assert_nil User.find_by_id(2)
308 308 assert_nil Watcher.find_by_id(watcher.id)
309 309 end
310 310
311 311 def test_destroy_should_update_wiki_contents
312 312 wiki_content = WikiContent.create!(
313 313 :text => 'foo',
314 314 :author_id => 2,
315 315 :page => WikiPage.create!(:title => 'Foo', :wiki => Wiki.create!(:project_id => 1, :start_page => 'Start'))
316 316 )
317 317 wiki_content.text = 'bar'
318 318 assert_difference 'WikiContent::Version.count' do
319 319 wiki_content.save!
320 320 end
321 321
322 322 User.find(2).destroy
323 323 assert_nil User.find_by_id(2)
324 324 assert_equal User.anonymous, wiki_content.reload.author
325 325 wiki_content.versions.each do |version|
326 326 assert_equal User.anonymous, version.reload.author
327 327 end
328 328 end
329 329
330 330 def test_destroy_should_nullify_issue_categories
331 331 category = IssueCategory.create!(:project_id => 1, :assigned_to_id => 2, :name => 'foo')
332 332
333 333 User.find(2).destroy
334 334 assert_nil User.find_by_id(2)
335 335 assert_nil category.reload.assigned_to_id
336 336 end
337 337
338 338 def test_destroy_should_nullify_changesets
339 339 changeset = Changeset.create!(
340 340 :repository => Repository::Subversion.create!(
341 341 :project_id => 1,
342 342 :url => 'file:///tmp',
343 343 :identifier => 'tmp'
344 344 ),
345 345 :revision => '12',
346 346 :committed_on => Time.now,
347 347 :committer => 'jsmith'
348 348 )
349 349 assert_equal 2, changeset.user_id
350 350
351 351 User.find(2).destroy
352 352 assert_nil User.find_by_id(2)
353 353 assert_nil changeset.reload.user_id
354 354 end
355 355
356 356 def test_anonymous_user_should_not_be_destroyable
357 357 assert_no_difference 'User.count' do
358 358 assert_equal false, User.anonymous.destroy
359 359 end
360 360 end
361 361
362 362 def test_validate_login_presence
363 363 @admin.login = ""
364 364 assert !@admin.save
365 365 assert_equal 1, @admin.errors.count
366 366 end
367 367
368 368 def test_validate_mail_notification_inclusion
369 369 u = User.new
370 370 u.mail_notification = 'foo'
371 371 u.save
372 372 assert_not_nil u.errors[:mail_notification]
373 373 end
374 374
375 375 context "User#try_to_login" do
376 376 should "fall-back to case-insensitive if user login is not found as-typed." do
377 377 user = User.try_to_login("AdMin", "admin")
378 378 assert_kind_of User, user
379 379 assert_equal "admin", user.login
380 380 end
381 381
382 382 should "select the exact matching user first" do
383 383 case_sensitive_user = User.generate! do |user|
384 384 user.password = "admin"
385 385 end
386 386 # bypass validations to make it appear like existing data
387 387 case_sensitive_user.update_attribute(:login, 'ADMIN')
388 388
389 389 user = User.try_to_login("ADMIN", "admin")
390 390 assert_kind_of User, user
391 391 assert_equal "ADMIN", user.login
392 392
393 393 end
394 394 end
395 395
396 396 def test_password
397 397 user = User.try_to_login("admin", "admin")
398 398 assert_kind_of User, user
399 399 assert_equal "admin", user.login
400 400 user.password = "hello"
401 401 assert user.save
402 402
403 403 user = User.try_to_login("admin", "hello")
404 404 assert_kind_of User, user
405 405 assert_equal "admin", user.login
406 406 end
407 407
408 408 def test_validate_password_length
409 409 with_settings :password_min_length => '100' do
410 410 user = User.new(:firstname => "new100", :lastname => "user100", :mail => "newuser100@somenet.foo")
411 411 user.login = "newuser100"
412 412 user.password, user.password_confirmation = "password100", "password100"
413 413 assert !user.save
414 414 assert_equal 1, user.errors.count
415 415 end
416 416 end
417 417
418 418 def test_name_format
419 419 assert_equal 'Smith, John', @jsmith.name(:lastname_coma_firstname)
420 420 with_settings :user_format => :firstname_lastname do
421 421 assert_equal 'John Smith', @jsmith.reload.name
422 422 end
423 423 with_settings :user_format => :username do
424 424 assert_equal 'jsmith', @jsmith.reload.name
425 425 end
426 426 end
427
427
428 def test_today_should_return_the_day_according_to_user_time_zone
429 preference = User.find(1).pref
430 date = Date.new(2012, 05, 15)
431 time = Time.gm(2012, 05, 15, 23, 30).utc # 2012-05-15 23:30 UTC
432 Date.stubs(:today).returns(date)
433 Time.stubs(:now).returns(time)
434
435 preference.update_attribute :time_zone, 'Baku' # UTC+4
436 assert_equal '2012-05-16', User.find(1).today.to_s
437
438 preference.update_attribute :time_zone, 'La Paz' # UTC-4
439 assert_equal '2012-05-15', User.find(1).today.to_s
440
441 preference.update_attribute :time_zone, ''
442 assert_equal '2012-05-15', User.find(1).today.to_s
443 end
444
445 def test_time_to_date_should_return_the_date_according_to_user_time_zone
446 preference = User.find(1).pref
447 time = Time.gm(2012, 05, 15, 23, 30).utc # 2012-05-15 23:30 UTC
448
449 preference.update_attribute :time_zone, 'Baku' # UTC+4
450 assert_equal '2012-05-16', User.find(1).time_to_date(time).to_s
451
452 preference.update_attribute :time_zone, 'La Paz' # UTC-4
453 assert_equal '2012-05-15', User.find(1).time_to_date(time).to_s
454
455 preference.update_attribute :time_zone, ''
456 assert_equal '2012-05-15', User.find(1).time_to_date(time).to_s
457 end
458
428 459 def test_fields_for_order_statement_should_return_fields_according_user_format_setting
429 460 with_settings :user_format => 'lastname_coma_firstname' do
430 461 assert_equal ['users.lastname', 'users.firstname', 'users.id'], User.fields_for_order_statement
431 462 end
432 463 end
433 464
434 465 def test_fields_for_order_statement_width_table_name_should_prepend_table_name
435 466 with_settings :user_format => 'lastname_firstname' do
436 467 assert_equal ['authors.lastname', 'authors.firstname', 'authors.id'], User.fields_for_order_statement('authors')
437 468 end
438 469 end
439 470
440 471 def test_fields_for_order_statement_with_blank_format_should_return_default
441 472 with_settings :user_format => '' do
442 473 assert_equal ['users.firstname', 'users.lastname', 'users.id'], User.fields_for_order_statement
443 474 end
444 475 end
445 476
446 477 def test_fields_for_order_statement_with_invalid_format_should_return_default
447 478 with_settings :user_format => 'foo' do
448 479 assert_equal ['users.firstname', 'users.lastname', 'users.id'], User.fields_for_order_statement
449 480 end
450 481 end
451 482
452 483 def test_lock
453 484 user = User.try_to_login("jsmith", "jsmith")
454 485 assert_equal @jsmith, user
455 486
456 487 @jsmith.status = User::STATUS_LOCKED
457 488 assert @jsmith.save
458 489
459 490 user = User.try_to_login("jsmith", "jsmith")
460 491 assert_equal nil, user
461 492 end
462 493
463 494 context ".try_to_login" do
464 495 context "with good credentials" do
465 496 should "return the user" do
466 497 user = User.try_to_login("admin", "admin")
467 498 assert_kind_of User, user
468 499 assert_equal "admin", user.login
469 500 end
470 501 end
471 502
472 503 context "with wrong credentials" do
473 504 should "return nil" do
474 505 assert_nil User.try_to_login("admin", "foo")
475 506 end
476 507 end
477 508 end
478 509
479 510 if ldap_configured?
480 511 context "#try_to_login using LDAP" do
481 512 context "with failed connection to the LDAP server" do
482 513 should "return nil" do
483 514 @auth_source = AuthSourceLdap.find(1)
484 515 AuthSource.any_instance.stubs(:initialize_ldap_con).raises(Net::LDAP::LdapError, 'Cannot connect')
485 516
486 517 assert_equal nil, User.try_to_login('edavis', 'wrong')
487 518 end
488 519 end
489 520
490 521 context "with an unsuccessful authentication" do
491 522 should "return nil" do
492 523 assert_equal nil, User.try_to_login('edavis', 'wrong')
493 524 end
494 525 end
495 526
496 527 context "binding with user's account" do
497 528 setup do
498 529 @auth_source = AuthSourceLdap.find(1)
499 530 @auth_source.account = "uid=$login,ou=Person,dc=redmine,dc=org"
500 531 @auth_source.account_password = ''
501 532 @auth_source.save!
502 533
503 534 @ldap_user = User.new(:mail => 'example1@redmine.org', :firstname => 'LDAP', :lastname => 'user', :auth_source_id => 1)
504 535 @ldap_user.login = 'example1'
505 536 @ldap_user.save!
506 537 end
507 538
508 539 context "with a successful authentication" do
509 540 should "return the user" do
510 541 assert_equal @ldap_user, User.try_to_login('example1', '123456')
511 542 end
512 543 end
513 544
514 545 context "with an unsuccessful authentication" do
515 546 should "return nil" do
516 547 assert_nil User.try_to_login('example1', '11111')
517 548 end
518 549 end
519 550 end
520 551
521 552 context "on the fly registration" do
522 553 setup do
523 554 @auth_source = AuthSourceLdap.find(1)
524 555 @auth_source.update_attribute :onthefly_register, true
525 556 end
526 557
527 558 context "with a successful authentication" do
528 559 should "create a new user account if it doesn't exist" do
529 560 assert_difference('User.count') do
530 561 user = User.try_to_login('edavis', '123456')
531 562 assert !user.admin?
532 563 end
533 564 end
534 565
535 566 should "retrieve existing user" do
536 567 user = User.try_to_login('edavis', '123456')
537 568 user.admin = true
538 569 user.save!
539 570
540 571 assert_no_difference('User.count') do
541 572 user = User.try_to_login('edavis', '123456')
542 573 assert user.admin?
543 574 end
544 575 end
545 576 end
546 577
547 578 context "binding with user's account" do
548 579 setup do
549 580 @auth_source = AuthSourceLdap.find(1)
550 581 @auth_source.account = "uid=$login,ou=Person,dc=redmine,dc=org"
551 582 @auth_source.account_password = ''
552 583 @auth_source.save!
553 584 end
554 585
555 586 context "with a successful authentication" do
556 587 should "create a new user account if it doesn't exist" do
557 588 assert_difference('User.count') do
558 589 user = User.try_to_login('example1', '123456')
559 590 assert_kind_of User, user
560 591 end
561 592 end
562 593 end
563 594
564 595 context "with an unsuccessful authentication" do
565 596 should "return nil" do
566 597 assert_nil User.try_to_login('example1', '11111')
567 598 end
568 599 end
569 600 end
570 601 end
571 602 end
572 603
573 604 else
574 605 puts "Skipping LDAP tests."
575 606 end
576 607
577 608 def test_create_anonymous
578 609 AnonymousUser.delete_all
579 610 anon = User.anonymous
580 611 assert !anon.new_record?
581 612 assert_kind_of AnonymousUser, anon
582 613 end
583 614
584 615 def test_ensure_single_anonymous_user
585 616 AnonymousUser.delete_all
586 617 anon1 = User.anonymous
587 618 assert !anon1.new_record?
588 619 assert_kind_of AnonymousUser, anon1
589 620 anon2 = AnonymousUser.create(
590 621 :lastname => 'Anonymous', :firstname => '',
591 622 :mail => '', :login => '', :status => 0)
592 623 assert_equal 1, anon2.errors.count
593 624 end
594 625
595 626 def test_rss_key
596 627 assert_nil @jsmith.rss_token
597 628 key = @jsmith.rss_key
598 629 assert_equal 40, key.length
599 630
600 631 @jsmith.reload
601 632 assert_equal key, @jsmith.rss_key
602 633 end
603 634
604 635 def test_rss_key_should_not_be_generated_twice
605 636 assert_difference 'Token.count', 1 do
606 637 key1 = @jsmith.rss_key
607 638 key2 = @jsmith.rss_key
608 639 assert_equal key1, key2
609 640 end
610 641 end
611 642
612 643 def test_api_key_should_not_be_generated_twice
613 644 assert_difference 'Token.count', 1 do
614 645 key1 = @jsmith.api_key
615 646 key2 = @jsmith.api_key
616 647 assert_equal key1, key2
617 648 end
618 649 end
619 650
620 651 context "User#api_key" do
621 652 should "generate a new one if the user doesn't have one" do
622 653 user = User.generate!(:api_token => nil)
623 654 assert_nil user.api_token
624 655
625 656 key = user.api_key
626 657 assert_equal 40, key.length
627 658 user.reload
628 659 assert_equal key, user.api_key
629 660 end
630 661
631 662 should "return the existing api token value" do
632 663 user = User.generate!
633 664 token = Token.create!(:action => 'api')
634 665 user.api_token = token
635 666 assert user.save
636 667
637 668 assert_equal token.value, user.api_key
638 669 end
639 670 end
640 671
641 672 context "User#find_by_api_key" do
642 673 should "return nil if no matching key is found" do
643 674 assert_nil User.find_by_api_key('zzzzzzzzz')
644 675 end
645 676
646 677 should "return nil if the key is found for an inactive user" do
647 678 user = User.generate!
648 679 user.status = User::STATUS_LOCKED
649 680 token = Token.create!(:action => 'api')
650 681 user.api_token = token
651 682 user.save
652 683
653 684 assert_nil User.find_by_api_key(token.value)
654 685 end
655 686
656 687 should "return the user if the key is found for an active user" do
657 688 user = User.generate!
658 689 token = Token.create!(:action => 'api')
659 690 user.api_token = token
660 691 user.save
661 692
662 693 assert_equal user, User.find_by_api_key(token.value)
663 694 end
664 695 end
665 696
666 697 def test_default_admin_account_changed_should_return_false_if_account_was_not_changed
667 698 user = User.find_by_login("admin")
668 699 user.password = "admin"
669 700 user.save!
670 701
671 702 assert_equal false, User.default_admin_account_changed?
672 703 end
673 704
674 705 def test_default_admin_account_changed_should_return_true_if_password_was_changed
675 706 user = User.find_by_login("admin")
676 707 user.password = "newpassword"
677 708 user.save!
678 709
679 710 assert_equal true, User.default_admin_account_changed?
680 711 end
681 712
682 713 def test_default_admin_account_changed_should_return_true_if_account_is_disabled
683 714 user = User.find_by_login("admin")
684 715 user.password = "admin"
685 716 user.status = User::STATUS_LOCKED
686 717 user.save!
687 718
688 719 assert_equal true, User.default_admin_account_changed?
689 720 end
690 721
691 722 def test_default_admin_account_changed_should_return_true_if_account_does_not_exist
692 723 user = User.find_by_login("admin")
693 724 user.destroy
694 725
695 726 assert_equal true, User.default_admin_account_changed?
696 727 end
697 728
698 729 def test_roles_for_project
699 730 # user with a role
700 731 roles = @jsmith.roles_for_project(Project.find(1))
701 732 assert_kind_of Role, roles.first
702 733 assert_equal "Manager", roles.first.name
703 734
704 735 # user with no role
705 736 assert_nil @dlopper.roles_for_project(Project.find(2)).detect {|role| role.member?}
706 737 end
707 738
708 739 def test_projects_by_role_for_user_with_role
709 740 user = User.find(2)
710 741 assert_kind_of Hash, user.projects_by_role
711 742 assert_equal 2, user.projects_by_role.size
712 743 assert_equal [1,5], user.projects_by_role[Role.find(1)].collect(&:id).sort
713 744 assert_equal [2], user.projects_by_role[Role.find(2)].collect(&:id).sort
714 745 end
715 746
716 747 def test_projects_by_role_for_user_with_no_role
717 748 user = User.generate!
718 749 assert_equal({}, user.projects_by_role)
719 750 end
720 751
721 752 def test_projects_by_role_for_anonymous
722 753 assert_equal({}, User.anonymous.projects_by_role)
723 754 end
724 755
725 756 def test_valid_notification_options
726 757 # without memberships
727 758 assert_equal 5, User.find(7).valid_notification_options.size
728 759 # with memberships
729 760 assert_equal 6, User.find(2).valid_notification_options.size
730 761 end
731 762
732 763 def test_valid_notification_options_class_method
733 764 assert_equal 5, User.valid_notification_options.size
734 765 assert_equal 5, User.valid_notification_options(User.find(7)).size
735 766 assert_equal 6, User.valid_notification_options(User.find(2)).size
736 767 end
737 768
738 769 def test_mail_notification_all
739 770 @jsmith.mail_notification = 'all'
740 771 @jsmith.notified_project_ids = []
741 772 @jsmith.save
742 773 @jsmith.reload
743 774 assert @jsmith.projects.first.recipients.include?(@jsmith.mail)
744 775 end
745 776
746 777 def test_mail_notification_selected
747 778 @jsmith.mail_notification = 'selected'
748 779 @jsmith.notified_project_ids = [1]
749 780 @jsmith.save
750 781 @jsmith.reload
751 782 assert Project.find(1).recipients.include?(@jsmith.mail)
752 783 end
753 784
754 785 def test_mail_notification_only_my_events
755 786 @jsmith.mail_notification = 'only_my_events'
756 787 @jsmith.notified_project_ids = []
757 788 @jsmith.save
758 789 @jsmith.reload
759 790 assert !@jsmith.projects.first.recipients.include?(@jsmith.mail)
760 791 end
761 792
762 793 def test_comments_sorting_preference
763 794 assert !@jsmith.wants_comments_in_reverse_order?
764 795 @jsmith.pref.comments_sorting = 'asc'
765 796 assert !@jsmith.wants_comments_in_reverse_order?
766 797 @jsmith.pref.comments_sorting = 'desc'
767 798 assert @jsmith.wants_comments_in_reverse_order?
768 799 end
769 800
770 801 def test_find_by_mail_should_be_case_insensitive
771 802 u = User.find_by_mail('JSmith@somenet.foo')
772 803 assert_not_nil u
773 804 assert_equal 'jsmith@somenet.foo', u.mail
774 805 end
775 806
776 807 def test_random_password
777 808 u = User.new
778 809 u.random_password
779 810 assert !u.password.blank?
780 811 assert !u.password_confirmation.blank?
781 812 end
782 813
783 814 context "#change_password_allowed?" do
784 815 should "be allowed if no auth source is set" do
785 816 user = User.generate!
786 817 assert user.change_password_allowed?
787 818 end
788 819
789 820 should "delegate to the auth source" do
790 821 user = User.generate!
791 822
792 823 allowed_auth_source = AuthSource.generate!
793 824 def allowed_auth_source.allow_password_changes?; true; end
794 825
795 826 denied_auth_source = AuthSource.generate!
796 827 def denied_auth_source.allow_password_changes?; false; end
797 828
798 829 assert user.change_password_allowed?
799 830
800 831 user.auth_source = allowed_auth_source
801 832 assert user.change_password_allowed?, "User not allowed to change password, though auth source does"
802 833
803 834 user.auth_source = denied_auth_source
804 835 assert !user.change_password_allowed?, "User allowed to change password, though auth source does not"
805 836 end
806 837 end
807 838
808 839 def test_own_account_deletable_should_be_true_with_unsubscrive_enabled
809 840 with_settings :unsubscribe => '1' do
810 841 assert_equal true, User.find(2).own_account_deletable?
811 842 end
812 843 end
813 844
814 845 def test_own_account_deletable_should_be_false_with_unsubscrive_disabled
815 846 with_settings :unsubscribe => '0' do
816 847 assert_equal false, User.find(2).own_account_deletable?
817 848 end
818 849 end
819 850
820 851 def test_own_account_deletable_should_be_false_for_a_single_admin
821 852 User.delete_all(["admin = ? AND id <> ?", true, 1])
822 853
823 854 with_settings :unsubscribe => '1' do
824 855 assert_equal false, User.find(1).own_account_deletable?
825 856 end
826 857 end
827 858
828 859 def test_own_account_deletable_should_be_true_for_an_admin_if_other_admin_exists
829 860 User.generate! do |user|
830 861 user.admin = true
831 862 end
832 863
833 864 with_settings :unsubscribe => '1' do
834 865 assert_equal true, User.find(1).own_account_deletable?
835 866 end
836 867 end
837 868
838 869 context "#allowed_to?" do
839 870 context "with a unique project" do
840 871 should "return false if project is archived" do
841 872 project = Project.find(1)
842 873 Project.any_instance.stubs(:status).returns(Project::STATUS_ARCHIVED)
843 874 assert ! @admin.allowed_to?(:view_issues, Project.find(1))
844 875 end
845 876
846 877 should "return false if related module is disabled" do
847 878 project = Project.find(1)
848 879 project.enabled_module_names = ["issue_tracking"]
849 880 assert @admin.allowed_to?(:add_issues, project)
850 881 assert ! @admin.allowed_to?(:view_wiki_pages, project)
851 882 end
852 883
853 884 should "authorize nearly everything for admin users" do
854 885 project = Project.find(1)
855 886 assert ! @admin.member_of?(project)
856 887 %w(edit_issues delete_issues manage_news manage_documents manage_wiki).each do |p|
857 888 assert @admin.allowed_to?(p.to_sym, project)
858 889 end
859 890 end
860 891
861 892 should "authorize normal users depending on their roles" do
862 893 project = Project.find(1)
863 894 assert @jsmith.allowed_to?(:delete_messages, project) #Manager
864 895 assert ! @dlopper.allowed_to?(:delete_messages, project) #Developper
865 896 end
866 897 end
867 898
868 899 context "with multiple projects" do
869 900 should "return false if array is empty" do
870 901 assert ! @admin.allowed_to?(:view_project, [])
871 902 end
872 903
873 904 should "return true only if user has permission on all these projects" do
874 905 assert @admin.allowed_to?(:view_project, Project.all)
875 906 assert ! @dlopper.allowed_to?(:view_project, Project.all) #cannot see Project(2)
876 907 assert @jsmith.allowed_to?(:edit_issues, @jsmith.projects) #Manager or Developer everywhere
877 908 assert ! @jsmith.allowed_to?(:delete_issue_watchers, @jsmith.projects) #Dev cannot delete_issue_watchers
878 909 end
879 910
880 911 should "behave correctly with arrays of 1 project" do
881 912 assert ! User.anonymous.allowed_to?(:delete_issues, [Project.first])
882 913 end
883 914 end
884 915
885 916 context "with options[:global]" do
886 917 should "authorize if user has at least one role that has this permission" do
887 918 @dlopper2 = User.find(5) #only Developper on a project, not Manager anywhere
888 919 @anonymous = User.find(6)
889 920 assert @jsmith.allowed_to?(:delete_issue_watchers, nil, :global => true)
890 921 assert ! @dlopper2.allowed_to?(:delete_issue_watchers, nil, :global => true)
891 922 assert @dlopper2.allowed_to?(:add_issues, nil, :global => true)
892 923 assert ! @anonymous.allowed_to?(:add_issues, nil, :global => true)
893 924 assert @anonymous.allowed_to?(:view_issues, nil, :global => true)
894 925 end
895 926 end
896 927 end
897 928
898 929 context "User#notify_about?" do
899 930 context "Issues" do
900 931 setup do
901 932 @project = Project.find(1)
902 933 @author = User.generate!
903 934 @assignee = User.generate!
904 935 @issue = Issue.generate_for_project!(@project, :assigned_to => @assignee, :author => @author)
905 936 end
906 937
907 938 should "be true for a user with :all" do
908 939 @author.update_attribute(:mail_notification, 'all')
909 940 assert @author.notify_about?(@issue)
910 941 end
911 942
912 943 should "be false for a user with :none" do
913 944 @author.update_attribute(:mail_notification, 'none')
914 945 assert ! @author.notify_about?(@issue)
915 946 end
916 947
917 948 should "be false for a user with :only_my_events and isn't an author, creator, or assignee" do
918 949 @user = User.generate!(:mail_notification => 'only_my_events')
919 950 Member.create!(:user => @user, :project => @project, :role_ids => [1])
920 951 assert ! @user.notify_about?(@issue)
921 952 end
922 953
923 954 should "be true for a user with :only_my_events and is the author" do
924 955 @author.update_attribute(:mail_notification, 'only_my_events')
925 956 assert @author.notify_about?(@issue)
926 957 end
927 958
928 959 should "be true for a user with :only_my_events and is the assignee" do
929 960 @assignee.update_attribute(:mail_notification, 'only_my_events')
930 961 assert @assignee.notify_about?(@issue)
931 962 end
932 963
933 964 should "be true for a user with :only_assigned and is the assignee" do
934 965 @assignee.update_attribute(:mail_notification, 'only_assigned')
935 966 assert @assignee.notify_about?(@issue)
936 967 end
937 968
938 969 should "be false for a user with :only_assigned and is not the assignee" do
939 970 @author.update_attribute(:mail_notification, 'only_assigned')
940 971 assert ! @author.notify_about?(@issue)
941 972 end
942 973
943 974 should "be true for a user with :only_owner and is the author" do
944 975 @author.update_attribute(:mail_notification, 'only_owner')
945 976 assert @author.notify_about?(@issue)
946 977 end
947 978
948 979 should "be false for a user with :only_owner and is not the author" do
949 980 @assignee.update_attribute(:mail_notification, 'only_owner')
950 981 assert ! @assignee.notify_about?(@issue)
951 982 end
952 983
953 984 should "be true for a user with :selected and is the author" do
954 985 @author.update_attribute(:mail_notification, 'selected')
955 986 assert @author.notify_about?(@issue)
956 987 end
957 988
958 989 should "be true for a user with :selected and is the assignee" do
959 990 @assignee.update_attribute(:mail_notification, 'selected')
960 991 assert @assignee.notify_about?(@issue)
961 992 end
962 993
963 994 should "be false for a user with :selected and is not the author or assignee" do
964 995 @user = User.generate!(:mail_notification => 'selected')
965 996 Member.create!(:user => @user, :project => @project, :role_ids => [1])
966 997 assert ! @user.notify_about?(@issue)
967 998 end
968 999 end
969 1000
970 1001 context "other events" do
971 1002 should 'be added and tested'
972 1003 end
973 1004 end
974 1005
975 1006 def test_salt_unsalted_passwords
976 1007 # Restore a user with an unsalted password
977 1008 user = User.find(1)
978 1009 user.salt = nil
979 1010 user.hashed_password = User.hash_password("unsalted")
980 1011 user.save!
981 1012
982 1013 User.salt_unsalted_passwords!
983 1014
984 1015 user.reload
985 1016 # Salt added
986 1017 assert !user.salt.blank?
987 1018 # Password still valid
988 1019 assert user.check_password?("unsalted")
989 1020 assert_equal user, User.try_to_login(user.login, "unsalted")
990 1021 end
991 1022
992 1023 if Object.const_defined?(:OpenID)
993 1024
994 1025 def test_setting_identity_url
995 1026 normalized_open_id_url = 'http://example.com/'
996 1027 u = User.new( :identity_url => 'http://example.com/' )
997 1028 assert_equal normalized_open_id_url, u.identity_url
998 1029 end
999 1030
1000 1031 def test_setting_identity_url_without_trailing_slash
1001 1032 normalized_open_id_url = 'http://example.com/'
1002 1033 u = User.new( :identity_url => 'http://example.com' )
1003 1034 assert_equal normalized_open_id_url, u.identity_url
1004 1035 end
1005 1036
1006 1037 def test_setting_identity_url_without_protocol
1007 1038 normalized_open_id_url = 'http://example.com/'
1008 1039 u = User.new( :identity_url => 'example.com' )
1009 1040 assert_equal normalized_open_id_url, u.identity_url
1010 1041 end
1011 1042
1012 1043 def test_setting_blank_identity_url
1013 1044 u = User.new( :identity_url => 'example.com' )
1014 1045 u.identity_url = ''
1015 1046 assert u.identity_url.blank?
1016 1047 end
1017 1048
1018 1049 def test_setting_invalid_identity_url
1019 1050 u = User.new( :identity_url => 'this is not an openid url' )
1020 1051 assert u.identity_url.blank?
1021 1052 end
1022 1053
1023 1054 else
1024 1055 puts "Skipping openid tests."
1025 1056 end
1026 1057
1027 1058 end
General Comments 0
You need to be logged in to leave comments. Login now