##// END OF EJS Templates
Merged nested projects branch. Removes limit on subproject nesting (#594)....
Jean-Philippe Lang -
r2302:c9906480d3f2
parent child
Show More
@@ -0,0 +1,11
1 class AddProjectsLftAndRgt < ActiveRecord::Migration
2 def self.up
3 add_column :projects, :lft, :integer
4 add_column :projects, :rgt, :integer
5 end
6
7 def self.down
8 remove_column :projects, :lft
9 remove_column :projects, :rgt
10 end
11 end
@@ -0,0 +1,8
1 class BuildProjectsTree < ActiveRecord::Migration
2 def self.up
3 Project.rebuild!
4 end
5
6 def self.down
7 end
8 end
@@ -0,0 +1,20
1 Copyright (c) 2007 [name of plugin creator]
2
3 Permission is hereby granted, free of charge, to any person obtaining
4 a copy of this software and associated documentation files (the
5 "Software"), to deal in the Software without restriction, including
6 without limitation the rights to use, copy, modify, merge, publish,
7 distribute, sublicense, and/or sell copies of the Software, and to
8 permit persons to whom the Software is furnished to do so, subject to
9 the following conditions:
10
11 The above copyright notice and this permission notice shall be
12 included in all copies or substantial portions of the Software.
13
14 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15 EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16 MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17 NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18 LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19 OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20 WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,64
1 = AwesomeNestedSet
2
3 Awesome Nested Set is an implementation of the nested set pattern for ActiveRecord models. It is replacement for acts_as_nested_set and BetterNestedSet, but awesomer.
4
5 == What makes this so awesome?
6
7 This is a new implementation of nested set based off of BetterNestedSet that fixes some bugs, removes tons of duplication, adds a few useful methods, and adds STI support.
8
9 == Installation
10
11 If you are on Rails 2.1 or later:
12
13 script/plugin install git://github.com/collectiveidea/awesome_nested_set.git
14
15 == Usage
16
17 To make use of awesome_nested_set, your model needs to have 3 fields: lft, rgt, and parent_id:
18
19 class CreateCategories < ActiveRecord::Migration
20 def self.up
21 create_table :categories do |t|
22 t.string :name
23 t.integer :parent_id
24 t.integer :lft
25 t.integer :rgt
26 end
27 end
28
29 def self.down
30 drop_table :categories
31 end
32 end
33
34 Enable the nested set functionality by declaring acts_as_nested_set on your model
35
36 class Category < ActiveRecord::Base
37 acts_as_nested_set
38 end
39
40 Run `rake rdoc` to generate the API docs and see CollectiveIdea::Acts::NestedSet::SingletonMethods for more info.
41
42 == View Helper
43
44 The view helper is called #nested_set_options.
45
46 Example usage:
47
48 <%= f.select :parent_id, nested_set_options(Category, @category) {|i| "#{'-' * i.level} #{i.name}" } %>
49
50 <%= select_tag 'parent_id', options_for_select(nested_set_options(Category) {|i| "#{'-' * i.level} #{i.name}" } ) %>
51
52 See CollectiveIdea::Acts::NestedSet::Helper for more information about the helpers.
53
54 == References
55
56 You can learn more about nested sets at:
57
58 http://www.dbmsmag.com/9603d06.html
59 http://threebit.net/tutorials/nestedset/tutorial1.html
60 http://api.rubyonrails.com/classes/ActiveRecord/Acts/NestedSet/ClassMethods.html
61 http://opensource.symetrie.com/trac/better_nested_set/
62
63
64 Copyright (c) 2008 Collective Idea, released under the MIT license No newline at end of file
@@ -0,0 +1,46
1 require 'rake'
2 require 'rake/testtask'
3 require 'rake/rdoctask'
4 require 'rake/gempackagetask'
5 require 'rcov/rcovtask'
6 require "load_multi_rails_rake_tasks"
7
8 spec = eval(File.read("#{File.dirname(__FILE__)}/awesome_nested_set.gemspec"))
9 PKG_NAME = spec.name
10 PKG_VERSION = spec.version
11
12 Rake::GemPackageTask.new(spec) do |pkg|
13 pkg.need_zip = true
14 pkg.need_tar = true
15 end
16
17
18 desc 'Default: run unit tests.'
19 task :default => :test
20
21 desc 'Test the awesome_nested_set plugin.'
22 Rake::TestTask.new(:test) do |t|
23 t.libs << 'lib'
24 t.pattern = 'test/**/*_test.rb'
25 t.verbose = true
26 end
27
28 desc 'Generate documentation for the awesome_nested_set plugin.'
29 Rake::RDocTask.new(:rdoc) do |rdoc|
30 rdoc.rdoc_dir = 'rdoc'
31 rdoc.title = 'AwesomeNestedSet'
32 rdoc.options << '--line-numbers' << '--inline-source'
33 rdoc.rdoc_files.include('README.rdoc')
34 rdoc.rdoc_files.include('lib/**/*.rb')
35 end
36
37 namespace :test do
38 desc "just rcov minus html output"
39 Rcov::RcovTask.new(:coverage) do |t|
40 # t.libs << 'test'
41 t.test_files = FileList['test/**/*_test.rb']
42 t.output_dir = 'coverage'
43 t.verbose = true
44 t.rcov_opts = %w(--exclude test,/usr/lib/ruby,/Library/Ruby,lib/awesome_nested_set/named_scope.rb --sort coverage)
45 end
46 end No newline at end of file
@@ -0,0 +1,20
1 Gem::Specification.new do |s|
2 s.name = "awesome_nested_set"
3 s.version = "1.1.1"
4 s.summary = "An awesome replacement for acts_as_nested_set and better_nested_set."
5 s.description = s.summary
6
7 s.files = %w(init.rb MIT-LICENSE Rakefile README.rdoc lib/awesome_nested_set.rb lib/awesome_nested_set/compatability.rb lib/awesome_nested_set/helper.rb lib/awesome_nested_set/named_scope.rb rails/init.rb test/awesome_nested_set_test.rb test/test_helper.rb test/awesome_nested_set/helper_test.rb test/db/database.yml test/db/schema.rb test/fixtures/categories.yml test/fixtures/category.rb test/fixtures/departments.yml test/fixtures/notes.yml)
8
9 s.add_dependency "activerecord", ['>= 1.1']
10
11 s.has_rdoc = true
12 s.extra_rdoc_files = [ "README.rdoc"]
13 s.rdoc_options = ["--main", "README.rdoc", "--inline-source", "--line-numbers"]
14
15 s.test_files = %w(test/awesome_nested_set_test.rb test/test_helper.rb test/awesome_nested_set/helper_test.rb test/db/database.yml test/db/schema.rb test/fixtures/categories.yml test/fixtures/category.rb test/fixtures/departments.yml test/fixtures/notes.yml)
16 s.require_path = 'lib'
17 s.author = "Collective Idea"
18 s.email = "info@collectiveidea.com"
19 s.homepage = "http://collectiveidea.com"
20 end
@@ -0,0 +1,1
1 require File.dirname(__FILE__) + "/rails/init"
This diff has been collapsed as it changes many lines, (547 lines changed) Show them Hide them
@@ -0,0 +1,547
1 module CollectiveIdea #:nodoc:
2 module Acts #:nodoc:
3 module NestedSet #:nodoc:
4 def self.included(base)
5 base.extend(SingletonMethods)
6 end
7
8 # This acts provides Nested Set functionality. Nested Set is a smart way to implement
9 # an _ordered_ tree, with the added feature that you can select the children and all of their
10 # descendants with a single query. The drawback is that insertion or move need some complex
11 # sql queries. But everything is done here by this module!
12 #
13 # Nested sets are appropriate each time you want either an orderd tree (menus,
14 # commercial categories) or an efficient way of querying big trees (threaded posts).
15 #
16 # == API
17 #
18 # Methods names are aligned with acts_as_tree as much as possible, to make replacment from one
19 # by another easier, except for the creation:
20 #
21 # in acts_as_tree:
22 # item.children.create(:name => "child1")
23 #
24 # in acts_as_nested_set:
25 # # adds a new item at the "end" of the tree, i.e. with child.left = max(tree.right)+1
26 # child = MyClass.new(:name => "child1")
27 # child.save
28 # # now move the item to its right place
29 # child.move_to_child_of my_item
30 #
31 # You can pass an id or an object to:
32 # * <tt>#move_to_child_of</tt>
33 # * <tt>#move_to_right_of</tt>
34 # * <tt>#move_to_left_of</tt>
35 #
36 module SingletonMethods
37 # Configuration options are:
38 #
39 # * +:parent_column+ - specifies the column name to use for keeping the position integer (default: parent_id)
40 # * +:left_column+ - column name for left boundry data, default "lft"
41 # * +:right_column+ - column name for right boundry data, default "rgt"
42 # * +:scope+ - restricts what is to be considered a list. Given a symbol, it'll attach "_id"
43 # (if it hasn't been already) and use that as the foreign key restriction. You
44 # can also pass an array to scope by multiple attributes.
45 # Example: <tt>acts_as_nested_set :scope => [:notable_id, :notable_type]</tt>
46 # * +:dependent+ - behavior for cascading destroy. If set to :destroy, all the
47 # child objects are destroyed alongside this object by calling their destroy
48 # method. If set to :delete_all (default), all the child objects are deleted
49 # without calling their destroy method.
50 #
51 # See CollectiveIdea::Acts::NestedSet::ClassMethods for a list of class methods and
52 # CollectiveIdea::Acts::NestedSet::InstanceMethods for a list of instance methods added
53 # to acts_as_nested_set models
54 def acts_as_nested_set(options = {})
55 options = {
56 :parent_column => 'parent_id',
57 :left_column => 'lft',
58 :right_column => 'rgt',
59 :order => 'id',
60 :dependent => :delete_all, # or :destroy
61 }.merge(options)
62
63 if options[:scope].is_a?(Symbol) && options[:scope].to_s !~ /_id$/
64 options[:scope] = "#{options[:scope]}_id".intern
65 end
66
67 write_inheritable_attribute :acts_as_nested_set_options, options
68 class_inheritable_reader :acts_as_nested_set_options
69
70 include Comparable
71 include Columns
72 include InstanceMethods
73 extend Columns
74 extend ClassMethods
75
76 # no bulk assignment
77 attr_protected left_column_name.intern,
78 right_column_name.intern,
79 parent_column_name.intern
80
81 before_create :set_default_left_and_right
82 before_destroy :prune_from_tree
83
84 # no assignment to structure fields
85 [left_column_name, right_column_name, parent_column_name].each do |column|
86 module_eval <<-"end_eval", __FILE__, __LINE__
87 def #{column}=(x)
88 raise ActiveRecord::ActiveRecordError, "Unauthorized assignment to #{column}: it's an internal field handled by acts_as_nested_set code, use move_to_* methods instead."
89 end
90 end_eval
91 end
92
93 named_scope :roots, :conditions => {parent_column_name => nil}, :order => quoted_left_column_name
94 named_scope :leaves, :conditions => "#{quoted_right_column_name} - #{quoted_left_column_name} = 1", :order => quoted_left_column_name
95 if self.respond_to?(:define_callbacks)
96 define_callbacks("before_move", "after_move")
97 end
98
99
100 end
101
102 end
103
104 module ClassMethods
105
106 # Returns the first root
107 def root
108 roots.find(:first)
109 end
110
111 def valid?
112 left_and_rights_valid? && no_duplicates_for_columns? && all_roots_valid?
113 end
114
115 def left_and_rights_valid?
116 count(
117 :joins => "LEFT OUTER JOIN #{quoted_table_name} AS parent ON " +
118 "#{quoted_table_name}.#{quoted_parent_column_name} = parent.#{primary_key}",
119 :conditions =>
120 "#{quoted_table_name}.#{quoted_left_column_name} IS NULL OR " +
121 "#{quoted_table_name}.#{quoted_right_column_name} IS NULL OR " +
122 "#{quoted_table_name}.#{quoted_left_column_name} >= " +
123 "#{quoted_table_name}.#{quoted_right_column_name} OR " +
124 "(#{quoted_table_name}.#{quoted_parent_column_name} IS NOT NULL AND " +
125 "(#{quoted_table_name}.#{quoted_left_column_name} <= parent.#{quoted_left_column_name} OR " +
126 "#{quoted_table_name}.#{quoted_right_column_name} >= parent.#{quoted_right_column_name}))"
127 ) == 0
128 end
129
130 def no_duplicates_for_columns?
131 scope_string = Array(acts_as_nested_set_options[:scope]).map do |c|
132 connection.quote_column_name(c)
133 end.push(nil).join(", ")
134 [quoted_left_column_name, quoted_right_column_name].all? do |column|
135 # No duplicates
136 find(:first,
137 :select => "#{scope_string}#{column}, COUNT(#{column})",
138 :group => "#{scope_string}#{column}
139 HAVING COUNT(#{column}) > 1").nil?
140 end
141 end
142
143 # Wrapper for each_root_valid? that can deal with scope.
144 def all_roots_valid?
145 if acts_as_nested_set_options[:scope]
146 roots(:group => scope_column_names).group_by{|record| scope_column_names.collect{|col| record.send(col.to_sym)}}.all? do |scope, grouped_roots|
147 each_root_valid?(grouped_roots)
148 end
149 else
150 each_root_valid?(roots)
151 end
152 end
153
154 def each_root_valid?(roots_to_validate)
155 left = right = 0
156 roots_to_validate.all? do |root|
157 returning(root.left > left && root.right > right) do
158 left = root.left
159 right = root.right
160 end
161 end
162 end
163
164 # Rebuilds the left & rights if unset or invalid. Also very useful for converting from acts_as_tree.
165 def rebuild!
166 # Don't rebuild a valid tree.
167 return true if valid?
168
169 scope = lambda{}
170 if acts_as_nested_set_options[:scope]
171 scope = lambda{|node|
172 scope_column_names.inject(""){|str, column_name|
173 str << "AND #{connection.quote_column_name(column_name)} = #{connection.quote(node.send(column_name.to_sym))} "
174 }
175 }
176 end
177 indices = {}
178
179 set_left_and_rights = lambda do |node|
180 # set left
181 node[left_column_name] = indices[scope.call(node)] += 1
182 # find
183 find(:all, :conditions => ["#{quoted_parent_column_name} = ? #{scope.call(node)}", node], :order => "#{quoted_left_column_name}, #{quoted_right_column_name}, #{acts_as_nested_set_options[:order]}").each{|n| set_left_and_rights.call(n) }
184 # set right
185 node[right_column_name] = indices[scope.call(node)] += 1
186 node.save!
187 end
188
189 # Find root node(s)
190 root_nodes = find(:all, :conditions => "#{quoted_parent_column_name} IS NULL", :order => "#{quoted_left_column_name}, #{quoted_right_column_name}, #{acts_as_nested_set_options[:order]}").each do |root_node|
191 # setup index for this scope
192 indices[scope.call(root_node)] ||= 0
193 set_left_and_rights.call(root_node)
194 end
195 end
196 end
197
198 # Mixed into both classes and instances to provide easy access to the column names
199 module Columns
200 def left_column_name
201 acts_as_nested_set_options[:left_column]
202 end
203
204 def right_column_name
205 acts_as_nested_set_options[:right_column]
206 end
207
208 def parent_column_name
209 acts_as_nested_set_options[:parent_column]
210 end
211
212 def scope_column_names
213 Array(acts_as_nested_set_options[:scope])
214 end
215
216 def quoted_left_column_name
217 connection.quote_column_name(left_column_name)
218 end
219
220 def quoted_right_column_name
221 connection.quote_column_name(right_column_name)
222 end
223
224 def quoted_parent_column_name
225 connection.quote_column_name(parent_column_name)
226 end
227
228 def quoted_scope_column_names
229 scope_column_names.collect {|column_name| connection.quote_column_name(column_name) }
230 end
231 end
232
233 # Any instance method that returns a collection makes use of Rails 2.1's named_scope (which is bundled for Rails 2.0), so it can be treated as a finder.
234 #
235 # category.self_and_descendants.count
236 # category.ancestors.find(:all, :conditions => "name like '%foo%'")
237 module InstanceMethods
238 # Value of the parent column
239 def parent_id
240 self[parent_column_name]
241 end
242
243 # Value of the left column
244 def left
245 self[left_column_name]
246 end
247
248 # Value of the right column
249 def right
250 self[right_column_name]
251 end
252
253 # Returns true if this is a root node.
254 def root?
255 parent_id.nil?
256 end
257
258 def leaf?
259 right - left == 1
260 end
261
262 # Returns true is this is a child node
263 def child?
264 !parent_id.nil?
265 end
266
267 # order by left column
268 def <=>(x)
269 left <=> x.left
270 end
271
272 # Redefine to act like active record
273 def ==(comparison_object)
274 comparison_object.equal?(self) ||
275 (comparison_object.instance_of?(self.class) &&
276 comparison_object.id == id &&
277 !comparison_object.new_record?)
278 end
279
280 # Returns root
281 def root
282 self_and_ancestors.find(:first)
283 end
284
285 # Returns the immediate parent
286 def parent
287 nested_set_scope.find_by_id(parent_id) if parent_id
288 end
289
290 # Returns the array of all parents and self
291 def self_and_ancestors
292 nested_set_scope.scoped :conditions => [
293 "#{self.class.table_name}.#{quoted_left_column_name} <= ? AND #{self.class.table_name}.#{quoted_right_column_name} >= ?", left, right
294 ]
295 end
296
297 # Returns an array of all parents
298 def ancestors
299 without_self self_and_ancestors
300 end
301
302 # Returns the array of all children of the parent, including self
303 def self_and_siblings
304 nested_set_scope.scoped :conditions => {parent_column_name => parent_id}
305 end
306
307 # Returns the array of all children of the parent, except self
308 def siblings
309 without_self self_and_siblings
310 end
311
312 # Returns a set of all of its nested children which do not have children
313 def leaves
314 descendants.scoped :conditions => "#{self.class.table_name}.#{quoted_right_column_name} - #{self.class.table_name}.#{quoted_left_column_name} = 1"
315 end
316
317 # Returns the level of this object in the tree
318 # root level is 0
319 def level
320 parent_id.nil? ? 0 : ancestors.count
321 end
322
323 # Returns a set of itself and all of its nested children
324 def self_and_descendants
325 nested_set_scope.scoped :conditions => [
326 "#{self.class.table_name}.#{quoted_left_column_name} >= ? AND #{self.class.table_name}.#{quoted_right_column_name} <= ?", left, right
327 ]
328 end
329
330 # Returns a set of all of its children and nested children
331 def descendants
332 without_self self_and_descendants
333 end
334
335 # Returns a set of only this entry's immediate children
336 def children
337 nested_set_scope.scoped :conditions => {parent_column_name => self}
338 end
339
340 def is_descendant_of?(other)
341 other.left < self.left && self.left < other.right && same_scope?(other)
342 end
343
344 def is_or_is_descendant_of?(other)
345 other.left <= self.left && self.left < other.right && same_scope?(other)
346 end
347
348 def is_ancestor_of?(other)
349 self.left < other.left && other.left < self.right && same_scope?(other)
350 end
351
352 def is_or_is_ancestor_of?(other)
353 self.left <= other.left && other.left < self.right && same_scope?(other)
354 end
355
356 # Check if other model is in the same scope
357 def same_scope?(other)
358 Array(acts_as_nested_set_options[:scope]).all? do |attr|
359 self.send(attr) == other.send(attr)
360 end
361 end
362
363 # Find the first sibling to the left
364 def left_sibling
365 siblings.find(:first, :conditions => ["#{self.class.table_name}.#{quoted_left_column_name} < ?", left],
366 :order => "#{self.class.table_name}.#{quoted_left_column_name} DESC")
367 end
368
369 # Find the first sibling to the right
370 def right_sibling
371 siblings.find(:first, :conditions => ["#{self.class.table_name}.#{quoted_left_column_name} > ?", left])
372 end
373
374 # Shorthand method for finding the left sibling and moving to the left of it.
375 def move_left
376 move_to_left_of left_sibling
377 end
378
379 # Shorthand method for finding the right sibling and moving to the right of it.
380 def move_right
381 move_to_right_of right_sibling
382 end
383
384 # Move the node to the left of another node (you can pass id only)
385 def move_to_left_of(node)
386 move_to node, :left
387 end
388
389 # Move the node to the left of another node (you can pass id only)
390 def move_to_right_of(node)
391 move_to node, :right
392 end
393
394 # Move the node to the child of another node (you can pass id only)
395 def move_to_child_of(node)
396 move_to node, :child
397 end
398
399 # Move the node to root nodes
400 def move_to_root
401 move_to nil, :root
402 end
403
404 def move_possible?(target)
405 self != target && # Can't target self
406 same_scope?(target) && # can't be in different scopes
407 # !(left..right).include?(target.left..target.right) # this needs tested more
408 # detect impossible move
409 !((left <= target.left && right >= target.left) or (left <= target.right && right >= target.right))
410 end
411
412 def to_text
413 self_and_descendants.map do |node|
414 "#{'*'*(node.level+1)} #{node.id} #{node.to_s} (#{node.parent_id}, #{node.left}, #{node.right})"
415 end.join("\n")
416 end
417
418 protected
419
420 def without_self(scope)
421 scope.scoped :conditions => ["#{self.class.table_name}.#{self.class.primary_key} != ?", self]
422 end
423
424 # All nested set queries should use this nested_set_scope, which performs finds on
425 # the base ActiveRecord class, using the :scope declared in the acts_as_nested_set
426 # declaration.
427 def nested_set_scope
428 options = {:order => quoted_left_column_name}
429 scopes = Array(acts_as_nested_set_options[:scope])
430 options[:conditions] = scopes.inject({}) do |conditions,attr|
431 conditions.merge attr => self[attr]
432 end unless scopes.empty?
433 self.class.base_class.scoped options
434 end
435
436 # on creation, set automatically lft and rgt to the end of the tree
437 def set_default_left_and_right
438 maxright = nested_set_scope.maximum(right_column_name) || 0
439 # adds the new node to the right of all existing nodes
440 self[left_column_name] = maxright + 1
441 self[right_column_name] = maxright + 2
442 end
443
444 # Prunes a branch off of the tree, shifting all of the elements on the right
445 # back to the left so the counts still work.
446 def prune_from_tree
447 return if right.nil? || left.nil?
448 diff = right - left + 1
449
450 delete_method = acts_as_nested_set_options[:dependent] == :destroy ?
451 :destroy_all : :delete_all
452
453 self.class.base_class.transaction do
454 nested_set_scope.send(delete_method,
455 ["#{quoted_left_column_name} > ? AND #{quoted_right_column_name} < ?",
456 left, right]
457 )
458 nested_set_scope.update_all(
459 ["#{quoted_left_column_name} = (#{quoted_left_column_name} - ?)", diff],
460 ["#{quoted_left_column_name} >= ?", right]
461 )
462 nested_set_scope.update_all(
463 ["#{quoted_right_column_name} = (#{quoted_right_column_name} - ?)", diff],
464 ["#{quoted_right_column_name} >= ?", right]
465 )
466 end
467 end
468
469 # reload left, right, and parent
470 def reload_nested_set
471 reload(:select => "#{quoted_left_column_name}, " +
472 "#{quoted_right_column_name}, #{quoted_parent_column_name}")
473 end
474
475 def move_to(target, position)
476 raise ActiveRecord::ActiveRecordError, "You cannot move a new node" if self.new_record?
477 return if callback(:before_move) == false
478 transaction do
479 if target.is_a? self.class.base_class
480 target.reload_nested_set
481 elsif position != :root
482 # load object if node is not an object
483 target = nested_set_scope.find(target)
484 end
485 self.reload_nested_set
486
487 unless position == :root || move_possible?(target)
488 raise ActiveRecord::ActiveRecordError, "Impossible move, target node cannot be inside moved tree."
489 end
490
491 bound = case position
492 when :child; target[right_column_name]
493 when :left; target[left_column_name]
494 when :right; target[right_column_name] + 1
495 when :root; 1
496 else raise ActiveRecord::ActiveRecordError, "Position should be :child, :left, :right or :root ('#{position}' received)."
497 end
498
499 if bound > self[right_column_name]
500 bound = bound - 1
501 other_bound = self[right_column_name] + 1
502 else
503 other_bound = self[left_column_name] - 1
504 end
505
506 # there would be no change
507 return if bound == self[right_column_name] || bound == self[left_column_name]
508
509 # we have defined the boundaries of two non-overlapping intervals,
510 # so sorting puts both the intervals and their boundaries in order
511 a, b, c, d = [self[left_column_name], self[right_column_name], bound, other_bound].sort
512
513 new_parent = case position
514 when :child; target.id
515 when :root; nil
516 else target[parent_column_name]
517 end
518
519 self.class.base_class.update_all([
520 "#{quoted_left_column_name} = CASE " +
521 "WHEN #{quoted_left_column_name} BETWEEN :a AND :b " +
522 "THEN #{quoted_left_column_name} + :d - :b " +
523 "WHEN #{quoted_left_column_name} BETWEEN :c AND :d " +
524 "THEN #{quoted_left_column_name} + :a - :c " +
525 "ELSE #{quoted_left_column_name} END, " +
526 "#{quoted_right_column_name} = CASE " +
527 "WHEN #{quoted_right_column_name} BETWEEN :a AND :b " +
528 "THEN #{quoted_right_column_name} + :d - :b " +
529 "WHEN #{quoted_right_column_name} BETWEEN :c AND :d " +
530 "THEN #{quoted_right_column_name} + :a - :c " +
531 "ELSE #{quoted_right_column_name} END, " +
532 "#{quoted_parent_column_name} = CASE " +
533 "WHEN #{self.class.base_class.primary_key} = :id THEN :new_parent " +
534 "ELSE #{quoted_parent_column_name} END",
535 {:a => a, :b => b, :c => c, :d => d, :id => self.id, :new_parent => new_parent}
536 ], nested_set_scope.proxy_options[:conditions])
537 end
538 target.reload_nested_set if target
539 self.reload_nested_set
540 callback(:after_move)
541 end
542
543 end
544
545 end
546 end
547 end
@@ -0,0 +1,29
1 # Rails <2.x doesn't define #except
2 class Hash #:nodoc:
3 # Returns a new hash without the given keys.
4 def except(*keys)
5 clone.except!(*keys)
6 end unless method_defined?(:except)
7
8 # Replaces the hash without the given keys.
9 def except!(*keys)
10 keys.map! { |key| convert_key(key) } if respond_to?(:convert_key)
11 keys.each { |key| delete(key) }
12 self
13 end unless method_defined?(:except!)
14 end
15
16 # NamedScope is new to Rails 2.1
17 unless defined? ActiveRecord::NamedScope
18 require 'awesome_nested_set/named_scope'
19 ActiveRecord::Base.class_eval do
20 include CollectiveIdea::NamedScope
21 end
22 end
23
24 # Rails 1.2.x doesn't define #quoted_table_name
25 class ActiveRecord::Base #:nodoc:
26 def self.quoted_table_name
27 self.connection.quote_column_name(self.table_name)
28 end unless methods.include?('quoted_table_name')
29 end No newline at end of file
@@ -0,0 +1,40
1 module CollectiveIdea #:nodoc:
2 module Acts #:nodoc:
3 module NestedSet #:nodoc:
4 # This module provides some helpers for the model classes using acts_as_nested_set.
5 # It is included by default in all views.
6 #
7 module Helper
8 # Returns options for select.
9 # You can exclude some items from the tree.
10 # You can pass a block receiving an item and returning the string displayed in the select.
11 #
12 # == Params
13 # * +class_or_item+ - Class name or top level times
14 # * +mover+ - The item that is being move, used to exlude impossible moves
15 # * +&block+ - a block that will be used to display: {Β |item| ... item.name }
16 #
17 # == Usage
18 #
19 # <%= f.select :parent_id, nested_set_options(Category, @category) {|i|
20 # "#{'–' * i.level} #{i.name}"
21 # }) %>
22 #
23 def nested_set_options(class_or_item, mover = nil)
24 class_or_item = class_or_item.roots if class_or_item.is_a?(Class)
25 items = Array(class_or_item)
26 result = []
27 items.each do |root|
28 result += root.self_and_descendants.map do |i|
29 if mover.nil? || mover.new_record? || mover.move_possible?(i)
30 [yield(i), i.id]
31 end
32 end.compact
33 end
34 result
35 end
36
37 end
38 end
39 end
40 end No newline at end of file
@@ -0,0 +1,140
1 # Taken from Rails 2.1
2 module CollectiveIdea #:nodoc:
3 module NamedScope #:nodoc:
4 # All subclasses of ActiveRecord::Base have two named_scopes:
5 # * <tt>all</tt>, which is similar to a <tt>find(:all)</tt> query, and
6 # * <tt>scoped</tt>, which allows for the creation of anonymous scopes, on the fly:
7 #
8 # Shirt.scoped(:conditions => {:color => 'red'}).scoped(:include => :washing_instructions)
9 #
10 # These anonymous scopes tend to be useful when procedurally generating complex queries, where passing
11 # intermediate values (scopes) around as first-class objects is convenient.
12 def self.included(base)
13 base.class_eval do
14 extend ClassMethods
15 named_scope :scoped, lambda { |scope| scope }
16 end
17 end
18
19 module ClassMethods #:nodoc:
20 def scopes
21 read_inheritable_attribute(:scopes) || write_inheritable_attribute(:scopes, {})
22 end
23
24 # Adds a class method for retrieving and querying objects. A scope represents a narrowing of a database query,
25 # such as <tt>:conditions => {:color => :red}, :select => 'shirts.*', :include => :washing_instructions</tt>.
26 #
27 # class Shirt < ActiveRecord::Base
28 # named_scope :red, :conditions => {:color => 'red'}
29 # named_scope :dry_clean_only, :joins => :washing_instructions, :conditions => ['washing_instructions.dry_clean_only = ?', true]
30 # end
31 #
32 # The above calls to <tt>named_scope</tt> define class methods <tt>Shirt.red</tt> and <tt>Shirt.dry_clean_only</tt>. <tt>Shirt.red</tt>,
33 # in effect, represents the query <tt>Shirt.find(:all, :conditions => {:color => 'red'})</tt>.
34 #
35 # Unlike Shirt.find(...), however, the object returned by <tt>Shirt.red</tt> is not an Array; it resembles the association object
36 # constructed by a <tt>has_many</tt> declaration. For instance, you can invoke <tt>Shirt.red.find(:first)</tt>, <tt>Shirt.red.count</tt>,
37 # <tt>Shirt.red.find(:all, :conditions => {:size => 'small'})</tt>. Also, just
38 # as with the association objects, name scopes acts like an Array, implementing Enumerable; <tt>Shirt.red.each(&block)</tt>,
39 # <tt>Shirt.red.first</tt>, and <tt>Shirt.red.inject(memo, &block)</tt> all behave as if Shirt.red really were an Array.
40 #
41 # These named scopes are composable. For instance, <tt>Shirt.red.dry_clean_only</tt> will produce all shirts that are both red and dry clean only.
42 # Nested finds and calculations also work with these compositions: <tt>Shirt.red.dry_clean_only.count</tt> returns the number of garments
43 # for which these criteria obtain. Similarly with <tt>Shirt.red.dry_clean_only.average(:thread_count)</tt>.
44 #
45 # All scopes are available as class methods on the ActiveRecord descendent upon which the scopes were defined. But they are also available to
46 # <tt>has_many</tt> associations. If,
47 #
48 # class Person < ActiveRecord::Base
49 # has_many :shirts
50 # end
51 #
52 # then <tt>elton.shirts.red.dry_clean_only</tt> will return all of Elton's red, dry clean
53 # only shirts.
54 #
55 # Named scopes can also be procedural.
56 #
57 # class Shirt < ActiveRecord::Base
58 # named_scope :colored, lambda { |color|
59 # { :conditions => { :color => color } }
60 # }
61 # end
62 #
63 # In this example, <tt>Shirt.colored('puce')</tt> finds all puce shirts.
64 #
65 # Named scopes can also have extensions, just as with <tt>has_many</tt> declarations:
66 #
67 # class Shirt < ActiveRecord::Base
68 # named_scope :red, :conditions => {:color => 'red'} do
69 # def dom_id
70 # 'red_shirts'
71 # end
72 # end
73 # end
74 #
75 #
76 # For testing complex named scopes, you can examine the scoping options using the
77 # <tt>proxy_options</tt> method on the proxy itself.
78 #
79 # class Shirt < ActiveRecord::Base
80 # named_scope :colored, lambda { |color|
81 # { :conditions => { :color => color } }
82 # }
83 # end
84 #
85 # expected_options = { :conditions => { :colored => 'red' } }
86 # assert_equal expected_options, Shirt.colored('red').proxy_options
87 def named_scope(name, options = {}, &block)
88 scopes[name] = lambda do |parent_scope, *args|
89 Scope.new(parent_scope, case options
90 when Hash
91 options
92 when Proc
93 options.call(*args)
94 end, &block)
95 end
96 (class << self; self end).instance_eval do
97 define_method name do |*args|
98 scopes[name].call(self, *args)
99 end
100 end
101 end
102 end
103
104 class Scope #:nodoc:
105 attr_reader :proxy_scope, :proxy_options
106 [].methods.each { |m| delegate m, :to => :proxy_found unless m =~ /(^__|^nil\?|^send|class|extend|find|count|sum|average|maximum|minimum|paginate)/ }
107 delegate :scopes, :with_scope, :to => :proxy_scope
108
109 def initialize(proxy_scope, options, &block)
110 [options[:extend]].flatten.each { |extension| extend extension } if options[:extend]
111 extend Module.new(&block) if block_given?
112 @proxy_scope, @proxy_options = proxy_scope, options.except(:extend)
113 end
114
115 def reload
116 load_found; self
117 end
118
119 protected
120 def proxy_found
121 @found || load_found
122 end
123
124 private
125 def method_missing(method, *args, &block)
126 if scopes.include?(method)
127 scopes[method].call(self, *args)
128 else
129 with_scope :find => proxy_options do
130 proxy_scope.send(method, *args, &block)
131 end
132 end
133 end
134
135 def load_found
136 @found = find(:all)
137 end
138 end
139 end
140 end No newline at end of file
@@ -0,0 +1,13
1 require 'awesome_nested_set/compatability'
2 require 'awesome_nested_set'
3
4 ActiveRecord::Base.class_eval do
5 include CollectiveIdea::Acts::NestedSet
6 end
7
8 if defined?(ActionView)
9 require 'awesome_nested_set/helper'
10 ActionView::Base.class_eval do
11 include CollectiveIdea::Acts::NestedSet::Helper
12 end
13 end No newline at end of file
@@ -0,0 +1,41
1 require File.dirname(__FILE__) + '/../test_helper'
2
3 module CollectiveIdea
4 module Acts #:nodoc:
5 module NestedSet #:nodoc:
6 class AwesomeNestedSetTest < Test::Unit::TestCase
7 include Helper
8 fixtures :categories
9
10 def test_nested_set_options
11 expected = [
12 [" Top Level", 1],
13 ["- Child 1", 2],
14 ['- Child 2', 3],
15 ['-- Child 2.1', 4],
16 ['- Child 3', 5],
17 [" Top Level 2", 6]
18 ]
19 actual = nested_set_options(Category) do |c|
20 "#{'-' * c.level} #{c.name}"
21 end
22 assert_equal expected, actual
23 end
24
25 def test_nested_set_options_with_mover
26 expected = [
27 [" Top Level", 1],
28 ["- Child 1", 2],
29 ['- Child 3', 5],
30 [" Top Level 2", 6]
31 ]
32 actual = nested_set_options(Category, categories(:child_2)) do |c|
33 "#{'-' * c.level} #{c.name}"
34 end
35 assert_equal expected, actual
36 end
37
38 end
39 end
40 end
41 end No newline at end of file
This diff has been collapsed as it changes many lines, (603 lines changed) Show them Hide them
@@ -0,0 +1,603
1 require File.dirname(__FILE__) + '/test_helper'
2
3 class Note < ActiveRecord::Base
4 acts_as_nested_set :scope => [:notable_id, :notable_type]
5 end
6
7 class AwesomeNestedSetTest < Test::Unit::TestCase
8
9 class Default < ActiveRecord::Base
10 acts_as_nested_set
11 set_table_name 'categories'
12 end
13 class Scoped < ActiveRecord::Base
14 acts_as_nested_set :scope => :organization
15 set_table_name 'categories'
16 end
17
18 def test_left_column_default
19 assert_equal 'lft', Default.acts_as_nested_set_options[:left_column]
20 end
21
22 def test_right_column_default
23 assert_equal 'rgt', Default.acts_as_nested_set_options[:right_column]
24 end
25
26 def test_parent_column_default
27 assert_equal 'parent_id', Default.acts_as_nested_set_options[:parent_column]
28 end
29
30 def test_scope_default
31 assert_nil Default.acts_as_nested_set_options[:scope]
32 end
33
34 def test_left_column_name
35 assert_equal 'lft', Default.left_column_name
36 assert_equal 'lft', Default.new.left_column_name
37 end
38
39 def test_right_column_name
40 assert_equal 'rgt', Default.right_column_name
41 assert_equal 'rgt', Default.new.right_column_name
42 end
43
44 def test_parent_column_name
45 assert_equal 'parent_id', Default.parent_column_name
46 assert_equal 'parent_id', Default.new.parent_column_name
47 end
48
49 def test_quoted_left_column_name
50 quoted = Default.connection.quote_column_name('lft')
51 assert_equal quoted, Default.quoted_left_column_name
52 assert_equal quoted, Default.new.quoted_left_column_name
53 end
54
55 def test_quoted_right_column_name
56 quoted = Default.connection.quote_column_name('rgt')
57 assert_equal quoted, Default.quoted_right_column_name
58 assert_equal quoted, Default.new.quoted_right_column_name
59 end
60
61 def test_left_column_protected_from_assignment
62 assert_raises(ActiveRecord::ActiveRecordError) { Category.new.lft = 1 }
63 end
64
65 def test_right_column_protected_from_assignment
66 assert_raises(ActiveRecord::ActiveRecordError) { Category.new.rgt = 1 }
67 end
68
69 def test_parent_column_protected_from_assignment
70 assert_raises(ActiveRecord::ActiveRecordError) { Category.new.parent_id = 1 }
71 end
72
73 def test_colums_protected_on_initialize
74 c = Category.new(:lft => 1, :rgt => 2, :parent_id => 3)
75 assert_nil c.lft
76 assert_nil c.rgt
77 assert_nil c.parent_id
78 end
79
80 def test_scoped_appends_id
81 assert_equal :organization_id, Scoped.acts_as_nested_set_options[:scope]
82 end
83
84 def test_roots_class_method
85 assert_equal Category.find_all_by_parent_id(nil), Category.roots
86 end
87
88 def test_root_class_method
89 assert_equal categories(:top_level), Category.root
90 end
91
92 def test_root
93 assert_equal categories(:top_level), categories(:child_3).root
94 end
95
96 def test_root?
97 assert categories(:top_level).root?
98 assert categories(:top_level_2).root?
99 end
100
101 def test_leaves_class_method
102 assert_equal Category.find(:all, :conditions => "#{Category.right_column_name} - #{Category.left_column_name} = 1"), Category.leaves
103 assert_equal Category.leaves.count, 4
104 assert (Category.leaves.include? categories(:child_1))
105 assert (Category.leaves.include? categories(:child_2_1))
106 assert (Category.leaves.include? categories(:child_3))
107 assert (Category.leaves.include? categories(:top_level_2))
108 end
109
110 def test_leaf
111 assert categories(:child_1).leaf?
112 assert categories(:child_2_1).leaf?
113 assert categories(:child_3).leaf?
114 assert categories(:top_level_2).leaf?
115
116 assert !categories(:top_level).leaf?
117 assert !categories(:child_2).leaf?
118 end
119
120 def test_parent
121 assert_equal categories(:child_2), categories(:child_2_1).parent
122 end
123
124 def test_self_and_ancestors
125 child = categories(:child_2_1)
126 self_and_ancestors = [categories(:top_level), categories(:child_2), child]
127 assert_equal self_and_ancestors, child.self_and_ancestors
128 end
129
130 def test_ancestors
131 child = categories(:child_2_1)
132 ancestors = [categories(:top_level), categories(:child_2)]
133 assert_equal ancestors, child.ancestors
134 end
135
136 def test_self_and_siblings
137 child = categories(:child_2)
138 self_and_siblings = [categories(:child_1), child, categories(:child_3)]
139 assert_equal self_and_siblings, child.self_and_siblings
140 assert_nothing_raised do
141 tops = [categories(:top_level), categories(:top_level_2)]
142 assert_equal tops, categories(:top_level).self_and_siblings
143 end
144 end
145
146 def test_siblings
147 child = categories(:child_2)
148 siblings = [categories(:child_1), categories(:child_3)]
149 assert_equal siblings, child.siblings
150 end
151
152 def test_leaves
153 leaves = [categories(:child_1), categories(:child_2_1), categories(:child_3), categories(:top_level_2)]
154 assert categories(:top_level).leaves, leaves
155 end
156
157 def test_level
158 assert_equal 0, categories(:top_level).level
159 assert_equal 1, categories(:child_1).level
160 assert_equal 2, categories(:child_2_1).level
161 end
162
163 def test_has_children?
164 assert categories(:child_2_1).children.empty?
165 assert !categories(:child_2).children.empty?
166 assert !categories(:top_level).children.empty?
167 end
168
169 def test_self_and_descendents
170 parent = categories(:top_level)
171 self_and_descendants = [parent, categories(:child_1), categories(:child_2),
172 categories(:child_2_1), categories(:child_3)]
173 assert_equal self_and_descendants, parent.self_and_descendants
174 assert_equal self_and_descendants, parent.self_and_descendants.count
175 end
176
177 def test_descendents
178 lawyers = Category.create!(:name => "lawyers")
179 us = Category.create!(:name => "United States")
180 us.move_to_child_of(lawyers)
181 patent = Category.create!(:name => "Patent Law")
182 patent.move_to_child_of(us)
183 lawyers.reload
184
185 assert_equal 1, lawyers.children.size
186 assert_equal 1, us.children.size
187 assert_equal 2, lawyers.descendants.size
188 end
189
190 def test_self_and_descendents
191 parent = categories(:top_level)
192 descendants = [categories(:child_1), categories(:child_2),
193 categories(:child_2_1), categories(:child_3)]
194 assert_equal descendants, parent.descendants
195 end
196
197 def test_children
198 category = categories(:top_level)
199 category.children.each {|c| assert_equal category.id, c.parent_id }
200 end
201
202 def test_is_or_is_ancestor_of?
203 assert categories(:top_level).is_or_is_ancestor_of?(categories(:child_1))
204 assert categories(:top_level).is_or_is_ancestor_of?(categories(:child_2_1))
205 assert categories(:child_2).is_or_is_ancestor_of?(categories(:child_2_1))
206 assert !categories(:child_2_1).is_or_is_ancestor_of?(categories(:child_2))
207 assert !categories(:child_1).is_or_is_ancestor_of?(categories(:child_2))
208 assert categories(:child_1).is_or_is_ancestor_of?(categories(:child_1))
209 end
210
211 def test_is_ancestor_of?
212 assert categories(:top_level).is_ancestor_of?(categories(:child_1))
213 assert categories(:top_level).is_ancestor_of?(categories(:child_2_1))
214 assert categories(:child_2).is_ancestor_of?(categories(:child_2_1))
215 assert !categories(:child_2_1).is_ancestor_of?(categories(:child_2))
216 assert !categories(:child_1).is_ancestor_of?(categories(:child_2))
217 assert !categories(:child_1).is_ancestor_of?(categories(:child_1))
218 end
219
220 def test_is_or_is_ancestor_of_with_scope
221 root = Scoped.root
222 child = root.children.first
223 assert root.is_or_is_ancestor_of?(child)
224 child.update_attribute :organization_id, 'different'
225 assert !root.is_or_is_ancestor_of?(child)
226 end
227
228 def test_is_or_is_descendant_of?
229 assert categories(:child_1).is_or_is_descendant_of?(categories(:top_level))
230 assert categories(:child_2_1).is_or_is_descendant_of?(categories(:top_level))
231 assert categories(:child_2_1).is_or_is_descendant_of?(categories(:child_2))
232 assert !categories(:child_2).is_or_is_descendant_of?(categories(:child_2_1))
233 assert !categories(:child_2).is_or_is_descendant_of?(categories(:child_1))
234 assert categories(:child_1).is_or_is_descendant_of?(categories(:child_1))
235 end
236
237 def test_is_descendant_of?
238 assert categories(:child_1).is_descendant_of?(categories(:top_level))
239 assert categories(:child_2_1).is_descendant_of?(categories(:top_level))
240 assert categories(:child_2_1).is_descendant_of?(categories(:child_2))
241 assert !categories(:child_2).is_descendant_of?(categories(:child_2_1))
242 assert !categories(:child_2).is_descendant_of?(categories(:child_1))
243 assert !categories(:child_1).is_descendant_of?(categories(:child_1))
244 end
245
246 def test_is_or_is_descendant_of_with_scope
247 root = Scoped.root
248 child = root.children.first
249 assert child.is_or_is_descendant_of?(root)
250 child.update_attribute :organization_id, 'different'
251 assert !child.is_or_is_descendant_of?(root)
252 end
253
254 def test_same_scope?
255 root = Scoped.root
256 child = root.children.first
257 assert child.same_scope?(root)
258 child.update_attribute :organization_id, 'different'
259 assert !child.same_scope?(root)
260 end
261
262 def test_left_sibling
263 assert_equal categories(:child_1), categories(:child_2).left_sibling
264 assert_equal categories(:child_2), categories(:child_3).left_sibling
265 end
266
267 def test_left_sibling_of_root
268 assert_nil categories(:top_level).left_sibling
269 end
270
271 def test_left_sibling_without_siblings
272 assert_nil categories(:child_2_1).left_sibling
273 end
274
275 def test_left_sibling_of_leftmost_node
276 assert_nil categories(:child_1).left_sibling
277 end
278
279 def test_right_sibling
280 assert_equal categories(:child_3), categories(:child_2).right_sibling
281 assert_equal categories(:child_2), categories(:child_1).right_sibling
282 end
283
284 def test_right_sibling_of_root
285 assert_equal categories(:top_level_2), categories(:top_level).right_sibling
286 assert_nil categories(:top_level_2).right_sibling
287 end
288
289 def test_right_sibling_without_siblings
290 assert_nil categories(:child_2_1).right_sibling
291 end
292
293 def test_right_sibling_of_rightmost_node
294 assert_nil categories(:child_3).right_sibling
295 end
296
297 def test_move_left
298 categories(:child_2).move_left
299 assert_nil categories(:child_2).left_sibling
300 assert_equal categories(:child_1), categories(:child_2).right_sibling
301 assert Category.valid?
302 end
303
304 def test_move_right
305 categories(:child_2).move_right
306 assert_nil categories(:child_2).right_sibling
307 assert_equal categories(:child_3), categories(:child_2).left_sibling
308 assert Category.valid?
309 end
310
311 def test_move_to_left_of
312 categories(:child_3).move_to_left_of(categories(:child_1))
313 assert_nil categories(:child_3).left_sibling
314 assert_equal categories(:child_1), categories(:child_3).right_sibling
315 assert Category.valid?
316 end
317
318 def test_move_to_right_of
319 categories(:child_1).move_to_right_of(categories(:child_3))
320 assert_nil categories(:child_1).right_sibling
321 assert_equal categories(:child_3), categories(:child_1).left_sibling
322 assert Category.valid?
323 end
324
325 def test_move_to_root
326 categories(:child_2).move_to_root
327 assert_nil categories(:child_2).parent
328 assert_equal 0, categories(:child_2).level
329 assert_equal 1, categories(:child_2_1).level
330 assert_equal 1, categories(:child_2).left
331 assert_equal 4, categories(:child_2).right
332 assert Category.valid?
333 end
334
335 def test_move_to_child_of
336 categories(:child_1).move_to_child_of(categories(:child_3))
337 assert_equal categories(:child_3).id, categories(:child_1).parent_id
338 assert Category.valid?
339 end
340
341 def test_move_to_child_of_appends_to_end
342 child = Category.create! :name => 'New Child'
343 child.move_to_child_of categories(:top_level)
344 assert_equal child, categories(:top_level).children.last
345 end
346
347 def test_subtree_move_to_child_of
348 assert_equal 4, categories(:child_2).left
349 assert_equal 7, categories(:child_2).right
350
351 assert_equal 2, categories(:child_1).left
352 assert_equal 3, categories(:child_1).right
353
354 categories(:child_2).move_to_child_of(categories(:child_1))
355 assert Category.valid?
356 assert_equal categories(:child_1).id, categories(:child_2).parent_id
357
358 assert_equal 3, categories(:child_2).left
359 assert_equal 6, categories(:child_2).right
360 assert_equal 2, categories(:child_1).left
361 assert_equal 7, categories(:child_1).right
362 end
363
364 def test_slightly_difficult_move_to_child_of
365 assert_equal 11, categories(:top_level_2).left
366 assert_equal 12, categories(:top_level_2).right
367
368 # create a new top-level node and move single-node top-level tree inside it.
369 new_top = Category.create(:name => 'New Top')
370 assert_equal 13, new_top.left
371 assert_equal 14, new_top.right
372
373 categories(:top_level_2).move_to_child_of(new_top)
374
375 assert Category.valid?
376 assert_equal new_top.id, categories(:top_level_2).parent_id
377
378 assert_equal 12, categories(:top_level_2).left
379 assert_equal 13, categories(:top_level_2).right
380 assert_equal 11, new_top.left
381 assert_equal 14, new_top.right
382 end
383
384 def test_difficult_move_to_child_of
385 assert_equal 1, categories(:top_level).left
386 assert_equal 10, categories(:top_level).right
387 assert_equal 5, categories(:child_2_1).left
388 assert_equal 6, categories(:child_2_1).right
389
390 # create a new top-level node and move an entire top-level tree inside it.
391 new_top = Category.create(:name => 'New Top')
392 categories(:top_level).move_to_child_of(new_top)
393 categories(:child_2_1).reload
394 assert Category.valid?
395 assert_equal new_top.id, categories(:top_level).parent_id
396
397 assert_equal 4, categories(:top_level).left
398 assert_equal 13, categories(:top_level).right
399 assert_equal 8, categories(:child_2_1).left
400 assert_equal 9, categories(:child_2_1).right
401 end
402
403 #rebuild swaps the position of the 2 children when added using move_to_child twice onto same parent
404 def test_move_to_child_more_than_once_per_parent_rebuild
405 root1 = Category.create(:name => 'Root1')
406 root2 = Category.create(:name => 'Root2')
407 root3 = Category.create(:name => 'Root3')
408
409 root2.move_to_child_of root1
410 root3.move_to_child_of root1
411
412 output = Category.roots.last.to_text
413 Category.update_all('lft = null, rgt = null')
414 Category.rebuild!
415
416 assert_equal Category.roots.last.to_text, output
417 end
418
419 # doing move_to_child twice onto same parent from the furthest right first
420 def test_move_to_child_more_than_once_per_parent_outside_in
421 node1 = Category.create(:name => 'Node-1')
422 node2 = Category.create(:name => 'Node-2')
423 node3 = Category.create(:name => 'Node-3')
424
425 node2.move_to_child_of node1
426 node3.move_to_child_of node1
427
428 output = Category.roots.last.to_text
429 Category.update_all('lft = null, rgt = null')
430 Category.rebuild!
431
432 assert_equal Category.roots.last.to_text, output
433 end
434
435
436 def test_valid_with_null_lefts
437 assert Category.valid?
438 Category.update_all('lft = null')
439 assert !Category.valid?
440 end
441
442 def test_valid_with_null_rights
443 assert Category.valid?
444 Category.update_all('rgt = null')
445 assert !Category.valid?
446 end
447
448 def test_valid_with_missing_intermediate_node
449 # Even though child_2_1 will still exist, it is a sign of a sloppy delete, not an invalid tree.
450 assert Category.valid?
451 Category.delete(categories(:child_2).id)
452 assert Category.valid?
453 end
454
455 def test_valid_with_overlapping_and_rights
456 assert Category.valid?
457 categories(:top_level_2)['lft'] = 0
458 categories(:top_level_2).save
459 assert !Category.valid?
460 end
461
462 def test_rebuild
463 assert Category.valid?
464 before_text = Category.root.to_text
465 Category.update_all('lft = null, rgt = null')
466 Category.rebuild!
467 assert Category.valid?
468 assert_equal before_text, Category.root.to_text
469 end
470
471 def test_move_possible_for_sibling
472 assert categories(:child_2).move_possible?(categories(:child_1))
473 end
474
475 def test_move_not_possible_to_self
476 assert !categories(:top_level).move_possible?(categories(:top_level))
477 end
478
479 def test_move_not_possible_to_parent
480 categories(:top_level).descendants.each do |descendant|
481 assert !categories(:top_level).move_possible?(descendant)
482 assert descendant.move_possible?(categories(:top_level))
483 end
484 end
485
486 def test_is_or_is_ancestor_of?
487 [:child_1, :child_2, :child_2_1, :child_3].each do |c|
488 assert categories(:top_level).is_or_is_ancestor_of?(categories(c))
489 end
490 assert !categories(:top_level).is_or_is_ancestor_of?(categories(:top_level_2))
491 end
492
493 def test_left_and_rights_valid_with_blank_left
494 assert Category.left_and_rights_valid?
495 categories(:child_2)[:lft] = nil
496 categories(:child_2).save(false)
497 assert !Category.left_and_rights_valid?
498 end
499
500 def test_left_and_rights_valid_with_blank_right
501 assert Category.left_and_rights_valid?
502 categories(:child_2)[:rgt] = nil
503 categories(:child_2).save(false)
504 assert !Category.left_and_rights_valid?
505 end
506
507 def test_left_and_rights_valid_with_equal
508 assert Category.left_and_rights_valid?
509 categories(:top_level_2)[:lft] = categories(:top_level_2)[:rgt]
510 categories(:top_level_2).save(false)
511 assert !Category.left_and_rights_valid?
512 end
513
514 def test_left_and_rights_valid_with_left_equal_to_parent
515 assert Category.left_and_rights_valid?
516 categories(:child_2)[:lft] = categories(:top_level)[:lft]
517 categories(:child_2).save(false)
518 assert !Category.left_and_rights_valid?
519 end
520
521 def test_left_and_rights_valid_with_right_equal_to_parent
522 assert Category.left_and_rights_valid?
523 categories(:child_2)[:rgt] = categories(:top_level)[:rgt]
524 categories(:child_2).save(false)
525 assert !Category.left_and_rights_valid?
526 end
527
528 def test_moving_dirty_objects_doesnt_invalidate_tree
529 r1 = Category.create
530 r2 = Category.create
531 r3 = Category.create
532 r4 = Category.create
533 nodes = [r1, r2, r3, r4]
534
535 r2.move_to_child_of(r1)
536 assert Category.valid?
537
538 r3.move_to_child_of(r1)
539 assert Category.valid?
540
541 r4.move_to_child_of(r2)
542 assert Category.valid?
543 end
544
545 def test_multi_scoped_no_duplicates_for_columns?
546 assert_nothing_raised do
547 Note.no_duplicates_for_columns?
548 end
549 end
550
551 def test_multi_scoped_all_roots_valid?
552 assert_nothing_raised do
553 Note.all_roots_valid?
554 end
555 end
556
557 def test_multi_scoped
558 note1 = Note.create!(:body => "A", :notable_id => 2, :notable_type => 'Category')
559 note2 = Note.create!(:body => "B", :notable_id => 2, :notable_type => 'Category')
560 note3 = Note.create!(:body => "C", :notable_id => 2, :notable_type => 'Default')
561
562 assert_equal [note1, note2], note1.self_and_siblings
563 assert_equal [note3], note3.self_and_siblings
564 end
565
566 def test_multi_scoped_rebuild
567 root = Note.create!(:body => "A", :notable_id => 3, :notable_type => 'Category')
568 child1 = Note.create!(:body => "B", :notable_id => 3, :notable_type => 'Category')
569 child2 = Note.create!(:body => "C", :notable_id => 3, :notable_type => 'Category')
570
571 child1.move_to_child_of root
572 child2.move_to_child_of root
573
574 Note.update_all('lft = null, rgt = null')
575 Note.rebuild!
576
577 assert_equal Note.roots.find_by_body('A'), root
578 assert_equal [child1, child2], Note.roots.find_by_body('A').children
579 end
580
581 def test_same_scope_with_multi_scopes
582 assert_nothing_raised do
583 notes(:scope1).same_scope?(notes(:child_1))
584 end
585 assert notes(:scope1).same_scope?(notes(:child_1))
586 assert notes(:child_1).same_scope?(notes(:scope1))
587 assert !notes(:scope1).same_scope?(notes(:scope2))
588 end
589
590 def test_quoting_of_multi_scope_column_names
591 assert_equal ["\"notable_id\"", "\"notable_type\""], Note.quoted_scope_column_names
592 end
593
594 def test_equal_in_same_scope
595 assert_equal notes(:scope1), notes(:scope1)
596 assert_not_equal notes(:scope1), notes(:child_1)
597 end
598
599 def test_equal_in_different_scopes
600 assert_not_equal notes(:scope1), notes(:scope2)
601 end
602
603 end
@@ -0,0 +1,18
1 sqlite3:
2 adapter: sqlite3
3 dbfile: awesome_nested_set.sqlite3.db
4 sqlite3mem:
5 :adapter: sqlite3
6 :dbfile: ":memory:"
7 postgresql:
8 :adapter: postgresql
9 :username: postgres
10 :password: postgres
11 :database: awesome_nested_set_plugin_test
12 :min_messages: ERROR
13 mysql:
14 :adapter: mysql
15 :host: localhost
16 :username: root
17 :password:
18 :database: awesome_nested_set_plugin_test No newline at end of file
@@ -0,0 +1,23
1 ActiveRecord::Schema.define(:version => 0) do
2
3 create_table :categories, :force => true do |t|
4 t.column :name, :string
5 t.column :parent_id, :integer
6 t.column :lft, :integer
7 t.column :rgt, :integer
8 t.column :organization_id, :integer
9 end
10
11 create_table :departments, :force => true do |t|
12 t.column :name, :string
13 end
14
15 create_table :notes, :force => true do |t|
16 t.column :body, :text
17 t.column :parent_id, :integer
18 t.column :lft, :integer
19 t.column :rgt, :integer
20 t.column :notable_id, :integer
21 t.column :notable_type, :string
22 end
23 end
@@ -0,0 +1,34
1 top_level:
2 id: 1
3 name: Top Level
4 lft: 1
5 rgt: 10
6 child_1:
7 id: 2
8 name: Child 1
9 parent_id: 1
10 lft: 2
11 rgt: 3
12 child_2:
13 id: 3
14 name: Child 2
15 parent_id: 1
16 lft: 4
17 rgt: 7
18 child_2_1:
19 id: 4
20 name: Child 2.1
21 parent_id: 3
22 lft: 5
23 rgt: 6
24 child_3:
25 id: 5
26 name: Child 3
27 parent_id: 1
28 lft: 8
29 rgt: 9
30 top_level_2:
31 id: 6
32 name: Top Level 2
33 lft: 11
34 rgt: 12
@@ -0,0 +1,15
1 class Category < ActiveRecord::Base
2 acts_as_nested_set
3
4 def to_s
5 name
6 end
7
8 def recurse &block
9 block.call self, lambda{
10 self.children.each do |child|
11 child.recurse &block
12 end
13 }
14 end
15 end No newline at end of file
@@ -0,0 +1,3
1 top:
2 id: 1
3 name: Top No newline at end of file
@@ -0,0 +1,38
1 scope1:
2 id: 1
3 body: Top Level
4 lft: 1
5 rgt: 10
6 notable_id: 1
7 notable_type: Category
8 child_1:
9 id: 2
10 body: Child 1
11 parent_id: 1
12 lft: 2
13 rgt: 3
14 notable_id: 1
15 notable_type: Category
16 child_2:
17 id: 3
18 body: Child 2
19 parent_id: 1
20 lft: 4
21 rgt: 7
22 notable_id: 1
23 notable_type: Category
24 child_3:
25 id: 4
26 body: Child 3
27 parent_id: 1
28 lft: 8
29 rgt: 9
30 notable_id: 1
31 notable_type: Category
32 scope2:
33 id: 5
34 body: Top Level 2
35 lft: 1
36 rgt: 2
37 notable_id: 1
38 notable_type: Departments
@@ -0,0 +1,31
1 $:.unshift(File.dirname(__FILE__) + '/../lib')
2 plugin_test_dir = File.dirname(__FILE__)
3
4 require 'rubygems'
5 require 'test/unit'
6 require 'multi_rails_init'
7 # gem 'activerecord', '>= 2.0'
8 require 'active_record'
9 require 'action_controller'
10 require 'action_view'
11 require 'active_record/fixtures'
12
13 require plugin_test_dir + '/../init.rb'
14
15 ActiveRecord::Base.logger = Logger.new(plugin_test_dir + "/debug.log")
16
17 ActiveRecord::Base.configurations = YAML::load(IO.read(plugin_test_dir + "/db/database.yml"))
18 ActiveRecord::Base.establish_connection(ENV["DB"] || "sqlite3mem")
19 ActiveRecord::Migration.verbose = false
20 load(File.join(plugin_test_dir, "db", "schema.rb"))
21
22 Dir["#{plugin_test_dir}/fixtures/*.rb"].each {|file| require file }
23
24
25 class Test::Unit::TestCase #:nodoc:
26 self.fixture_path = File.dirname(__FILE__) + "/fixtures/"
27 self.use_transactional_fixtures = true
28 self.use_instantiated_fixtures = false
29
30 fixtures :categories, :notes, :departments
31 end No newline at end of file
@@ -1,93 +1,84
1 1 # redMine - project management software
2 2 # Copyright (C) 2006 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class AdminController < ApplicationController
19 19 before_filter :require_admin
20 20
21 21 helper :sort
22 22 include SortHelper
23 23
24 24 def index
25 25 @no_configuration_data = Redmine::DefaultData::Loader::no_data?
26 26 end
27 27
28 28 def projects
29 sort_init 'name', 'asc'
30 sort_update %w(name is_public created_on)
31
32 29 @status = params[:status] ? params[:status].to_i : 1
33 30 c = ARCondition.new(@status == 0 ? "status <> 0" : ["status = ?", @status])
34 31
35 32 unless params[:name].blank?
36 33 name = "%#{params[:name].strip.downcase}%"
37 34 c << ["LOWER(identifier) LIKE ? OR LOWER(name) LIKE ?", name, name]
38 35 end
39 36
40 @project_count = Project.count(:conditions => c.conditions)
41 @project_pages = Paginator.new self, @project_count,
42 per_page_option,
43 params['page']
44 @projects = Project.find :all, :order => sort_clause,
45 :conditions => c.conditions,
46 :limit => @project_pages.items_per_page,
47 :offset => @project_pages.current.offset
37 @projects = Project.find :all, :order => 'lft',
38 :conditions => c.conditions
48 39
49 40 render :action => "projects", :layout => false if request.xhr?
50 41 end
51 42
52 43 def plugins
53 44 @plugins = Redmine::Plugin.all
54 45 end
55 46
56 47 # Loads the default configuration
57 48 # (roles, trackers, statuses, workflow, enumerations)
58 49 def default_configuration
59 50 if request.post?
60 51 begin
61 52 Redmine::DefaultData::Loader::load(params[:lang])
62 53 flash[:notice] = l(:notice_default_data_loaded)
63 54 rescue Exception => e
64 55 flash[:error] = l(:error_can_t_load_default_data, e.message)
65 56 end
66 57 end
67 58 redirect_to :action => 'index'
68 59 end
69 60
70 61 def test_email
71 62 raise_delivery_errors = ActionMailer::Base.raise_delivery_errors
72 63 # Force ActionMailer to raise delivery errors so we can catch it
73 64 ActionMailer::Base.raise_delivery_errors = true
74 65 begin
75 66 @test = Mailer.deliver_test(User.current)
76 67 flash[:notice] = l(:notice_email_sent, User.current.mail)
77 68 rescue Exception => e
78 69 flash[:error] = l(:notice_email_error, e.message)
79 70 end
80 71 ActionMailer::Base.raise_delivery_errors = raise_delivery_errors
81 72 redirect_to :controller => 'settings', :action => 'edit', :tab => 'notifications'
82 73 end
83 74
84 75 def info
85 76 @db_adapter_name = ActiveRecord::Base.connection.adapter_name
86 77 @flags = {
87 78 :default_admin_changed => User.find(:first, :conditions => ["login=? and hashed_password=?", 'admin', User.hash_password('admin')]).nil?,
88 79 :file_repository_writable => File.writable?(Attachment.storage_path),
89 80 :plugin_assets_writable => File.writable?(Engines.public_directory),
90 81 :rmagick_available => Object.const_defined?(:Magick)
91 82 }
92 83 end
93 84 end
@@ -1,296 +1,290
1 1 # redMine - project management software
2 2 # Copyright (C) 2006-2007 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class ProjectsController < ApplicationController
19 19 menu_item :overview
20 20 menu_item :activity, :only => :activity
21 21 menu_item :roadmap, :only => :roadmap
22 22 menu_item :files, :only => [:list_files, :add_file]
23 23 menu_item :settings, :only => :settings
24 24 menu_item :issues, :only => [:changelog]
25 25
26 26 before_filter :find_project, :except => [ :index, :list, :add, :activity ]
27 27 before_filter :find_optional_project, :only => :activity
28 28 before_filter :authorize, :except => [ :index, :list, :add, :archive, :unarchive, :destroy, :activity ]
29 29 before_filter :require_admin, :only => [ :add, :archive, :unarchive, :destroy ]
30 30 accept_key_auth :activity
31 31
32 32 helper :sort
33 33 include SortHelper
34 34 helper :custom_fields
35 35 include CustomFieldsHelper
36 36 helper :issues
37 37 helper IssuesHelper
38 38 helper :queries
39 39 include QueriesHelper
40 40 helper :repositories
41 41 include RepositoriesHelper
42 42 include ProjectsHelper
43 43
44 44 # Lists visible projects
45 45 def index
46 projects = Project.find :all,
47 :conditions => Project.visible_by(User.current),
48 :include => :parent
49 46 respond_to do |format|
50 47 format.html {
51 @project_tree = projects.group_by {|p| p.parent || p}
52 @project_tree.keys.each {|p| @project_tree[p] -= [p]}
48 @projects = Project.visible.find(:all, :order => 'lft')
53 49 }
54 50 format.atom {
55 render_feed(projects.sort_by(&:created_on).reverse.slice(0, Setting.feeds_limit.to_i),
56 :title => "#{Setting.app_title}: #{l(:label_project_latest)}")
51 projects = Project.visible.find(:all, :order => 'created_on DESC',
52 :limit => Setting.feeds_limit.to_i)
53 render_feed(projects, :title => "#{Setting.app_title}: #{l(:label_project_latest)}")
57 54 }
58 55 end
59 56 end
60 57
61 58 # Add a new project
62 59 def add
63 60 @issue_custom_fields = IssueCustomField.find(:all, :order => "#{CustomField.table_name}.position")
64 61 @trackers = Tracker.all
65 @root_projects = Project.find(:all,
66 :conditions => "parent_id IS NULL AND status = #{Project::STATUS_ACTIVE}",
67 :order => 'name')
68 62 @project = Project.new(params[:project])
69 63 if request.get?
70 64 @project.identifier = Project.next_identifier if Setting.sequential_project_identifiers?
71 65 @project.trackers = Tracker.all
72 66 @project.is_public = Setting.default_projects_public?
73 67 @project.enabled_module_names = Redmine::AccessControl.available_project_modules
74 68 else
75 69 @project.enabled_module_names = params[:enabled_modules]
76 70 if @project.save
71 @project.set_parent!(params[:project]['parent_id']) if User.current.admin? && params[:project].has_key?('parent_id')
77 72 flash[:notice] = l(:notice_successful_create)
78 73 redirect_to :controller => 'admin', :action => 'projects'
79 74 end
80 75 end
81 76 end
82 77
83 78 # Show @project
84 79 def show
85 80 if params[:jump]
86 81 # try to redirect to the requested menu item
87 82 redirect_to_project_menu_item(@project, params[:jump]) && return
88 83 end
89 84
90 85 @members_by_role = @project.members.find(:all, :include => [:user, :role], :order => 'position').group_by {|m| m.role}
91 @subprojects = @project.children.find(:all, :conditions => Project.visible_by(User.current))
86 @subprojects = @project.children.visible
87 @ancestors = @project.ancestors.visible
92 88 @news = @project.news.find(:all, :limit => 5, :include => [ :author, :project ], :order => "#{News.table_name}.created_on DESC")
93 89 @trackers = @project.rolled_up_trackers
94 90
95 91 cond = @project.project_condition(Setting.display_subprojects_issues?)
96 92 Issue.visible_by(User.current) do
97 93 @open_issues_by_tracker = Issue.count(:group => :tracker,
98 94 :include => [:project, :status, :tracker],
99 95 :conditions => ["(#{cond}) AND #{IssueStatus.table_name}.is_closed=?", false])
100 96 @total_issues_by_tracker = Issue.count(:group => :tracker,
101 97 :include => [:project, :status, :tracker],
102 98 :conditions => cond)
103 99 end
104 100 TimeEntry.visible_by(User.current) do
105 101 @total_hours = TimeEntry.sum(:hours,
106 102 :include => :project,
107 103 :conditions => cond).to_f
108 104 end
109 105 @key = User.current.rss_key
110 106 end
111 107
112 108 def settings
113 @root_projects = Project.find(:all,
114 :conditions => ["parent_id IS NULL AND status = #{Project::STATUS_ACTIVE} AND id <> ?", @project.id],
115 :order => 'name')
116 109 @issue_custom_fields = IssueCustomField.find(:all, :order => "#{CustomField.table_name}.position")
117 110 @issue_category ||= IssueCategory.new
118 111 @member ||= @project.members.new
119 112 @trackers = Tracker.all
120 113 @repository ||= @project.repository
121 114 @wiki ||= @project.wiki
122 115 end
123 116
124 117 # Edit @project
125 118 def edit
126 119 if request.post?
127 120 @project.attributes = params[:project]
128 121 if @project.save
122 @project.set_parent!(params[:project]['parent_id']) if User.current.admin? && params[:project].has_key?('parent_id')
129 123 flash[:notice] = l(:notice_successful_update)
130 124 redirect_to :action => 'settings', :id => @project
131 125 else
132 126 settings
133 127 render :action => 'settings'
134 128 end
135 129 end
136 130 end
137 131
138 132 def modules
139 133 @project.enabled_module_names = params[:enabled_modules]
140 134 redirect_to :action => 'settings', :id => @project, :tab => 'modules'
141 135 end
142 136
143 137 def archive
144 138 @project.archive if request.post? && @project.active?
145 139 redirect_to :controller => 'admin', :action => 'projects'
146 140 end
147 141
148 142 def unarchive
149 143 @project.unarchive if request.post? && !@project.active?
150 144 redirect_to :controller => 'admin', :action => 'projects'
151 145 end
152 146
153 147 # Delete @project
154 148 def destroy
155 149 @project_to_destroy = @project
156 150 if request.post? and params[:confirm]
157 151 @project_to_destroy.destroy
158 152 redirect_to :controller => 'admin', :action => 'projects'
159 153 end
160 154 # hide project in layout
161 155 @project = nil
162 156 end
163 157
164 158 # Add a new issue category to @project
165 159 def add_issue_category
166 160 @category = @project.issue_categories.build(params[:category])
167 161 if request.post? and @category.save
168 162 respond_to do |format|
169 163 format.html do
170 164 flash[:notice] = l(:notice_successful_create)
171 165 redirect_to :action => 'settings', :tab => 'categories', :id => @project
172 166 end
173 167 format.js do
174 168 # IE doesn't support the replace_html rjs method for select box options
175 169 render(:update) {|page| page.replace "issue_category_id",
176 170 content_tag('select', '<option></option>' + options_from_collection_for_select(@project.issue_categories, 'id', 'name', @category.id), :id => 'issue_category_id', :name => 'issue[category_id]')
177 171 }
178 172 end
179 173 end
180 174 end
181 175 end
182 176
183 177 # Add a new version to @project
184 178 def add_version
185 179 @version = @project.versions.build(params[:version])
186 180 if request.post? and @version.save
187 181 flash[:notice] = l(:notice_successful_create)
188 182 redirect_to :action => 'settings', :tab => 'versions', :id => @project
189 183 end
190 184 end
191 185
192 186 def add_file
193 187 if request.post?
194 188 container = (params[:version_id].blank? ? @project : @project.versions.find_by_id(params[:version_id]))
195 189 attachments = attach_files(container, params[:attachments])
196 190 if !attachments.empty? && Setting.notified_events.include?('file_added')
197 191 Mailer.deliver_attachments_added(attachments)
198 192 end
199 193 redirect_to :controller => 'projects', :action => 'list_files', :id => @project
200 194 return
201 195 end
202 196 @versions = @project.versions.sort
203 197 end
204 198
205 199 def list_files
206 200 sort_init 'filename', 'asc'
207 201 sort_update 'filename' => "#{Attachment.table_name}.filename",
208 202 'created_on' => "#{Attachment.table_name}.created_on",
209 203 'size' => "#{Attachment.table_name}.filesize",
210 204 'downloads' => "#{Attachment.table_name}.downloads"
211 205
212 206 @containers = [ Project.find(@project.id, :include => :attachments, :order => sort_clause)]
213 207 @containers += @project.versions.find(:all, :include => :attachments, :order => sort_clause).sort.reverse
214 208 render :layout => !request.xhr?
215 209 end
216 210
217 211 # Show changelog for @project
218 212 def changelog
219 213 @trackers = @project.trackers.find(:all, :conditions => ["is_in_chlog=?", true], :order => 'position')
220 214 retrieve_selected_tracker_ids(@trackers)
221 215 @versions = @project.versions.sort
222 216 end
223 217
224 218 def roadmap
225 219 @trackers = @project.trackers.find(:all, :conditions => ["is_in_roadmap=?", true])
226 220 retrieve_selected_tracker_ids(@trackers)
227 221 @versions = @project.versions.sort
228 222 @versions = @versions.select {|v| !v.completed? } unless params[:completed]
229 223 end
230 224
231 225 def activity
232 226 @days = Setting.activity_days_default.to_i
233 227
234 228 if params[:from]
235 229 begin; @date_to = params[:from].to_date + 1; rescue; end
236 230 end
237 231
238 232 @date_to ||= Date.today + 1
239 233 @date_from = @date_to - @days
240 234 @with_subprojects = params[:with_subprojects].nil? ? Setting.display_subprojects_issues? : (params[:with_subprojects] == '1')
241 235 @author = (params[:user_id].blank? ? nil : User.active.find(params[:user_id]))
242 236
243 237 @activity = Redmine::Activity::Fetcher.new(User.current, :project => @project,
244 238 :with_subprojects => @with_subprojects,
245 239 :author => @author)
246 240 @activity.scope_select {|t| !params["show_#{t}"].nil?}
247 241 @activity.scope = (@author.nil? ? :default : :all) if @activity.scope.empty?
248 242
249 243 events = @activity.events(@date_from, @date_to)
250 244
251 245 respond_to do |format|
252 246 format.html {
253 247 @events_by_day = events.group_by(&:event_date)
254 248 render :layout => false if request.xhr?
255 249 }
256 250 format.atom {
257 251 title = l(:label_activity)
258 252 if @author
259 253 title = @author.name
260 254 elsif @activity.scope.size == 1
261 255 title = l("label_#{@activity.scope.first.singularize}_plural")
262 256 end
263 257 render_feed(events, :title => "#{@project || Setting.app_title}: #{title}")
264 258 }
265 259 end
266 260
267 261 rescue ActiveRecord::RecordNotFound
268 262 render_404
269 263 end
270 264
271 265 private
272 266 # Find project of id params[:id]
273 267 # if not found, redirect to project list
274 268 # Used as a before_filter
275 269 def find_project
276 270 @project = Project.find(params[:id])
277 271 rescue ActiveRecord::RecordNotFound
278 272 render_404
279 273 end
280 274
281 275 def find_optional_project
282 276 return true unless params[:id]
283 277 @project = Project.find(params[:id])
284 278 authorize
285 279 rescue ActiveRecord::RecordNotFound
286 280 render_404
287 281 end
288 282
289 283 def retrieve_selected_tracker_ids(selectable_trackers)
290 284 if ids = params[:tracker_ids]
291 285 @selected_tracker_ids = (ids.is_a? Array) ? ids.collect { |id| id.to_i.to_s } : ids.split('/').collect { |id| id.to_i.to_s }
292 286 else
293 287 @selected_tracker_ids = selectable_trackers.collect {|t| t.id.to_s }
294 288 end
295 289 end
296 290 end
@@ -1,236 +1,236
1 1 # redMine - project management software
2 2 # Copyright (C) 2006 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class ReportsController < ApplicationController
19 19 menu_item :issues
20 20 before_filter :find_project, :authorize
21 21
22 22 def issue_report
23 23 @statuses = IssueStatus.find(:all, :order => 'position')
24 24
25 25 case params[:detail]
26 26 when "tracker"
27 27 @field = "tracker_id"
28 28 @rows = @project.trackers
29 29 @data = issues_by_tracker
30 30 @report_title = l(:field_tracker)
31 31 render :template => "reports/issue_report_details"
32 32 when "version"
33 33 @field = "fixed_version_id"
34 34 @rows = @project.versions.sort
35 35 @data = issues_by_version
36 36 @report_title = l(:field_version)
37 37 render :template => "reports/issue_report_details"
38 38 when "priority"
39 39 @field = "priority_id"
40 40 @rows = Enumeration::get_values('IPRI')
41 41 @data = issues_by_priority
42 42 @report_title = l(:field_priority)
43 43 render :template => "reports/issue_report_details"
44 44 when "category"
45 45 @field = "category_id"
46 46 @rows = @project.issue_categories
47 47 @data = issues_by_category
48 48 @report_title = l(:field_category)
49 49 render :template => "reports/issue_report_details"
50 50 when "assigned_to"
51 51 @field = "assigned_to_id"
52 52 @rows = @project.members.collect { |m| m.user }
53 53 @data = issues_by_assigned_to
54 54 @report_title = l(:field_assigned_to)
55 55 render :template => "reports/issue_report_details"
56 56 when "author"
57 57 @field = "author_id"
58 58 @rows = @project.members.collect { |m| m.user }
59 59 @data = issues_by_author
60 60 @report_title = l(:field_author)
61 61 render :template => "reports/issue_report_details"
62 62 when "subproject"
63 63 @field = "project_id"
64 @rows = @project.active_children
64 @rows = @project.descendants.active
65 65 @data = issues_by_subproject
66 66 @report_title = l(:field_subproject)
67 67 render :template => "reports/issue_report_details"
68 68 else
69 69 @trackers = @project.trackers
70 70 @versions = @project.versions.sort
71 71 @priorities = Enumeration::get_values('IPRI')
72 72 @categories = @project.issue_categories
73 73 @assignees = @project.members.collect { |m| m.user }
74 74 @authors = @project.members.collect { |m| m.user }
75 @subprojects = @project.active_children
75 @subprojects = @project.descendants.active
76 76 issues_by_tracker
77 77 issues_by_version
78 78 issues_by_priority
79 79 issues_by_category
80 80 issues_by_assigned_to
81 81 issues_by_author
82 82 issues_by_subproject
83 83
84 84 render :template => "reports/issue_report"
85 85 end
86 86 end
87 87
88 88 def delays
89 89 @trackers = Tracker.find(:all)
90 90 if request.get?
91 91 @selected_tracker_ids = @trackers.collect {|t| t.id.to_s }
92 92 else
93 93 @selected_tracker_ids = params[:tracker_ids].collect { |id| id.to_i.to_s } if params[:tracker_ids] and params[:tracker_ids].is_a? Array
94 94 end
95 95 @selected_tracker_ids ||= []
96 96 @raw =
97 97 ActiveRecord::Base.connection.select_all("SELECT datediff( a.created_on, b.created_on ) as delay, count(a.id) as total
98 98 FROM issue_histories a, issue_histories b, issues i
99 99 WHERE a.status_id =5
100 100 AND a.issue_id = b.issue_id
101 101 AND a.issue_id = i.id
102 102 AND i.tracker_id in (#{@selected_tracker_ids.join(',')})
103 103 AND b.id = (
104 104 SELECT min( c.id )
105 105 FROM issue_histories c
106 106 WHERE b.issue_id = c.issue_id )
107 107 GROUP BY delay") unless @selected_tracker_ids.empty?
108 108 @raw ||=[]
109 109
110 110 @x_from = 0
111 111 @x_to = 0
112 112 @y_from = 0
113 113 @y_to = 0
114 114 @sum_total = 0
115 115 @sum_delay = 0
116 116 @raw.each do |r|
117 117 @x_to = [r['delay'].to_i, @x_to].max
118 118 @y_to = [r['total'].to_i, @y_to].max
119 119 @sum_total = @sum_total + r['total'].to_i
120 120 @sum_delay = @sum_delay + r['total'].to_i * r['delay'].to_i
121 121 end
122 122 end
123 123
124 124 private
125 125 # Find project of id params[:id]
126 126 def find_project
127 127 @project = Project.find(params[:id])
128 128 rescue ActiveRecord::RecordNotFound
129 129 render_404
130 130 end
131 131
132 132 def issues_by_tracker
133 133 @issues_by_tracker ||=
134 134 ActiveRecord::Base.connection.select_all("select s.id as status_id,
135 135 s.is_closed as closed,
136 136 t.id as tracker_id,
137 137 count(i.id) as total
138 138 from
139 139 #{Issue.table_name} i, #{IssueStatus.table_name} s, #{Tracker.table_name} t
140 140 where
141 141 i.status_id=s.id
142 142 and i.tracker_id=t.id
143 143 and i.project_id=#{@project.id}
144 144 group by s.id, s.is_closed, t.id")
145 145 end
146 146
147 147 def issues_by_version
148 148 @issues_by_version ||=
149 149 ActiveRecord::Base.connection.select_all("select s.id as status_id,
150 150 s.is_closed as closed,
151 151 v.id as fixed_version_id,
152 152 count(i.id) as total
153 153 from
154 154 #{Issue.table_name} i, #{IssueStatus.table_name} s, #{Version.table_name} v
155 155 where
156 156 i.status_id=s.id
157 157 and i.fixed_version_id=v.id
158 158 and i.project_id=#{@project.id}
159 159 group by s.id, s.is_closed, v.id")
160 160 end
161 161
162 162 def issues_by_priority
163 163 @issues_by_priority ||=
164 164 ActiveRecord::Base.connection.select_all("select s.id as status_id,
165 165 s.is_closed as closed,
166 166 p.id as priority_id,
167 167 count(i.id) as total
168 168 from
169 169 #{Issue.table_name} i, #{IssueStatus.table_name} s, #{Enumeration.table_name} p
170 170 where
171 171 i.status_id=s.id
172 172 and i.priority_id=p.id
173 173 and i.project_id=#{@project.id}
174 174 group by s.id, s.is_closed, p.id")
175 175 end
176 176
177 177 def issues_by_category
178 178 @issues_by_category ||=
179 179 ActiveRecord::Base.connection.select_all("select s.id as status_id,
180 180 s.is_closed as closed,
181 181 c.id as category_id,
182 182 count(i.id) as total
183 183 from
184 184 #{Issue.table_name} i, #{IssueStatus.table_name} s, #{IssueCategory.table_name} c
185 185 where
186 186 i.status_id=s.id
187 187 and i.category_id=c.id
188 188 and i.project_id=#{@project.id}
189 189 group by s.id, s.is_closed, c.id")
190 190 end
191 191
192 192 def issues_by_assigned_to
193 193 @issues_by_assigned_to ||=
194 194 ActiveRecord::Base.connection.select_all("select s.id as status_id,
195 195 s.is_closed as closed,
196 196 a.id as assigned_to_id,
197 197 count(i.id) as total
198 198 from
199 199 #{Issue.table_name} i, #{IssueStatus.table_name} s, #{User.table_name} a
200 200 where
201 201 i.status_id=s.id
202 202 and i.assigned_to_id=a.id
203 203 and i.project_id=#{@project.id}
204 204 group by s.id, s.is_closed, a.id")
205 205 end
206 206
207 207 def issues_by_author
208 208 @issues_by_author ||=
209 209 ActiveRecord::Base.connection.select_all("select s.id as status_id,
210 210 s.is_closed as closed,
211 211 a.id as author_id,
212 212 count(i.id) as total
213 213 from
214 214 #{Issue.table_name} i, #{IssueStatus.table_name} s, #{User.table_name} a
215 215 where
216 216 i.status_id=s.id
217 217 and i.author_id=a.id
218 218 and i.project_id=#{@project.id}
219 219 group by s.id, s.is_closed, a.id")
220 220 end
221 221
222 222 def issues_by_subproject
223 223 @issues_by_subproject ||=
224 224 ActiveRecord::Base.connection.select_all("select s.id as status_id,
225 225 s.is_closed as closed,
226 226 i.project_id as project_id,
227 227 count(i.id) as total
228 228 from
229 229 #{Issue.table_name} i, #{IssueStatus.table_name} s
230 230 where
231 231 i.status_id=s.id
232 and i.project_id IN (#{@project.active_children.collect{|p| p.id}.join(',')})
233 group by s.id, s.is_closed, i.project_id") if @project.active_children.any?
232 and i.project_id IN (#{@project.descendants.active.collect{|p| p.id}.join(',')})
233 group by s.id, s.is_closed, i.project_id") if @project.descendants.active.any?
234 234 @issues_by_subproject ||= []
235 235 end
236 236 end
@@ -1,116 +1,116
1 1 # redMine - project management software
2 2 # Copyright (C) 2006 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class SearchController < ApplicationController
19 19 before_filter :find_optional_project
20 20
21 21 helper :messages
22 22 include MessagesHelper
23 23
24 24 def index
25 25 @question = params[:q] || ""
26 26 @question.strip!
27 27 @all_words = params[:all_words] || (params[:submit] ? false : true)
28 28 @titles_only = !params[:titles_only].nil?
29 29
30 30 projects_to_search =
31 31 case params[:scope]
32 32 when 'all'
33 33 nil
34 34 when 'my_projects'
35 35 User.current.memberships.collect(&:project)
36 36 when 'subprojects'
37 @project ? ([ @project ] + @project.active_children) : nil
37 @project ? (@project.self_and_descendants.active) : nil
38 38 else
39 39 @project
40 40 end
41 41
42 42 offset = nil
43 43 begin; offset = params[:offset].to_time if params[:offset]; rescue; end
44 44
45 45 # quick jump to an issue
46 46 if @question.match(/^#?(\d+)$/) && Issue.find_by_id($1, :include => :project, :conditions => Project.visible_by(User.current))
47 47 redirect_to :controller => "issues", :action => "show", :id => $1
48 48 return
49 49 end
50 50
51 51 @object_types = %w(issues news documents changesets wiki_pages messages projects)
52 52 if projects_to_search.is_a? Project
53 53 # don't search projects
54 54 @object_types.delete('projects')
55 55 # only show what the user is allowed to view
56 56 @object_types = @object_types.select {|o| User.current.allowed_to?("view_#{o}".to_sym, projects_to_search)}
57 57 end
58 58
59 59 @scope = @object_types.select {|t| params[t]}
60 60 @scope = @object_types if @scope.empty?
61 61
62 62 # extract tokens from the question
63 63 # eg. hello "bye bye" => ["hello", "bye bye"]
64 64 @tokens = @question.scan(%r{((\s|^)"[\s\w]+"(\s|$)|\S+)}).collect {|m| m.first.gsub(%r{(^\s*"\s*|\s*"\s*$)}, '')}
65 65 # tokens must be at least 3 character long
66 66 @tokens = @tokens.uniq.select {|w| w.length > 2 }
67 67
68 68 if !@tokens.empty?
69 69 # no more than 5 tokens to search for
70 70 @tokens.slice! 5..-1 if @tokens.size > 5
71 71 # strings used in sql like statement
72 72 like_tokens = @tokens.collect {|w| "%#{w.downcase}%"}
73 73
74 74 @results = []
75 75 @results_by_type = Hash.new {|h,k| h[k] = 0}
76 76
77 77 limit = 10
78 78 @scope.each do |s|
79 79 r, c = s.singularize.camelcase.constantize.search(like_tokens, projects_to_search,
80 80 :all_words => @all_words,
81 81 :titles_only => @titles_only,
82 82 :limit => (limit+1),
83 83 :offset => offset,
84 84 :before => params[:previous].nil?)
85 85 @results += r
86 86 @results_by_type[s] += c
87 87 end
88 88 @results = @results.sort {|a,b| b.event_datetime <=> a.event_datetime}
89 89 if params[:previous].nil?
90 90 @pagination_previous_date = @results[0].event_datetime if offset && @results[0]
91 91 if @results.size > limit
92 92 @pagination_next_date = @results[limit-1].event_datetime
93 93 @results = @results[0, limit]
94 94 end
95 95 else
96 96 @pagination_next_date = @results[-1].event_datetime if offset && @results[-1]
97 97 if @results.size > limit
98 98 @pagination_previous_date = @results[-(limit)].event_datetime
99 99 @results = @results[-(limit), limit]
100 100 end
101 101 end
102 102 else
103 103 @question = ""
104 104 end
105 105 render :layout => false if request.xhr?
106 106 end
107 107
108 108 private
109 109 def find_optional_project
110 110 return true unless params[:id]
111 111 @project = Project.find(params[:id])
112 112 check_project_privacy
113 113 rescue ActiveRecord::RecordNotFound
114 114 render_404
115 115 end
116 116 end
@@ -1,104 +1,104
1 1 # redMine - project management software
2 2 # Copyright (C) 2006-2007 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class UsersController < ApplicationController
19 19 before_filter :require_admin
20 20
21 21 helper :sort
22 22 include SortHelper
23 23 helper :custom_fields
24 24 include CustomFieldsHelper
25 25
26 26 def index
27 27 list
28 28 render :action => 'list' unless request.xhr?
29 29 end
30 30
31 31 def list
32 32 sort_init 'login', 'asc'
33 33 sort_update %w(login firstname lastname mail admin created_on last_login_on)
34 34
35 35 @status = params[:status] ? params[:status].to_i : 1
36 36 c = ARCondition.new(@status == 0 ? "status <> 0" : ["status = ?", @status])
37 37
38 38 unless params[:name].blank?
39 39 name = "%#{params[:name].strip.downcase}%"
40 40 c << ["LOWER(login) LIKE ? OR LOWER(firstname) LIKE ? OR LOWER(lastname) LIKE ?", name, name, name]
41 41 end
42 42
43 43 @user_count = User.count(:conditions => c.conditions)
44 44 @user_pages = Paginator.new self, @user_count,
45 45 per_page_option,
46 46 params['page']
47 47 @users = User.find :all,:order => sort_clause,
48 48 :conditions => c.conditions,
49 49 :limit => @user_pages.items_per_page,
50 50 :offset => @user_pages.current.offset
51 51
52 52 render :action => "list", :layout => false if request.xhr?
53 53 end
54 54
55 55 def add
56 56 if request.get?
57 57 @user = User.new(:language => Setting.default_language)
58 58 else
59 59 @user = User.new(params[:user])
60 60 @user.admin = params[:user][:admin] || false
61 61 @user.login = params[:user][:login]
62 62 @user.password, @user.password_confirmation = params[:password], params[:password_confirmation] unless @user.auth_source_id
63 63 if @user.save
64 64 Mailer.deliver_account_information(@user, params[:password]) if params[:send_information]
65 65 flash[:notice] = l(:notice_successful_create)
66 66 redirect_to :action => 'list'
67 67 end
68 68 end
69 69 @auth_sources = AuthSource.find(:all)
70 70 end
71 71
72 72 def edit
73 73 @user = User.find(params[:id])
74 74 if request.post?
75 75 @user.admin = params[:user][:admin] if params[:user][:admin]
76 76 @user.login = params[:user][:login] if params[:user][:login]
77 77 @user.password, @user.password_confirmation = params[:password], params[:password_confirmation] unless params[:password].nil? or params[:password].empty? or @user.auth_source_id
78 78 if @user.update_attributes(params[:user])
79 79 flash[:notice] = l(:notice_successful_update)
80 80 # Give a string to redirect_to otherwise it would use status param as the response code
81 81 redirect_to(url_for(:action => 'list', :status => params[:status], :page => params[:page]))
82 82 end
83 83 end
84 84 @auth_sources = AuthSource.find(:all)
85 85 @roles = Role.find_all_givable
86 @projects = Project.find(:all, :order => 'name', :conditions => "status=#{Project::STATUS_ACTIVE}") - @user.projects
86 @projects = Project.active.find(:all, :order => 'lft')
87 87 @membership ||= Member.new
88 88 @memberships = @user.memberships
89 89 end
90 90
91 91 def edit_membership
92 92 @user = User.find(params[:id])
93 93 @membership = params[:membership_id] ? Member.find(params[:membership_id]) : Member.new(:user => @user)
94 94 @membership.attributes = params[:membership]
95 95 @membership.save if request.post?
96 96 redirect_to :action => 'edit', :id => @user, :tab => 'memberships'
97 97 end
98 98
99 99 def destroy_membership
100 100 @user = User.find(params[:id])
101 101 Member.find(params[:membership_id]).destroy if request.post?
102 102 redirect_to :action => 'edit', :id => @user, :tab => 'memberships'
103 103 end
104 104 end
@@ -1,23 +1,31
1 1 # redMine - project management software
2 2 # Copyright (C) 2006 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 module AdminHelper
19 19 def project_status_options_for_select(selected)
20 20 options_for_select([[l(:label_all), ''],
21 21 [l(:status_active), 1]], selected)
22 22 end
23
24 def css_project_classes(project)
25 s = 'project'
26 s << ' root' if project.root?
27 s << ' child' if project.child?
28 s << (project.leaf? ? ' leaf' : ' parent')
29 s
30 end
23 31 end
@@ -1,628 +1,667
1 1 # redMine - project management software
2 2 # Copyright (C) 2006-2007 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 require 'coderay'
19 19 require 'coderay/helpers/file_type'
20 20 require 'forwardable'
21 21 require 'cgi'
22 22
23 23 module ApplicationHelper
24 24 include Redmine::WikiFormatting::Macros::Definitions
25 25 include GravatarHelper::PublicMethods
26 26
27 27 extend Forwardable
28 28 def_delegators :wiki_helper, :wikitoolbar_for, :heads_for_wiki_formatter
29 29
30 30 def current_role
31 31 @current_role ||= User.current.role_for_project(@project)
32 32 end
33 33
34 34 # Return true if user is authorized for controller/action, otherwise false
35 35 def authorize_for(controller, action)
36 36 User.current.allowed_to?({:controller => controller, :action => action}, @project)
37 37 end
38 38
39 39 # Display a link if user is authorized
40 40 def link_to_if_authorized(name, options = {}, html_options = nil, *parameters_for_method_reference)
41 41 link_to(name, options, html_options, *parameters_for_method_reference) if authorize_for(options[:controller] || params[:controller], options[:action])
42 42 end
43 43
44 44 # Display a link to remote if user is authorized
45 45 def link_to_remote_if_authorized(name, options = {}, html_options = nil)
46 46 url = options[:url] || {}
47 47 link_to_remote(name, options, html_options) if authorize_for(url[:controller] || params[:controller], url[:action])
48 48 end
49 49
50 50 # Display a link to user's account page
51 51 def link_to_user(user, options={})
52 52 (user && !user.anonymous?) ? link_to(user.name(options[:format]), :controller => 'account', :action => 'show', :id => user) : 'Anonymous'
53 53 end
54 54
55 55 def link_to_issue(issue, options={})
56 56 options[:class] ||= ''
57 57 options[:class] << ' issue'
58 58 options[:class] << ' closed' if issue.closed?
59 59 link_to "#{issue.tracker.name} ##{issue.id}", {:controller => "issues", :action => "show", :id => issue}, options
60 60 end
61 61
62 62 # Generates a link to an attachment.
63 63 # Options:
64 64 # * :text - Link text (default to attachment filename)
65 65 # * :download - Force download (default: false)
66 66 def link_to_attachment(attachment, options={})
67 67 text = options.delete(:text) || attachment.filename
68 68 action = options.delete(:download) ? 'download' : 'show'
69 69
70 70 link_to(h(text), {:controller => 'attachments', :action => action, :id => attachment, :filename => attachment.filename }, options)
71 71 end
72 72
73 73 def toggle_link(name, id, options={})
74 74 onclick = "Element.toggle('#{id}'); "
75 75 onclick << (options[:focus] ? "Form.Element.focus('#{options[:focus]}'); " : "this.blur(); ")
76 76 onclick << "return false;"
77 77 link_to(name, "#", :onclick => onclick)
78 78 end
79 79
80 80 def image_to_function(name, function, html_options = {})
81 81 html_options.symbolize_keys!
82 82 tag(:input, html_options.merge({
83 83 :type => "image", :src => image_path(name),
84 84 :onclick => (html_options[:onclick] ? "#{html_options[:onclick]}; " : "") + "#{function};"
85 85 }))
86 86 end
87 87
88 88 def prompt_to_remote(name, text, param, url, html_options = {})
89 89 html_options[:onclick] = "promptToRemote('#{text}', '#{param}', '#{url_for(url)}'); return false;"
90 90 link_to name, {}, html_options
91 91 end
92 92
93 93 def format_date(date)
94 94 return nil unless date
95 95 # "Setting.date_format.size < 2" is a temporary fix (content of date_format setting changed)
96 96 @date_format ||= (Setting.date_format.blank? || Setting.date_format.size < 2 ? l(:general_fmt_date) : Setting.date_format)
97 97 date.strftime(@date_format)
98 98 end
99 99
100 100 def format_time(time, include_date = true)
101 101 return nil unless time
102 102 time = time.to_time if time.is_a?(String)
103 103 zone = User.current.time_zone
104 104 local = zone ? time.in_time_zone(zone) : (time.utc? ? time.localtime : time)
105 105 @date_format ||= (Setting.date_format.blank? || Setting.date_format.size < 2 ? l(:general_fmt_date) : Setting.date_format)
106 106 @time_format ||= (Setting.time_format.blank? ? l(:general_fmt_time) : Setting.time_format)
107 107 include_date ? local.strftime("#{@date_format} #{@time_format}") : local.strftime(@time_format)
108 108 end
109 109
110 110 def format_activity_title(text)
111 111 h(truncate_single_line(text, 100))
112 112 end
113 113
114 114 def format_activity_day(date)
115 115 date == Date.today ? l(:label_today).titleize : format_date(date)
116 116 end
117 117
118 118 def format_activity_description(text)
119 119 h(truncate(text.to_s, 250).gsub(%r{<(pre|code)>.*$}m, '...'))
120 120 end
121 121
122 122 def distance_of_date_in_words(from_date, to_date = 0)
123 123 from_date = from_date.to_date if from_date.respond_to?(:to_date)
124 124 to_date = to_date.to_date if to_date.respond_to?(:to_date)
125 125 distance_in_days = (to_date - from_date).abs
126 126 lwr(:actionview_datehelper_time_in_words_day, distance_in_days)
127 127 end
128 128
129 129 def due_date_distance_in_words(date)
130 130 if date
131 131 l((date < Date.today ? :label_roadmap_overdue : :label_roadmap_due_in), distance_of_date_in_words(Date.today, date))
132 132 end
133 133 end
134 134
135 135 def render_page_hierarchy(pages, node=nil)
136 136 content = ''
137 137 if pages[node]
138 138 content << "<ul class=\"pages-hierarchy\">\n"
139 139 pages[node].each do |page|
140 140 content << "<li>"
141 141 content << link_to(h(page.pretty_title), {:controller => 'wiki', :action => 'index', :id => page.project, :page => page.title},
142 142 :title => (page.respond_to?(:updated_on) ? l(:label_updated_time, distance_of_time_in_words(Time.now, page.updated_on)) : nil))
143 143 content << "\n" + render_page_hierarchy(pages, page.id) if pages[page.id]
144 144 content << "</li>\n"
145 145 end
146 146 content << "</ul>\n"
147 147 end
148 148 content
149 149 end
150 150
151 151 # Renders flash messages
152 152 def render_flash_messages
153 153 s = ''
154 154 flash.each do |k,v|
155 155 s << content_tag('div', v, :class => "flash #{k}")
156 156 end
157 157 s
158 158 end
159
160 # Renders the project quick-jump box
161 def render_project_jump_box
162 # Retrieve them now to avoid a COUNT query
163 projects = User.current.projects.all
164 if projects.any?
165 s = '<select onchange="if (this.value != \'\') { window.location = this.value; }">' +
166 "<option selected='selected'>#{ l(:label_jump_to_a_project) }</option>" +
167 '<option disabled="disabled">---</option>'
168 s << project_tree_options_for_select(projects) do |p|
169 { :value => url_for(:controller => 'projects', :action => 'show', :id => p) }
170 end
171 s << '</select>'
172 s
173 end
174 end
175
176 def project_tree_options_for_select(projects, options = {})
177 s = ''
178 project_tree(projects) do |project, level|
179 name_prefix = (level > 0 ? ('&nbsp;' * 2 * level + '&#187; ') : '')
180 tag_options = {:value => project.id, :selected => ((project == options[:selected]) ? 'selected' : nil)}
181 tag_options.merge!(yield(project)) if block_given?
182 s << content_tag('option', name_prefix + h(project), tag_options)
183 end
184 s
185 end
186
187 # Yields the given block for each project with its level in the tree
188 def project_tree(projects, &block)
189 ancestors = []
190 projects.sort_by(&:lft).each do |project|
191 while (ancestors.any? && !project.is_descendant_of?(ancestors.last))
192 ancestors.pop
193 end
194 yield project, ancestors.size
195 ancestors << project
196 end
197 end
159 198
160 199 # Truncates and returns the string as a single line
161 200 def truncate_single_line(string, *args)
162 201 truncate(string, *args).gsub(%r{[\r\n]+}m, ' ')
163 202 end
164 203
165 204 def html_hours(text)
166 205 text.gsub(%r{(\d+)\.(\d+)}, '<span class="hours hours-int">\1</span><span class="hours hours-dec">.\2</span>')
167 206 end
168 207
169 208 def authoring(created, author, options={})
170 209 time_tag = @project.nil? ? content_tag('acronym', distance_of_time_in_words(Time.now, created), :title => format_time(created)) :
171 210 link_to(distance_of_time_in_words(Time.now, created),
172 211 {:controller => 'projects', :action => 'activity', :id => @project, :from => created.to_date},
173 212 :title => format_time(created))
174 213 author_tag = (author.is_a?(User) && !author.anonymous?) ? link_to(h(author), :controller => 'account', :action => 'show', :id => author) : h(author || 'Anonymous')
175 214 l(options[:label] || :label_added_time_by, author_tag, time_tag)
176 215 end
177 216
178 217 def l_or_humanize(s, options={})
179 218 k = "#{options[:prefix]}#{s}".to_sym
180 219 l_has_string?(k) ? l(k) : s.to_s.humanize
181 220 end
182 221
183 222 def day_name(day)
184 223 l(:general_day_names).split(',')[day-1]
185 224 end
186 225
187 226 def month_name(month)
188 227 l(:actionview_datehelper_select_month_names).split(',')[month-1]
189 228 end
190 229
191 230 def syntax_highlight(name, content)
192 231 type = CodeRay::FileType[name]
193 232 type ? CodeRay.scan(content, type).html : h(content)
194 233 end
195 234
196 235 def to_path_param(path)
197 236 path.to_s.split(%r{[/\\]}).select {|p| !p.blank?}
198 237 end
199 238
200 239 def pagination_links_full(paginator, count=nil, options={})
201 240 page_param = options.delete(:page_param) || :page
202 241 url_param = params.dup
203 242 # don't reuse params if filters are present
204 243 url_param.clear if url_param.has_key?(:set_filter)
205 244
206 245 html = ''
207 246 html << link_to_remote(('&#171; ' + l(:label_previous)),
208 247 {:update => 'content',
209 248 :url => url_param.merge(page_param => paginator.current.previous),
210 249 :complete => 'window.scrollTo(0,0)'},
211 250 {:href => url_for(:params => url_param.merge(page_param => paginator.current.previous))}) + ' ' if paginator.current.previous
212 251
213 252 html << (pagination_links_each(paginator, options) do |n|
214 253 link_to_remote(n.to_s,
215 254 {:url => {:params => url_param.merge(page_param => n)},
216 255 :update => 'content',
217 256 :complete => 'window.scrollTo(0,0)'},
218 257 {:href => url_for(:params => url_param.merge(page_param => n))})
219 258 end || '')
220 259
221 260 html << ' ' + link_to_remote((l(:label_next) + ' &#187;'),
222 261 {:update => 'content',
223 262 :url => url_param.merge(page_param => paginator.current.next),
224 263 :complete => 'window.scrollTo(0,0)'},
225 264 {:href => url_for(:params => url_param.merge(page_param => paginator.current.next))}) if paginator.current.next
226 265
227 266 unless count.nil?
228 267 html << [" (#{paginator.current.first_item}-#{paginator.current.last_item}/#{count})", per_page_links(paginator.items_per_page)].compact.join(' | ')
229 268 end
230 269
231 270 html
232 271 end
233 272
234 273 def per_page_links(selected=nil)
235 274 url_param = params.dup
236 275 url_param.clear if url_param.has_key?(:set_filter)
237 276
238 277 links = Setting.per_page_options_array.collect do |n|
239 278 n == selected ? n : link_to_remote(n, {:update => "content", :url => params.dup.merge(:per_page => n)},
240 279 {:href => url_for(url_param.merge(:per_page => n))})
241 280 end
242 281 links.size > 1 ? l(:label_display_per_page, links.join(', ')) : nil
243 282 end
244 283
245 284 def breadcrumb(*args)
246 285 elements = args.flatten
247 286 elements.any? ? content_tag('p', args.join(' &#187; ') + ' &#187; ', :class => 'breadcrumb') : nil
248 287 end
249 288
250 289 def html_title(*args)
251 290 if args.empty?
252 291 title = []
253 292 title << @project.name if @project
254 293 title += @html_title if @html_title
255 294 title << Setting.app_title
256 295 title.compact.join(' - ')
257 296 else
258 297 @html_title ||= []
259 298 @html_title += args
260 299 end
261 300 end
262 301
263 302 def accesskey(s)
264 303 Redmine::AccessKeys.key_for s
265 304 end
266 305
267 306 # Formats text according to system settings.
268 307 # 2 ways to call this method:
269 308 # * with a String: textilizable(text, options)
270 309 # * with an object and one of its attribute: textilizable(issue, :description, options)
271 310 def textilizable(*args)
272 311 options = args.last.is_a?(Hash) ? args.pop : {}
273 312 case args.size
274 313 when 1
275 314 obj = options[:object]
276 315 text = args.shift
277 316 when 2
278 317 obj = args.shift
279 318 text = obj.send(args.shift).to_s
280 319 else
281 320 raise ArgumentError, 'invalid arguments to textilizable'
282 321 end
283 322 return '' if text.blank?
284 323
285 324 only_path = options.delete(:only_path) == false ? false : true
286 325
287 326 # when using an image link, try to use an attachment, if possible
288 327 attachments = options[:attachments] || (obj && obj.respond_to?(:attachments) ? obj.attachments : nil)
289 328
290 329 if attachments
291 330 attachments = attachments.sort_by(&:created_on).reverse
292 331 text = text.gsub(/!((\<|\=|\>)?(\([^\)]+\))?(\[[^\]]+\])?(\{[^\}]+\})?)(\S+\.(bmp|gif|jpg|jpeg|png))!/i) do |m|
293 332 style = $1
294 333 filename = $6
295 334 rf = Regexp.new(Regexp.escape(filename), Regexp::IGNORECASE)
296 335 # search for the picture in attachments
297 336 if found = attachments.detect { |att| att.filename =~ rf }
298 337 image_url = url_for :only_path => only_path, :controller => 'attachments', :action => 'download', :id => found
299 338 desc = found.description.to_s.gsub(/^([^\(\)]*).*$/, "\\1")
300 339 alt = desc.blank? ? nil : "(#{desc})"
301 340 "!#{style}#{image_url}#{alt}!"
302 341 else
303 342 "!#{style}#{filename}!"
304 343 end
305 344 end
306 345 end
307 346
308 347 text = Redmine::WikiFormatting.to_html(Setting.text_formatting, text) { |macro, args| exec_macro(macro, obj, args) }
309 348
310 349 # different methods for formatting wiki links
311 350 case options[:wiki_links]
312 351 when :local
313 352 # used for local links to html files
314 353 format_wiki_link = Proc.new {|project, title, anchor| "#{title}.html" }
315 354 when :anchor
316 355 # used for single-file wiki export
317 356 format_wiki_link = Proc.new {|project, title, anchor| "##{title}" }
318 357 else
319 358 format_wiki_link = Proc.new {|project, title, anchor| url_for(:only_path => only_path, :controller => 'wiki', :action => 'index', :id => project, :page => title, :anchor => anchor) }
320 359 end
321 360
322 361 project = options[:project] || @project || (obj && obj.respond_to?(:project) ? obj.project : nil)
323 362
324 363 # Wiki links
325 364 #
326 365 # Examples:
327 366 # [[mypage]]
328 367 # [[mypage|mytext]]
329 368 # wiki links can refer other project wikis, using project name or identifier:
330 369 # [[project:]] -> wiki starting page
331 370 # [[project:|mytext]]
332 371 # [[project:mypage]]
333 372 # [[project:mypage|mytext]]
334 373 text = text.gsub(/(!)?(\[\[([^\]\n\|]+)(\|([^\]\n\|]+))?\]\])/) do |m|
335 374 link_project = project
336 375 esc, all, page, title = $1, $2, $3, $5
337 376 if esc.nil?
338 377 if page =~ /^([^\:]+)\:(.*)$/
339 378 link_project = Project.find_by_name($1) || Project.find_by_identifier($1)
340 379 page = $2
341 380 title ||= $1 if page.blank?
342 381 end
343 382
344 383 if link_project && link_project.wiki
345 384 # extract anchor
346 385 anchor = nil
347 386 if page =~ /^(.+?)\#(.+)$/
348 387 page, anchor = $1, $2
349 388 end
350 389 # check if page exists
351 390 wiki_page = link_project.wiki.find_page(page)
352 391 link_to((title || page), format_wiki_link.call(link_project, Wiki.titleize(page), anchor),
353 392 :class => ('wiki-page' + (wiki_page ? '' : ' new')))
354 393 else
355 394 # project or wiki doesn't exist
356 395 title || page
357 396 end
358 397 else
359 398 all
360 399 end
361 400 end
362 401
363 402 # Redmine links
364 403 #
365 404 # Examples:
366 405 # Issues:
367 406 # #52 -> Link to issue #52
368 407 # Changesets:
369 408 # r52 -> Link to revision 52
370 409 # commit:a85130f -> Link to scmid starting with a85130f
371 410 # Documents:
372 411 # document#17 -> Link to document with id 17
373 412 # document:Greetings -> Link to the document with title "Greetings"
374 413 # document:"Some document" -> Link to the document with title "Some document"
375 414 # Versions:
376 415 # version#3 -> Link to version with id 3
377 416 # version:1.0.0 -> Link to version named "1.0.0"
378 417 # version:"1.0 beta 2" -> Link to version named "1.0 beta 2"
379 418 # Attachments:
380 419 # attachment:file.zip -> Link to the attachment of the current object named file.zip
381 420 # Source files:
382 421 # source:some/file -> Link to the file located at /some/file in the project's repository
383 422 # source:some/file@52 -> Link to the file's revision 52
384 423 # source:some/file#L120 -> Link to line 120 of the file
385 424 # source:some/file@52#L120 -> Link to line 120 of the file's revision 52
386 425 # export:some/file -> Force the download of the file
387 426 # Forum messages:
388 427 # message#1218 -> Link to message with id 1218
389 428 text = text.gsub(%r{([\s\(,\-\>]|^)(!)?(attachment|document|version|commit|source|export|message)?((#|r)(\d+)|(:)([^"\s<>][^\s<>]*?|"[^"]+?"))(?=(?=[[:punct:]]\W)|\s|<|$)}) do |m|
390 429 leading, esc, prefix, sep, oid = $1, $2, $3, $5 || $7, $6 || $8
391 430 link = nil
392 431 if esc.nil?
393 432 if prefix.nil? && sep == 'r'
394 433 if project && (changeset = project.changesets.find_by_revision(oid))
395 434 link = link_to("r#{oid}", {:only_path => only_path, :controller => 'repositories', :action => 'revision', :id => project, :rev => oid},
396 435 :class => 'changeset',
397 436 :title => truncate_single_line(changeset.comments, 100))
398 437 end
399 438 elsif sep == '#'
400 439 oid = oid.to_i
401 440 case prefix
402 441 when nil
403 442 if issue = Issue.find_by_id(oid, :include => [:project, :status], :conditions => Project.visible_by(User.current))
404 443 link = link_to("##{oid}", {:only_path => only_path, :controller => 'issues', :action => 'show', :id => oid},
405 444 :class => (issue.closed? ? 'issue closed' : 'issue'),
406 445 :title => "#{truncate(issue.subject, 100)} (#{issue.status.name})")
407 446 link = content_tag('del', link) if issue.closed?
408 447 end
409 448 when 'document'
410 449 if document = Document.find_by_id(oid, :include => [:project], :conditions => Project.visible_by(User.current))
411 450 link = link_to h(document.title), {:only_path => only_path, :controller => 'documents', :action => 'show', :id => document},
412 451 :class => 'document'
413 452 end
414 453 when 'version'
415 454 if version = Version.find_by_id(oid, :include => [:project], :conditions => Project.visible_by(User.current))
416 455 link = link_to h(version.name), {:only_path => only_path, :controller => 'versions', :action => 'show', :id => version},
417 456 :class => 'version'
418 457 end
419 458 when 'message'
420 459 if message = Message.find_by_id(oid, :include => [:parent, {:board => :project}], :conditions => Project.visible_by(User.current))
421 460 link = link_to h(truncate(message.subject, 60)), {:only_path => only_path,
422 461 :controller => 'messages',
423 462 :action => 'show',
424 463 :board_id => message.board,
425 464 :id => message.root,
426 465 :anchor => (message.parent ? "message-#{message.id}" : nil)},
427 466 :class => 'message'
428 467 end
429 468 end
430 469 elsif sep == ':'
431 470 # removes the double quotes if any
432 471 name = oid.gsub(%r{^"(.*)"$}, "\\1")
433 472 case prefix
434 473 when 'document'
435 474 if project && document = project.documents.find_by_title(name)
436 475 link = link_to h(document.title), {:only_path => only_path, :controller => 'documents', :action => 'show', :id => document},
437 476 :class => 'document'
438 477 end
439 478 when 'version'
440 479 if project && version = project.versions.find_by_name(name)
441 480 link = link_to h(version.name), {:only_path => only_path, :controller => 'versions', :action => 'show', :id => version},
442 481 :class => 'version'
443 482 end
444 483 when 'commit'
445 484 if project && (changeset = project.changesets.find(:first, :conditions => ["scmid LIKE ?", "#{name}%"]))
446 485 link = link_to h("#{name}"), {:only_path => only_path, :controller => 'repositories', :action => 'revision', :id => project, :rev => changeset.revision},
447 486 :class => 'changeset',
448 487 :title => truncate_single_line(changeset.comments, 100)
449 488 end
450 489 when 'source', 'export'
451 490 if project && project.repository
452 491 name =~ %r{^[/\\]*(.*?)(@([0-9a-f]+))?(#(L\d+))?$}
453 492 path, rev, anchor = $1, $3, $5
454 493 link = link_to h("#{prefix}:#{name}"), {:controller => 'repositories', :action => 'entry', :id => project,
455 494 :path => to_path_param(path),
456 495 :rev => rev,
457 496 :anchor => anchor,
458 497 :format => (prefix == 'export' ? 'raw' : nil)},
459 498 :class => (prefix == 'export' ? 'source download' : 'source')
460 499 end
461 500 when 'attachment'
462 501 if attachments && attachment = attachments.detect {|a| a.filename == name }
463 502 link = link_to h(attachment.filename), {:only_path => only_path, :controller => 'attachments', :action => 'download', :id => attachment},
464 503 :class => 'attachment'
465 504 end
466 505 end
467 506 end
468 507 end
469 508 leading + (link || "#{prefix}#{sep}#{oid}")
470 509 end
471 510
472 511 text
473 512 end
474 513
475 514 # Same as Rails' simple_format helper without using paragraphs
476 515 def simple_format_without_paragraph(text)
477 516 text.to_s.
478 517 gsub(/\r\n?/, "\n"). # \r\n and \r -> \n
479 518 gsub(/\n\n+/, "<br /><br />"). # 2+ newline -> 2 br
480 519 gsub(/([^\n]\n)(?=[^\n])/, '\1<br />') # 1 newline -> br
481 520 end
482 521
483 522 def error_messages_for(object_name, options = {})
484 523 options = options.symbolize_keys
485 524 object = instance_variable_get("@#{object_name}")
486 525 if object && !object.errors.empty?
487 526 # build full_messages here with controller current language
488 527 full_messages = []
489 528 object.errors.each do |attr, msg|
490 529 next if msg.nil?
491 530 msg = msg.first if msg.is_a? Array
492 531 if attr == "base"
493 532 full_messages << l(msg)
494 533 else
495 534 full_messages << "&#171; " + (l_has_string?("field_" + attr) ? l("field_" + attr) : object.class.human_attribute_name(attr)) + " &#187; " + l(msg) unless attr == "custom_values"
496 535 end
497 536 end
498 537 # retrieve custom values error messages
499 538 if object.errors[:custom_values]
500 539 object.custom_values.each do |v|
501 540 v.errors.each do |attr, msg|
502 541 next if msg.nil?
503 542 msg = msg.first if msg.is_a? Array
504 543 full_messages << "&#171; " + v.custom_field.name + " &#187; " + l(msg)
505 544 end
506 545 end
507 546 end
508 547 content_tag("div",
509 548 content_tag(
510 549 options[:header_tag] || "span", lwr(:gui_validation_error, full_messages.length) + ":"
511 550 ) +
512 551 content_tag("ul", full_messages.collect { |msg| content_tag("li", msg) }),
513 552 "id" => options[:id] || "errorExplanation", "class" => options[:class] || "errorExplanation"
514 553 )
515 554 else
516 555 ""
517 556 end
518 557 end
519 558
520 559 def lang_options_for_select(blank=true)
521 560 (blank ? [["(auto)", ""]] : []) +
522 561 GLoc.valid_languages.collect{|lang| [ ll(lang.to_s, :general_lang_name), lang.to_s]}.sort{|x,y| x.last <=> y.last }
523 562 end
524 563
525 564 def label_tag_for(name, option_tags = nil, options = {})
526 565 label_text = l(("field_"+field.to_s.gsub(/\_id$/, "")).to_sym) + (options.delete(:required) ? @template.content_tag("span", " *", :class => "required"): "")
527 566 content_tag("label", label_text)
528 567 end
529 568
530 569 def labelled_tabular_form_for(name, object, options, &proc)
531 570 options[:html] ||= {}
532 571 options[:html][:class] = 'tabular' unless options[:html].has_key?(:class)
533 572 form_for(name, object, options.merge({ :builder => TabularFormBuilder, :lang => current_language}), &proc)
534 573 end
535 574
536 575 def back_url_hidden_field_tag
537 576 back_url = params[:back_url] || request.env['HTTP_REFERER']
538 577 back_url = CGI.unescape(back_url.to_s)
539 578 hidden_field_tag('back_url', CGI.escape(back_url)) unless back_url.blank?
540 579 end
541 580
542 581 def check_all_links(form_name)
543 582 link_to_function(l(:button_check_all), "checkAll('#{form_name}', true)") +
544 583 " | " +
545 584 link_to_function(l(:button_uncheck_all), "checkAll('#{form_name}', false)")
546 585 end
547 586
548 587 def progress_bar(pcts, options={})
549 588 pcts = [pcts, pcts] unless pcts.is_a?(Array)
550 589 pcts[1] = pcts[1] - pcts[0]
551 590 pcts << (100 - pcts[1] - pcts[0])
552 591 width = options[:width] || '100px;'
553 592 legend = options[:legend] || ''
554 593 content_tag('table',
555 594 content_tag('tr',
556 595 (pcts[0] > 0 ? content_tag('td', '', :style => "width: #{pcts[0].floor}%;", :class => 'closed') : '') +
557 596 (pcts[1] > 0 ? content_tag('td', '', :style => "width: #{pcts[1].floor}%;", :class => 'done') : '') +
558 597 (pcts[2] > 0 ? content_tag('td', '', :style => "width: #{pcts[2].floor}%;", :class => 'todo') : '')
559 598 ), :class => 'progress', :style => "width: #{width};") +
560 599 content_tag('p', legend, :class => 'pourcent')
561 600 end
562 601
563 602 def context_menu_link(name, url, options={})
564 603 options[:class] ||= ''
565 604 if options.delete(:selected)
566 605 options[:class] << ' icon-checked disabled'
567 606 options[:disabled] = true
568 607 end
569 608 if options.delete(:disabled)
570 609 options.delete(:method)
571 610 options.delete(:confirm)
572 611 options.delete(:onclick)
573 612 options[:class] << ' disabled'
574 613 url = '#'
575 614 end
576 615 link_to name, url, options
577 616 end
578 617
579 618 def calendar_for(field_id)
580 619 include_calendar_headers_tags
581 620 image_tag("calendar.png", {:id => "#{field_id}_trigger",:class => "calendar-trigger"}) +
582 621 javascript_tag("Calendar.setup({inputField : '#{field_id}', ifFormat : '%Y-%m-%d', button : '#{field_id}_trigger' });")
583 622 end
584 623
585 624 def include_calendar_headers_tags
586 625 unless @calendar_headers_tags_included
587 626 @calendar_headers_tags_included = true
588 627 content_for :header_tags do
589 628 javascript_include_tag('calendar/calendar') +
590 629 javascript_include_tag("calendar/lang/calendar-#{current_language}.js") +
591 630 javascript_include_tag('calendar/calendar-setup') +
592 631 stylesheet_link_tag('calendar')
593 632 end
594 633 end
595 634 end
596 635
597 636 def content_for(name, content = nil, &block)
598 637 @has_content ||= {}
599 638 @has_content[name] = true
600 639 super(name, content, &block)
601 640 end
602 641
603 642 def has_content?(name)
604 643 (@has_content && @has_content[name]) || false
605 644 end
606 645
607 646 # Returns the avatar image tag for the given +user+ if avatars are enabled
608 647 # +user+ can be a User or a string that will be scanned for an email address (eg. 'joe <joe@foo.bar>')
609 648 def avatar(user, options = { })
610 649 if Setting.gravatar_enabled?
611 650 email = nil
612 651 if user.respond_to?(:mail)
613 652 email = user.mail
614 653 elsif user.to_s =~ %r{<(.+?)>}
615 654 email = $1
616 655 end
617 656 return gravatar(email.to_s.downcase, options) unless email.blank? rescue nil
618 657 end
619 658 end
620 659
621 660 private
622 661
623 662 def wiki_helper
624 663 helper = Redmine::WikiFormatting.helper_for(Setting.text_formatting)
625 664 extend helper
626 665 return self
627 666 end
628 667 end
@@ -1,36 +1,71
1 1 # redMine - project management software
2 2 # Copyright (C) 2006 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 module ProjectsHelper
19 19 def link_to_version(version, options = {})
20 20 return '' unless version && version.is_a?(Version)
21 21 link_to h(version.name), { :controller => 'versions', :action => 'show', :id => version }, options
22 22 end
23 23
24 24 def project_settings_tabs
25 25 tabs = [{:name => 'info', :action => :edit_project, :partial => 'projects/edit', :label => :label_information_plural},
26 26 {:name => 'modules', :action => :select_project_modules, :partial => 'projects/settings/modules', :label => :label_module_plural},
27 27 {:name => 'members', :action => :manage_members, :partial => 'projects/settings/members', :label => :label_member_plural},
28 28 {:name => 'versions', :action => :manage_versions, :partial => 'projects/settings/versions', :label => :label_version_plural},
29 29 {:name => 'categories', :action => :manage_categories, :partial => 'projects/settings/issue_categories', :label => :label_issue_category_plural},
30 30 {:name => 'wiki', :action => :manage_wiki, :partial => 'projects/settings/wiki', :label => :label_wiki},
31 31 {:name => 'repository', :action => :manage_repository, :partial => 'projects/settings/repository', :label => :label_repository},
32 32 {:name => 'boards', :action => :manage_boards, :partial => 'projects/settings/boards', :label => :label_board_plural}
33 33 ]
34 34 tabs.select {|tab| User.current.allowed_to?(tab[:action], @project)}
35 35 end
36
37 def parent_project_select_tag(project)
38 options = '<option></option>' + project_tree_options_for_select(project.possible_parents, :selected => project.parent)
39 content_tag('select', options, :name => 'project[parent_id]')
40 end
41
42 # Renders a tree of projects as a nested set of unordered lists
43 # The given collection may be a subset of the whole project tree
44 # (eg. some intermediate nodes are private and can not be seen)
45 def render_project_hierarchy(projects)
46 s = ''
47 if projects.any?
48 ancestors = []
49 projects.each do |project|
50 if (ancestors.empty? || project.is_descendant_of?(ancestors.last))
51 s << "<ul class='projects #{ ancestors.empty? ? 'root' : nil}'>\n"
52 else
53 ancestors.pop
54 s << "</li>"
55 while (ancestors.any? && !project.is_descendant_of?(ancestors.last))
56 ancestors.pop
57 s << "</ul></li>\n"
58 end
59 end
60 classes = (ancestors.empty? ? 'root' : 'child')
61 s << "<li class='#{classes}'><div class='#{classes}'>" +
62 link_to(h(project), {:controller => 'projects', :action => 'show', :id => project}, :class => "project #{User.current.member_of?(project) ? 'my-project' : nil}")
63 s << "<div class='wiki description'>#{textilizable(project.short_description, :project => project)}</div>" unless project.description.blank?
64 s << "</div>\n"
65 ancestors << project
66 end
67 s << ("</li></ul>\n" * ancestors.size)
68 end
69 s
70 end
36 71 end
@@ -1,63 +1,63
1 1 # redMine - project management software
2 2 # Copyright (C) 2006-2007 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 module SearchHelper
19 19 def highlight_tokens(text, tokens)
20 20 return text unless text && tokens && !tokens.empty?
21 21 re_tokens = tokens.collect {|t| Regexp.escape(t)}
22 22 regexp = Regexp.new "(#{re_tokens.join('|')})", Regexp::IGNORECASE
23 23 result = ''
24 24 text.split(regexp).each_with_index do |words, i|
25 25 if result.length > 1200
26 26 # maximum length of the preview reached
27 27 result << '...'
28 28 break
29 29 end
30 30 if i.even?
31 31 result << h(words.length > 100 ? "#{words[0..44]} ... #{words[-45..-1]}" : words)
32 32 else
33 33 t = (tokens.index(words.downcase) || 0) % 4
34 34 result << content_tag('span', h(words), :class => "highlight token-#{t}")
35 35 end
36 36 end
37 37 result
38 38 end
39 39
40 40 def type_label(t)
41 41 l("label_#{t.singularize}_plural")
42 42 end
43 43
44 44 def project_select_tag
45 45 options = [[l(:label_project_all), 'all']]
46 46 options << [l(:label_my_projects), 'my_projects'] unless User.current.memberships.empty?
47 options << [l(:label_and_its_subprojects, @project.name), 'subprojects'] unless @project.nil? || @project.active_children.empty?
47 options << [l(:label_and_its_subprojects, @project.name), 'subprojects'] unless @project.nil? || @project.descendants.active.empty?
48 48 options << [@project.name, ''] unless @project.nil?
49 49 select_tag('scope', options_for_select(options, params[:scope].to_s)) if options.size > 1
50 50 end
51 51
52 52 def render_results_by_type(results_by_type)
53 53 links = []
54 54 # Sorts types by results count
55 55 results_by_type.keys.sort {|a, b| results_by_type[b] <=> results_by_type[a]}.each do |t|
56 56 c = results_by_type[t]
57 57 next if c == 0
58 58 text = "#{type_label(t)} (#{c})"
59 59 links << link_to(text, :q => params[:q], :titles_only => params[:title_only], :all_words => params[:all_words], :scope => params[:scope], t => 1)
60 60 end
61 61 ('<ul>' + links.map {|link| content_tag('li', link)}.join(' ') + '</ul>') unless links.empty?
62 62 end
63 63 end
@@ -1,58 +1,53
1 1 # redMine - project management software
2 2 # Copyright (C) 2006 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 module UsersHelper
19 19 def users_status_options_for_select(selected)
20 20 user_count_by_status = User.count(:group => 'status').to_hash
21 21 options_for_select([[l(:label_all), ''],
22 22 ["#{l(:status_active)} (#{user_count_by_status[1].to_i})", 1],
23 23 ["#{l(:status_registered)} (#{user_count_by_status[2].to_i})", 2],
24 24 ["#{l(:status_locked)} (#{user_count_by_status[3].to_i})", 3]], selected)
25 25 end
26 26
27 27 # Options for the new membership projects combo-box
28 def projects_options_for_select(projects)
28 def options_for_membership_project_select(user, projects)
29 29 options = content_tag('option', "--- #{l(:actionview_instancetag_blank_option)} ---")
30 projects_by_root = projects.group_by(&:root)
31 projects_by_root.keys.sort.each do |root|
32 options << content_tag('option', h(root.name), :value => root.id, :disabled => (!projects.include?(root)))
33 projects_by_root[root].sort.each do |project|
34 next if project == root
35 options << content_tag('option', '&#187; ' + h(project.name), :value => project.id)
36 end
30 options << project_tree_options_for_select(projects) do |p|
31 {:disabled => (user.projects.include?(p))}
37 32 end
38 33 options
39 34 end
40 35
41 36 def change_status_link(user)
42 37 url = {:action => 'edit', :id => user, :page => params[:page], :status => params[:status]}
43 38
44 39 if user.locked?
45 40 link_to l(:button_unlock), url.merge(:user => {:status => User::STATUS_ACTIVE}), :method => :post, :class => 'icon icon-unlock'
46 41 elsif user.registered?
47 42 link_to l(:button_activate), url.merge(:user => {:status => User::STATUS_ACTIVE}), :method => :post, :class => 'icon icon-unlock'
48 43 elsif user != User.current
49 44 link_to l(:button_lock), url.merge(:user => {:status => User::STATUS_LOCKED}), :method => :post, :class => 'icon icon-lock'
50 45 end
51 46 end
52 47
53 48 def user_settings_tabs
54 49 tabs = [{:name => 'general', :partial => 'users/general', :label => :label_general},
55 50 {:name => 'memberships', :partial => 'users/memberships', :label => :label_project_plural}
56 51 ]
57 52 end
58 53 end
@@ -1,276 +1,318
1 1 # redMine - project management software
2 2 # Copyright (C) 2006 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class Project < ActiveRecord::Base
19 19 # Project statuses
20 20 STATUS_ACTIVE = 1
21 21 STATUS_ARCHIVED = 9
22 22
23 23 has_many :members, :include => :user, :conditions => "#{User.table_name}.status=#{User::STATUS_ACTIVE}"
24 24 has_many :users, :through => :members
25 25 has_many :enabled_modules, :dependent => :delete_all
26 26 has_and_belongs_to_many :trackers, :order => "#{Tracker.table_name}.position"
27 27 has_many :issues, :dependent => :destroy, :order => "#{Issue.table_name}.created_on DESC", :include => [:status, :tracker]
28 28 has_many :issue_changes, :through => :issues, :source => :journals
29 29 has_many :versions, :dependent => :destroy, :order => "#{Version.table_name}.effective_date DESC, #{Version.table_name}.name DESC"
30 30 has_many :time_entries, :dependent => :delete_all
31 31 has_many :queries, :dependent => :delete_all
32 32 has_many :documents, :dependent => :destroy
33 33 has_many :news, :dependent => :delete_all, :include => :author
34 34 has_many :issue_categories, :dependent => :delete_all, :order => "#{IssueCategory.table_name}.name"
35 35 has_many :boards, :dependent => :destroy, :order => "position ASC"
36 36 has_one :repository, :dependent => :destroy
37 37 has_many :changesets, :through => :repository
38 38 has_one :wiki, :dependent => :destroy
39 39 # Custom field for the project issues
40 40 has_and_belongs_to_many :issue_custom_fields,
41 41 :class_name => 'IssueCustomField',
42 42 :order => "#{CustomField.table_name}.position",
43 43 :join_table => "#{table_name_prefix}custom_fields_projects#{table_name_suffix}",
44 44 :association_foreign_key => 'custom_field_id'
45 45
46 acts_as_tree :order => "name", :counter_cache => true
46 acts_as_nested_set :order => 'name', :dependent => :destroy
47 47 acts_as_attachable :view_permission => :view_files,
48 48 :delete_permission => :manage_files
49 49
50 50 acts_as_customizable
51 51 acts_as_searchable :columns => ['name', 'description'], :project_key => 'id', :permission => nil
52 52 acts_as_event :title => Proc.new {|o| "#{l(:label_project)}: #{o.name}"},
53 53 :url => Proc.new {|o| {:controller => 'projects', :action => 'show', :id => o.id}},
54 54 :author => nil
55 55
56 56 attr_protected :status, :enabled_module_names
57 57
58 58 validates_presence_of :name, :identifier
59 59 validates_uniqueness_of :name, :identifier
60 60 validates_associated :repository, :wiki
61 61 validates_length_of :name, :maximum => 30
62 62 validates_length_of :homepage, :maximum => 255
63 63 validates_length_of :identifier, :in => 2..20
64 64 validates_format_of :identifier, :with => /^[a-z0-9\-]*$/
65 65
66 66 before_destroy :delete_all_members
67 67
68 68 named_scope :has_module, lambda { |mod| { :conditions => ["#{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name=?)", mod.to_s] } }
69 named_scope :active, { :conditions => "#{Project.table_name}.status = #{STATUS_ACTIVE}"}
70 named_scope :visible, lambda { { :conditions => Project.visible_by(User.current) } }
69 71
70 72 def identifier=(identifier)
71 73 super unless identifier_frozen?
72 74 end
73 75
74 76 def identifier_frozen?
75 77 errors[:identifier].nil? && !(new_record? || identifier.blank?)
76 78 end
77 79
78 80 def issues_with_subprojects(include_subprojects=false)
79 81 conditions = nil
80 82 if include_subprojects
81 ids = [id] + child_ids
83 ids = [id] + descendants.collect(&:id)
82 84 conditions = ["#{Project.table_name}.id IN (#{ids.join(',')}) AND #{Project.visible_by}"]
83 85 end
84 86 conditions ||= ["#{Project.table_name}.id = ?", id]
85 87 # Quick and dirty fix for Rails 2 compatibility
86 88 Issue.send(:with_scope, :find => { :conditions => conditions }) do
87 89 Version.send(:with_scope, :find => { :conditions => conditions }) do
88 90 yield
89 91 end
90 92 end
91 93 end
92 94
93 95 # returns latest created projects
94 96 # non public projects will be returned only if user is a member of those
95 97 def self.latest(user=nil, count=5)
96 98 find(:all, :limit => count, :conditions => visible_by(user), :order => "created_on DESC")
97 99 end
98 100
99 101 def self.visible_by(user=nil)
100 102 user ||= User.current
101 103 if user && user.admin?
102 104 return "#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"
103 105 elsif user && user.memberships.any?
104 106 return "#{Project.table_name}.status=#{Project::STATUS_ACTIVE} AND (#{Project.table_name}.is_public = #{connection.quoted_true} or #{Project.table_name}.id IN (#{user.memberships.collect{|m| m.project_id}.join(',')}))"
105 107 else
106 108 return "#{Project.table_name}.status=#{Project::STATUS_ACTIVE} AND #{Project.table_name}.is_public = #{connection.quoted_true}"
107 109 end
108 110 end
109 111
110 112 def self.allowed_to_condition(user, permission, options={})
111 113 statements = []
112 114 base_statement = "#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"
113 115 if perm = Redmine::AccessControl.permission(permission)
114 116 unless perm.project_module.nil?
115 117 # If the permission belongs to a project module, make sure the module is enabled
116 118 base_statement << " AND EXISTS (SELECT em.id FROM #{EnabledModule.table_name} em WHERE em.name='#{perm.project_module}' AND em.project_id=#{Project.table_name}.id)"
117 119 end
118 120 end
119 121 if options[:project]
120 122 project_statement = "#{Project.table_name}.id = #{options[:project].id}"
121 project_statement << " OR #{Project.table_name}.parent_id = #{options[:project].id}" if options[:with_subprojects]
123 project_statement << " OR (#{Project.table_name}.lft > #{options[:project].lft} AND #{Project.table_name}.rgt < #{options[:project].rgt})" if options[:with_subprojects]
122 124 base_statement = "(#{project_statement}) AND (#{base_statement})"
123 125 end
124 126 if user.admin?
125 127 # no restriction
126 128 else
127 129 statements << "1=0"
128 130 if user.logged?
129 131 statements << "#{Project.table_name}.is_public = #{connection.quoted_true}" if Role.non_member.allowed_to?(permission)
130 132 allowed_project_ids = user.memberships.select {|m| m.role.allowed_to?(permission)}.collect {|m| m.project_id}
131 133 statements << "#{Project.table_name}.id IN (#{allowed_project_ids.join(',')})" if allowed_project_ids.any?
132 134 elsif Role.anonymous.allowed_to?(permission)
133 135 # anonymous user allowed on public project
134 136 statements << "#{Project.table_name}.is_public = #{connection.quoted_true}"
135 137 else
136 138 # anonymous user is not authorized
137 139 end
138 140 end
139 141 statements.empty? ? base_statement : "((#{base_statement}) AND (#{statements.join(' OR ')}))"
140 142 end
141 143
142 144 def project_condition(with_subprojects)
143 145 cond = "#{Project.table_name}.id = #{id}"
144 cond = "(#{cond} OR #{Project.table_name}.parent_id = #{id})" if with_subprojects
146 cond = "(#{cond} OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt}))" if with_subprojects
145 147 cond
146 148 end
147 149
148 150 def self.find(*args)
149 151 if args.first && args.first.is_a?(String) && !args.first.match(/^\d*$/)
150 152 project = find_by_identifier(*args)
151 153 raise ActiveRecord::RecordNotFound, "Couldn't find Project with identifier=#{args.first}" if project.nil?
152 154 project
153 155 else
154 156 super
155 157 end
156 158 end
157 159
158 160 def to_param
159 161 # id is used for projects with a numeric identifier (compatibility)
160 162 @to_param ||= (identifier.to_s =~ %r{^\d*$} ? id : identifier)
161 163 end
162 164
163 165 def active?
164 166 self.status == STATUS_ACTIVE
165 167 end
166 168
169 # Archives the project and its descendants recursively
167 170 def archive
168 171 # Archive subprojects if any
169 172 children.each do |subproject|
170 173 subproject.archive
171 174 end
172 175 update_attribute :status, STATUS_ARCHIVED
173 176 end
174 177
178 # Unarchives the project
179 # All its ancestors must be active
175 180 def unarchive
176 return false if parent && !parent.active?
181 return false if ancestors.detect {|a| !a.active?}
177 182 update_attribute :status, STATUS_ACTIVE
178 183 end
179 184
180 def active_children
181 children.select {|child| child.active?}
185 # Returns an array of projects the project can be moved to
186 def possible_parents
187 @possible_parents ||= (Project.active.find(:all) - self_and_descendants)
188 end
189
190 # Sets the parent of the project
191 # Argument can be either a Project, a String, a Fixnum or nil
192 def set_parent!(p)
193 unless p.nil? || p.is_a?(Project)
194 if p.to_s.blank?
195 p = nil
196 else
197 p = Project.find_by_id(p)
198 return false unless p
199 end
200 end
201 if p == parent && !p.nil?
202 # Nothing to do
203 true
204 elsif p.nil? || (p.active? && move_possible?(p))
205 # Insert the project so that target's children or root projects stay alphabetically sorted
206 sibs = (p.nil? ? self.class.roots : p.children)
207 to_be_inserted_before = sibs.detect {|c| c.name.to_s.downcase > name.to_s.downcase }
208 if to_be_inserted_before
209 move_to_left_of(to_be_inserted_before)
210 elsif p.nil?
211 if sibs.empty?
212 # move_to_root adds the project in first (ie. left) position
213 move_to_root
214 else
215 move_to_right_of(sibs.last) unless self == sibs.last
216 end
217 else
218 # move_to_child_of adds the project in last (ie.right) position
219 move_to_child_of(p)
220 end
221 true
222 else
223 # Can not move to the given target
224 false
225 end
182 226 end
183 227
184 228 # Returns an array of the trackers used by the project and its sub projects
185 229 def rolled_up_trackers
186 230 @rolled_up_trackers ||=
187 231 Tracker.find(:all, :include => :projects,
188 232 :select => "DISTINCT #{Tracker.table_name}.*",
189 :conditions => ["#{Project.table_name}.id = ? OR #{Project.table_name}.parent_id = ?", id, id],
233 :conditions => ["#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ?", lft, rgt],
190 234 :order => "#{Tracker.table_name}.position")
191 235 end
192 236
193 237 # Deletes all project's members
194 238 def delete_all_members
195 239 Member.delete_all(['project_id = ?', id])
196 240 end
197 241
198 242 # Users issues can be assigned to
199 243 def assignable_users
200 244 members.select {|m| m.role.assignable?}.collect {|m| m.user}.sort
201 245 end
202 246
203 247 # Returns the mail adresses of users that should be always notified on project events
204 248 def recipients
205 249 members.select {|m| m.mail_notification? || m.user.mail_notification?}.collect {|m| m.user.mail}
206 250 end
207 251
208 252 # Returns an array of all custom fields enabled for project issues
209 253 # (explictly associated custom fields and custom fields enabled for all projects)
210 254 def all_issue_custom_fields
211 255 @all_issue_custom_fields ||= (IssueCustomField.for_all + issue_custom_fields).uniq.sort
212 256 end
213 257
214 258 def project
215 259 self
216 260 end
217 261
218 262 def <=>(project)
219 263 name.downcase <=> project.name.downcase
220 264 end
221 265
222 266 def to_s
223 267 name
224 268 end
225 269
226 270 # Returns a short description of the projects (first lines)
227 271 def short_description(length = 255)
228 description.gsub(/^(.{#{length}}[^\n]*).*$/m, '\1').strip if description
272 description.gsub(/^(.{#{length}}[^\n\r]*).*$/m, '\1...').strip if description
229 273 end
230 274
231 275 def allows_to?(action)
232 276 if action.is_a? Hash
233 277 allowed_actions.include? "#{action[:controller]}/#{action[:action]}"
234 278 else
235 279 allowed_permissions.include? action
236 280 end
237 281 end
238 282
239 283 def module_enabled?(module_name)
240 284 module_name = module_name.to_s
241 285 enabled_modules.detect {|m| m.name == module_name}
242 286 end
243 287
244 288 def enabled_module_names=(module_names)
245 289 enabled_modules.clear
246 290 module_names = [] unless module_names && module_names.is_a?(Array)
247 291 module_names.each do |name|
248 292 enabled_modules << EnabledModule.new(:name => name.to_s)
249 293 end
250 294 end
251 295
252 296 # Returns an auto-generated project identifier based on the last identifier used
253 297 def self.next_identifier
254 298 p = Project.find(:first, :order => 'created_on DESC')
255 299 p.nil? ? nil : p.identifier.to_s.succ
256 300 end
257 301
258 302 protected
259 303 def validate
260 errors.add(parent_id, " must be a root project") if parent and parent.parent
261 errors.add_to_base("A project with subprojects can't be a subproject") if parent and children.size > 0
262 304 errors.add(:identifier, :activerecord_error_invalid) if !identifier.blank? && identifier.match(/^\d*$/)
263 305 end
264 306
265 307 private
266 308 def allowed_permissions
267 309 @allowed_permissions ||= begin
268 310 module_names = enabled_modules.collect {|m| m.name}
269 311 Redmine::AccessControl.modules_permissions(module_names).collect {|p| p.name}
270 312 end
271 313 end
272 314
273 315 def allowed_actions
274 316 @actions_allowed ||= allowed_permissions.inject([]) { |actions, permission| actions += Redmine::AccessControl.allowed_actions(permission) }.flatten
275 317 end
276 318 end
@@ -1,406 +1,406
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2008 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, :default_order
20 20 include GLoc
21 21
22 22 def initialize(name, options={})
23 23 self.name = name
24 24 self.sortable = options[:sortable]
25 25 self.default_order = options[:default_order]
26 26 end
27 27
28 28 def caption
29 29 set_language_if_valid(User.current.language)
30 30 l("field_#{name}")
31 31 end
32 32 end
33 33
34 34 class QueryCustomFieldColumn < QueryColumn
35 35
36 36 def initialize(custom_field)
37 37 self.name = "cf_#{custom_field.id}".to_sym
38 38 self.sortable = custom_field.order_statement || false
39 39 @cf = custom_field
40 40 end
41 41
42 42 def caption
43 43 @cf.name
44 44 end
45 45
46 46 def custom_field
47 47 @cf
48 48 end
49 49 end
50 50
51 51 class Query < ActiveRecord::Base
52 52 belongs_to :project
53 53 belongs_to :user
54 54 serialize :filters
55 55 serialize :column_names
56 56
57 57 attr_protected :project_id, :user_id
58 58
59 59 validates_presence_of :name, :on => :save
60 60 validates_length_of :name, :maximum => 255
61 61
62 62 @@operators = { "=" => :label_equals,
63 63 "!" => :label_not_equals,
64 64 "o" => :label_open_issues,
65 65 "c" => :label_closed_issues,
66 66 "!*" => :label_none,
67 67 "*" => :label_all,
68 68 ">=" => '>=',
69 69 "<=" => '<=',
70 70 "<t+" => :label_in_less_than,
71 71 ">t+" => :label_in_more_than,
72 72 "t+" => :label_in,
73 73 "t" => :label_today,
74 74 "w" => :label_this_week,
75 75 ">t-" => :label_less_than_ago,
76 76 "<t-" => :label_more_than_ago,
77 77 "t-" => :label_ago,
78 78 "~" => :label_contains,
79 79 "!~" => :label_not_contains }
80 80
81 81 cattr_reader :operators
82 82
83 83 @@operators_by_filter_type = { :list => [ "=", "!" ],
84 84 :list_status => [ "o", "=", "!", "c", "*" ],
85 85 :list_optional => [ "=", "!", "!*", "*" ],
86 86 :list_subprojects => [ "*", "!*", "=" ],
87 87 :date => [ "<t+", ">t+", "t+", "t", "w", ">t-", "<t-", "t-" ],
88 88 :date_past => [ ">t-", "<t-", "t-", "t", "w" ],
89 89 :string => [ "=", "~", "!", "!~" ],
90 90 :text => [ "~", "!~" ],
91 91 :integer => [ "=", ">=", "<=", "!*", "*" ] }
92 92
93 93 cattr_reader :operators_by_filter_type
94 94
95 95 @@available_columns = [
96 96 QueryColumn.new(:tracker, :sortable => "#{Tracker.table_name}.position"),
97 97 QueryColumn.new(:status, :sortable => "#{IssueStatus.table_name}.position"),
98 98 QueryColumn.new(:priority, :sortable => "#{Enumeration.table_name}.position", :default_order => 'desc'),
99 99 QueryColumn.new(:subject, :sortable => "#{Issue.table_name}.subject"),
100 100 QueryColumn.new(:author),
101 101 QueryColumn.new(:assigned_to, :sortable => "#{User.table_name}.lastname"),
102 102 QueryColumn.new(:updated_on, :sortable => "#{Issue.table_name}.updated_on", :default_order => 'desc'),
103 103 QueryColumn.new(:category, :sortable => "#{IssueCategory.table_name}.name"),
104 104 QueryColumn.new(:fixed_version, :sortable => "#{Version.table_name}.effective_date", :default_order => 'desc'),
105 105 QueryColumn.new(:start_date, :sortable => "#{Issue.table_name}.start_date"),
106 106 QueryColumn.new(:due_date, :sortable => "#{Issue.table_name}.due_date"),
107 107 QueryColumn.new(:estimated_hours, :sortable => "#{Issue.table_name}.estimated_hours"),
108 108 QueryColumn.new(:done_ratio, :sortable => "#{Issue.table_name}.done_ratio"),
109 109 QueryColumn.new(:created_on, :sortable => "#{Issue.table_name}.created_on", :default_order => 'desc'),
110 110 ]
111 111 cattr_reader :available_columns
112 112
113 113 def initialize(attributes = nil)
114 114 super attributes
115 115 self.filters ||= { 'status_id' => {:operator => "o", :values => [""]} }
116 116 set_language_if_valid(User.current.language)
117 117 end
118 118
119 119 def after_initialize
120 120 # Store the fact that project is nil (used in #editable_by?)
121 121 @is_for_all = project.nil?
122 122 end
123 123
124 124 def validate
125 125 filters.each_key do |field|
126 126 errors.add label_for(field), :activerecord_error_blank unless
127 127 # filter requires one or more values
128 128 (values_for(field) and !values_for(field).first.blank?) or
129 129 # filter doesn't require any value
130 130 ["o", "c", "!*", "*", "t", "w"].include? operator_for(field)
131 131 end if filters
132 132 end
133 133
134 134 def editable_by?(user)
135 135 return false unless user
136 136 # Admin can edit them all and regular users can edit their private queries
137 137 return true if user.admin? || (!is_public && self.user_id == user.id)
138 138 # Members can not edit public queries that are for all project (only admin is allowed to)
139 139 is_public && !@is_for_all && user.allowed_to?(:manage_public_queries, project)
140 140 end
141 141
142 142 def available_filters
143 143 return @available_filters if @available_filters
144 144
145 145 trackers = project.nil? ? Tracker.find(:all, :order => 'position') : project.rolled_up_trackers
146 146
147 147 @available_filters = { "status_id" => { :type => :list_status, :order => 1, :values => IssueStatus.find(:all, :order => 'position').collect{|s| [s.name, s.id.to_s] } },
148 148 "tracker_id" => { :type => :list, :order => 2, :values => trackers.collect{|s| [s.name, s.id.to_s] } },
149 149 "priority_id" => { :type => :list, :order => 3, :values => Enumeration.find(:all, :conditions => ['opt=?','IPRI'], :order => 'position').collect{|s| [s.name, s.id.to_s] } },
150 150 "subject" => { :type => :text, :order => 8 },
151 151 "created_on" => { :type => :date_past, :order => 9 },
152 152 "updated_on" => { :type => :date_past, :order => 10 },
153 153 "start_date" => { :type => :date, :order => 11 },
154 154 "due_date" => { :type => :date, :order => 12 },
155 155 "estimated_hours" => { :type => :integer, :order => 13 },
156 156 "done_ratio" => { :type => :integer, :order => 14 }}
157 157
158 158 user_values = []
159 159 user_values << ["<< #{l(:label_me)} >>", "me"] if User.current.logged?
160 160 if project
161 161 user_values += project.users.sort.collect{|s| [s.name, s.id.to_s] }
162 162 else
163 163 # members of the user's projects
164 164 user_values += User.current.projects.collect(&:users).flatten.uniq.sort.collect{|s| [s.name, s.id.to_s] }
165 165 end
166 166 @available_filters["assigned_to_id"] = { :type => :list_optional, :order => 4, :values => user_values } unless user_values.empty?
167 167 @available_filters["author_id"] = { :type => :list, :order => 5, :values => user_values } unless user_values.empty?
168 168
169 169 if project
170 170 # project specific filters
171 171 unless @project.issue_categories.empty?
172 172 @available_filters["category_id"] = { :type => :list_optional, :order => 6, :values => @project.issue_categories.collect{|s| [s.name, s.id.to_s] } }
173 173 end
174 174 unless @project.versions.empty?
175 175 @available_filters["fixed_version_id"] = { :type => :list_optional, :order => 7, :values => @project.versions.sort.collect{|s| [s.name, s.id.to_s] } }
176 176 end
177 unless @project.active_children.empty?
178 @available_filters["subproject_id"] = { :type => :list_subprojects, :order => 13, :values => @project.active_children.collect{|s| [s.name, s.id.to_s] } }
177 unless @project.descendants.active.empty?
178 @available_filters["subproject_id"] = { :type => :list_subprojects, :order => 13, :values => @project.descendants.visible.collect{|s| [s.name, s.id.to_s] } }
179 179 end
180 180 add_custom_fields_filters(@project.all_issue_custom_fields)
181 181 else
182 182 # global filters for cross project issue list
183 183 add_custom_fields_filters(IssueCustomField.find(:all, :conditions => {:is_filter => true, :is_for_all => true}))
184 184 end
185 185 @available_filters
186 186 end
187 187
188 188 def add_filter(field, operator, values)
189 189 # values must be an array
190 190 return unless values and values.is_a? Array # and !values.first.empty?
191 191 # check if field is defined as an available filter
192 192 if available_filters.has_key? field
193 193 filter_options = available_filters[field]
194 194 # check if operator is allowed for that filter
195 195 #if @@operators_by_filter_type[filter_options[:type]].include? operator
196 196 # allowed_values = values & ([""] + (filter_options[:values] || []).collect {|val| val[1]})
197 197 # filters[field] = {:operator => operator, :values => allowed_values } if (allowed_values.first and !allowed_values.first.empty?) or ["o", "c", "!*", "*", "t"].include? operator
198 198 #end
199 199 filters[field] = {:operator => operator, :values => values }
200 200 end
201 201 end
202 202
203 203 def add_short_filter(field, expression)
204 204 return unless expression
205 205 parms = expression.scan(/^(o|c|\!|\*)?(.*)$/).first
206 206 add_filter field, (parms[0] || "="), [parms[1] || ""]
207 207 end
208 208
209 209 def has_filter?(field)
210 210 filters and filters[field]
211 211 end
212 212
213 213 def operator_for(field)
214 214 has_filter?(field) ? filters[field][:operator] : nil
215 215 end
216 216
217 217 def values_for(field)
218 218 has_filter?(field) ? filters[field][:values] : nil
219 219 end
220 220
221 221 def label_for(field)
222 222 label = available_filters[field][:name] if available_filters.has_key?(field)
223 223 label ||= field.gsub(/\_id$/, "")
224 224 end
225 225
226 226 def available_columns
227 227 return @available_columns if @available_columns
228 228 @available_columns = Query.available_columns
229 229 @available_columns += (project ?
230 230 project.all_issue_custom_fields :
231 231 IssueCustomField.find(:all, :conditions => {:is_for_all => true})
232 232 ).collect {|cf| QueryCustomFieldColumn.new(cf) }
233 233 end
234 234
235 235 def columns
236 236 if has_default_columns?
237 237 available_columns.select {|c| Setting.issue_list_default_columns.include?(c.name.to_s) }
238 238 else
239 239 # preserve the column_names order
240 240 column_names.collect {|name| available_columns.find {|col| col.name == name}}.compact
241 241 end
242 242 end
243 243
244 244 def column_names=(names)
245 245 names = names.select {|n| n.is_a?(Symbol) || !n.blank? } if names
246 246 names = names.collect {|n| n.is_a?(Symbol) ? n : n.to_sym } if names
247 247 write_attribute(:column_names, names)
248 248 end
249 249
250 250 def has_column?(column)
251 251 column_names && column_names.include?(column.name)
252 252 end
253 253
254 254 def has_default_columns?
255 255 column_names.nil? || column_names.empty?
256 256 end
257 257
258 258 def project_statement
259 259 project_clauses = []
260 if project && !@project.active_children.empty?
260 if project && !@project.descendants.active.empty?
261 261 ids = [project.id]
262 262 if has_filter?("subproject_id")
263 263 case operator_for("subproject_id")
264 264 when '='
265 265 # include the selected subprojects
266 266 ids += values_for("subproject_id").each(&:to_i)
267 267 when '!*'
268 268 # main project only
269 269 else
270 270 # all subprojects
271 ids += project.child_ids
271 ids += project.descendants.collect(&:id)
272 272 end
273 273 elsif Setting.display_subprojects_issues?
274 ids += project.child_ids
274 ids += project.descendants.collect(&:id)
275 275 end
276 276 project_clauses << "#{Project.table_name}.id IN (%s)" % ids.join(',')
277 277 elsif project
278 278 project_clauses << "#{Project.table_name}.id = %d" % project.id
279 279 end
280 280 project_clauses << Project.allowed_to_condition(User.current, :view_issues)
281 281 project_clauses.join(' AND ')
282 282 end
283 283
284 284 def statement
285 285 # filters clauses
286 286 filters_clauses = []
287 287 filters.each_key do |field|
288 288 next if field == "subproject_id"
289 289 v = values_for(field).clone
290 290 next unless v and !v.empty?
291 291
292 292 sql = ''
293 293 is_custom_filter = false
294 294 if field =~ /^cf_(\d+)$/
295 295 # custom field
296 296 db_table = CustomValue.table_name
297 297 db_field = 'value'
298 298 is_custom_filter = true
299 299 sql << "#{Issue.table_name}.id 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=#{$1} WHERE "
300 300 else
301 301 # regular field
302 302 db_table = Issue.table_name
303 303 db_field = field
304 304 sql << '('
305 305 end
306 306
307 307 # "me" value subsitution
308 308 if %w(assigned_to_id author_id).include?(field)
309 309 v.push(User.current.logged? ? User.current.id.to_s : "0") if v.delete("me")
310 310 end
311 311
312 312 sql = sql + sql_for_field(field, v, db_table, db_field, is_custom_filter)
313 313
314 314 sql << ')'
315 315 filters_clauses << sql
316 316 end if filters and valid?
317 317
318 318 (filters_clauses << project_statement).join(' AND ')
319 319 end
320 320
321 321 private
322 322
323 323 # Helper method to generate the WHERE sql for a +field+ with a +value+
324 324 def sql_for_field(field, value, db_table, db_field, is_custom_filter)
325 325 sql = ''
326 326 case operator_for field
327 327 when "="
328 328 sql = "#{db_table}.#{db_field} IN (" + value.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + ")"
329 329 when "!"
330 330 sql = "(#{db_table}.#{db_field} IS NULL OR #{db_table}.#{db_field} NOT IN (" + value.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + "))"
331 331 when "!*"
332 332 sql = "#{db_table}.#{db_field} IS NULL"
333 333 sql << " OR #{db_table}.#{db_field} = ''" if is_custom_filter
334 334 when "*"
335 335 sql = "#{db_table}.#{db_field} IS NOT NULL"
336 336 sql << " AND #{db_table}.#{db_field} <> ''" if is_custom_filter
337 337 when ">="
338 338 sql = "#{db_table}.#{db_field} >= #{value.first.to_i}"
339 339 when "<="
340 340 sql = "#{db_table}.#{db_field} <= #{value.first.to_i}"
341 341 when "o"
342 342 sql = "#{IssueStatus.table_name}.is_closed=#{connection.quoted_false}" if field == "status_id"
343 343 when "c"
344 344 sql = "#{IssueStatus.table_name}.is_closed=#{connection.quoted_true}" if field == "status_id"
345 345 when ">t-"
346 346 sql = date_range_clause(db_table, db_field, - value.first.to_i, 0)
347 347 when "<t-"
348 348 sql = date_range_clause(db_table, db_field, nil, - value.first.to_i)
349 349 when "t-"
350 350 sql = date_range_clause(db_table, db_field, - value.first.to_i, - value.first.to_i)
351 351 when ">t+"
352 352 sql = date_range_clause(db_table, db_field, value.first.to_i, nil)
353 353 when "<t+"
354 354 sql = date_range_clause(db_table, db_field, 0, value.first.to_i)
355 355 when "t+"
356 356 sql = date_range_clause(db_table, db_field, value.first.to_i, value.first.to_i)
357 357 when "t"
358 358 sql = date_range_clause(db_table, db_field, 0, 0)
359 359 when "w"
360 360 from = l(:general_first_day_of_week) == '7' ?
361 361 # week starts on sunday
362 362 ((Date.today.cwday == 7) ? Time.now.at_beginning_of_day : Time.now.at_beginning_of_week - 1.day) :
363 363 # week starts on monday (Rails default)
364 364 Time.now.at_beginning_of_week
365 365 sql = "#{db_table}.#{db_field} BETWEEN '%s' AND '%s'" % [connection.quoted_date(from), connection.quoted_date(from + 7.days)]
366 366 when "~"
367 367 sql = "#{db_table}.#{db_field} LIKE '%#{connection.quote_string(value.first)}%'"
368 368 when "!~"
369 369 sql = "#{db_table}.#{db_field} NOT LIKE '%#{connection.quote_string(value.first)}%'"
370 370 end
371 371
372 372 return sql
373 373 end
374 374
375 375 def add_custom_fields_filters(custom_fields)
376 376 @available_filters ||= {}
377 377
378 378 custom_fields.select(&:is_filter?).each do |field|
379 379 case field.field_format
380 380 when "text"
381 381 options = { :type => :text, :order => 20 }
382 382 when "list"
383 383 options = { :type => :list_optional, :values => field.possible_values, :order => 20}
384 384 when "date"
385 385 options = { :type => :date, :order => 20 }
386 386 when "bool"
387 387 options = { :type => :list, :values => [[l(:general_text_yes), "1"], [l(:general_text_no), "0"]], :order => 20 }
388 388 else
389 389 options = { :type => :string, :order => 20 }
390 390 end
391 391 @available_filters["cf_#{field.id}"] = options.merge({ :name => field.name })
392 392 end
393 393 end
394 394
395 395 # Returns a SQL clause for a date or datetime field.
396 396 def date_range_clause(table, field, from, to)
397 397 s = []
398 398 if from
399 399 s << ("#{table}.#{field} > '%s'" % [connection.quoted_date((Date.yesterday + from).to_time.end_of_day)])
400 400 end
401 401 if to
402 402 s << ("#{table}.#{field} <= '%s'" % [connection.quoted_date((Date.today + to).to_time.end_of_day)])
403 403 end
404 404 s.join(' AND ')
405 405 end
406 406 end
@@ -1,52 +1,48
1 1 <div class="contextual">
2 2 <%= link_to l(:label_project_new), {:controller => 'projects', :action => 'add'}, :class => 'icon icon-add' %>
3 3 </div>
4 4
5 5 <h2><%=l(:label_project_plural)%></h2>
6 6
7 7 <% form_tag({}, :method => :get) do %>
8 8 <fieldset><legend><%= l(:label_filter_plural) %></legend>
9 9 <label><%= l(:field_status) %> :</label>
10 10 <%= select_tag 'status', project_status_options_for_select(@status), :class => "small", :onchange => "this.form.submit(); return false;" %>
11 11 <label><%= l(:label_project) %>:</label>
12 12 <%= text_field_tag 'name', params[:name], :size => 30 %>
13 13 <%= submit_tag l(:button_apply), :class => "small", :name => nil %>
14 14 </fieldset>
15 15 <% end %>
16 16 &nbsp;
17 17
18 18 <table class="list">
19 19 <thead><tr>
20 <%= sort_header_tag('name', :caption => l(:label_project)) %>
20 <th><%=l(:label_project)%></th>
21 21 <th><%=l(:field_description)%></th>
22 <th><%=l(:label_subproject_plural)%></th>
23 <%= sort_header_tag('is_public', :caption => l(:field_is_public), :default_order => 'desc') %>
24 <%= sort_header_tag('created_on', :caption => l(:field_created_on), :default_order => 'desc') %>
22 <th><%=l(:field_is_public)%></th>
23 <th><%=l(:field_created_on)%></th>
25 24 <th></th>
26 25 <th></th>
27 26 </tr></thead>
28 27 <tbody>
29 28 <% for project in @projects %>
30 <tr class="<%= cycle("odd", "even") %>">
31 <td><%= project.active? ? link_to(h(project.name), :controller => 'projects', :action => 'settings', :id => project) : h(project.name) %>
32 <td><%= textilizable project.short_description, :project => project %>
33 <td align="center"><%= project.children.size %>
34 <td align="center"><%= image_tag 'true.png' if project.is_public? %>
35 <td align="center"><%= format_date(project.created_on) %>
29 <tr class="<%= cycle("odd", "even") %> <%= css_project_classes(project) %>">
30 <td class="name" style="padding-left: <%= project.level %>em;"><%= project.active? ? link_to(h(project.name), :controller => 'projects', :action => 'settings', :id => project) : h(project.name) %></td>
31 <td><%= textilizable project.short_description, :project => project %></td>
32 <td align="center"><%= image_tag 'true.png' if project.is_public? %></td>
33 <td align="center"><%= format_date(project.created_on) %></td>
36 34 <td align="center" style="width:10%">
37 35 <small>
38 36 <%= link_to(l(:button_archive), { :controller => 'projects', :action => 'archive', :id => project }, :confirm => l(:text_are_you_sure), :method => :post, :class => 'icon icon-lock') if project.active? %>
39 37 <%= link_to(l(:button_unarchive), { :controller => 'projects', :action => 'unarchive', :id => project }, :method => :post, :class => 'icon icon-unlock') if !project.active? && (project.parent.nil? || project.parent.active?) %>
40 38 </small>
41 39 </td>
42 40 <td align="center" style="width:10%">
43 41 <small><%= link_to(l(:button_delete), { :controller => 'projects', :action => 'destroy', :id => project }, :class => 'icon icon-del') %></small>
44 42 </td>
45 43 </tr>
46 44 <% end %>
47 45 </tbody>
48 46 </table>
49 47
50 <p class="pagination"><%= pagination_links_full @project_pages, @project_count %></p>
51
52 48 <% html_title(l(:label_project_plural)) -%>
@@ -1,66 +1,66
1 1 <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
2 2 <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">
3 3 <head>
4 4 <title><%=h html_title %></title>
5 5 <meta http-equiv="content-type" content="text/html; charset=utf-8" />
6 6 <meta name="description" content="<%= Redmine::Info.app_name %>" />
7 7 <meta name="keywords" content="issue,bug,tracker" />
8 8 <%= stylesheet_link_tag 'application', :media => 'all' %>
9 9 <%= javascript_include_tag :defaults %>
10 10 <%= heads_for_wiki_formatter %>
11 11 <!--[if IE]>
12 12 <style type="text/css">
13 13 * html body{ width: expression( document.documentElement.clientWidth < 900 ? '900px' : '100%' ); }
14 14 body {behavior: url(<%= stylesheet_path "csshover.htc" %>);}
15 15 </style>
16 16 <![endif]-->
17 17 <%= call_hook :view_layouts_base_html_head %>
18 18 <!-- page specific tags -->
19 19 <%= yield :header_tags -%>
20 20 </head>
21 21 <body>
22 22 <div id="wrapper">
23 23 <div id="top-menu">
24 24 <div id="account">
25 25 <%= render_menu :account_menu -%>
26 26 </div>
27 27 <%= content_tag('div', "#{l(:label_logged_as)} #{link_to_user(User.current, :format => :username)}", :id => 'loggedas') if User.current.logged? %>
28 28 <%= render_menu :top_menu -%>
29 29 </div>
30 30
31 31 <div id="header">
32 32 <div id="quick-search">
33 33 <% form_tag({:controller => 'search', :action => 'index', :id => @project}, :method => :get ) do %>
34 34 <%= link_to l(:label_search), {:controller => 'search', :action => 'index', :id => @project}, :accesskey => accesskey(:search) %>:
35 35 <%= text_field_tag 'q', @question, :size => 20, :class => 'small', :accesskey => accesskey(:quick_search) %>
36 36 <% end %>
37 <%= render :partial => 'layouts/project_selector' if User.current.memberships.any? %>
37 <%= render_project_jump_box %>
38 38 </div>
39 39
40 40 <h1><%= h(@project && !@project.new_record? ? @project.name : Setting.app_title) %></h1>
41 41
42 42 <div id="main-menu">
43 43 <%= render_main_menu(@project) %>
44 44 </div>
45 45 </div>
46 46
47 47 <%= tag('div', {:id => 'main', :class => (has_content?(:sidebar) ? '' : 'nosidebar')}, true) %>
48 48 <div id="sidebar">
49 49 <%= yield :sidebar %>
50 50 </div>
51 51
52 52 <div id="content">
53 53 <%= render_flash_messages %>
54 54 <%= yield %>
55 55 </div>
56 56 </div>
57 57
58 58 <div id="ajax-indicator" style="display:none;"><span><%= l(:label_loading) %></span></div>
59 59
60 60 <div id="footer">
61 61 Powered by <%= link_to Redmine::Info.app_name, Redmine::Info.url %> &copy; 2006-2008 Jean-Philippe Lang
62 62 </div>
63 63 </div>
64 64 <%= call_hook :view_layouts_base_body_bottom %>
65 65 </body>
66 66 </html>
@@ -1,49 +1,49
1 1 <%= error_messages_for 'project' %>
2 2
3 3 <div class="box">
4 4 <!--[form:project]-->
5 5 <p><%= f.text_field :name, :required => true %><br /><em><%= l(:text_caracters_maximum, 30) %></em></p>
6 6
7 <% if User.current.admin? and !@root_projects.empty? %>
8 <p><%= f.select :parent_id, (@root_projects.collect {|p| [p.name, p.id]}), { :include_blank => true } %></p>
7 <% if User.current.admin? && !@project.possible_parents.empty? %>
8 <p><label><%= l(:field_parent) %></label><%= parent_project_select_tag(@project) %></p>
9 9 <% end %>
10 10
11 11 <p><%= f.text_area :description, :rows => 5, :class => 'wiki-edit' %></p>
12 12 <p><%= f.text_field :identifier, :required => true, :disabled => @project.identifier_frozen? %>
13 13 <% unless @project.identifier_frozen? %>
14 14 <br /><em><%= l(:text_length_between, 2, 20) %> <%= l(:text_project_identifier_info) %></em>
15 15 <% end %></p>
16 16 <p><%= f.text_field :homepage, :size => 60 %></p>
17 17 <p><%= f.check_box :is_public %></p>
18 18 <%= wikitoolbar_for 'project_description' %>
19 19
20 20 <% @project.custom_field_values.each do |value| %>
21 21 <p><%= custom_field_tag_with_label :project, value %></p>
22 22 <% end %>
23 23 <%= call_hook(:view_projects_form, :project => @project, :form => f) %>
24 24 </div>
25 25
26 26 <% unless @trackers.empty? %>
27 27 <fieldset class="box"><legend><%=l(:label_tracker_plural)%></legend>
28 28 <% @trackers.each do |tracker| %>
29 29 <label class="floating">
30 30 <%= check_box_tag 'project[tracker_ids][]', tracker.id, @project.trackers.include?(tracker) %>
31 31 <%= tracker %>
32 32 </label>
33 33 <% end %>
34 34 <%= hidden_field_tag 'project[tracker_ids][]', '' %>
35 35 </fieldset>
36 36 <% end %>
37 37
38 38 <% unless @issue_custom_fields.empty? %>
39 39 <fieldset class="box"><legend><%=l(:label_custom_field_plural)%></legend>
40 40 <% @issue_custom_fields.each do |custom_field| %>
41 41 <label class="floating">
42 42 <%= check_box_tag 'project[issue_custom_field_ids][]', custom_field.id, (@project.all_issue_custom_fields.include? custom_field), (custom_field.is_for_all? ? {:disabled => "disabled"} : {}) %>
43 43 <%= custom_field.name %>
44 44 </label>
45 45 <% end %>
46 46 <%= hidden_field_tag 'project[issue_custom_field_ids][]', '' %>
47 47 </fieldset>
48 48 <% end %>
49 49 <!--[eoform:project]-->
@@ -1,60 +1,60
1 1 <h2><%= @author.nil? ? l(:label_activity) : l(:label_user_activity, link_to_user(@author)) %></h2>
2 2 <p class="subtitle"><%= "#{l(:label_date_from)} #{format_date(@date_to - @days)} #{l(:label_date_to).downcase} #{format_date(@date_to-1)}" %></p>
3 3
4 4 <div id="activity">
5 5 <% @events_by_day.keys.sort.reverse.each do |day| %>
6 6 <h3><%= format_activity_day(day) %></h3>
7 7 <dl>
8 8 <% @events_by_day[day].sort {|x,y| y.event_datetime <=> x.event_datetime }.each do |e| -%>
9 9 <dt class="<%= e.event_type %> <%= User.current.logged? && e.respond_to?(:event_author) && User.current == e.event_author ? 'me' : nil %>">
10 10 <%= avatar(e.event_author, :size => "24") if e.respond_to?(:event_author) %>
11 11 <span class="time"><%= format_time(e.event_datetime, false) %></span>
12 12 <%= content_tag('span', h(e.project), :class => 'project') if @project.nil? || @project != e.project %>
13 13 <%= link_to format_activity_title(e.event_title), e.event_url %></dt>
14 14 <dd><span class="description"><%= format_activity_description(e.event_description) %></span>
15 15 <span class="author"><%= e.event_author if e.respond_to?(:event_author) %></span></dd>
16 16 <% end -%>
17 17 </dl>
18 18 <% end -%>
19 19 </div>
20 20
21 21 <%= content_tag('p', l(:label_no_data), :class => 'nodata') if @events_by_day.empty? %>
22 22
23 23 <div style="float:left;">
24 24 <%= link_to_remote(('&#171; ' + l(:label_previous)),
25 25 {:update => "content", :url => params.merge(:from => @date_to - @days - 1), :complete => 'window.scrollTo(0,0)'},
26 26 {:href => url_for(params.merge(:from => @date_to - @days - 1)),
27 27 :title => "#{l(:label_date_from)} #{format_date(@date_to - 2*@days)} #{l(:label_date_to).downcase} #{format_date(@date_to - @days - 1)}"}) %>
28 28 </div>
29 29 <div style="float:right;">
30 30 <%= link_to_remote((l(:label_next) + ' &#187;'),
31 31 {:update => "content", :url => params.merge(:from => @date_to + @days - 1), :complete => 'window.scrollTo(0,0)'},
32 32 {:href => url_for(params.merge(:from => @date_to + @days - 1)),
33 33 :title => "#{l(:label_date_from)} #{format_date(@date_to)} #{l(:label_date_to).downcase} #{format_date(@date_to + @days - 1)}"}) unless @date_to >= Date.today %>
34 34 </div>
35 35 &nbsp;
36 36 <p class="other-formats">
37 37 <%= l(:label_export_to) %>
38 38 <%= link_to 'Atom', params.merge(:format => :atom, :from => nil, :key => User.current.rss_key), :class => 'feed' %>
39 39 </p>
40 40
41 41 <% content_for :header_tags do %>
42 42 <%= auto_discovery_link_tag(:atom, params.merge(:format => 'atom', :from => nil, :key => User.current.rss_key)) %>
43 43 <% end %>
44 44
45 45 <% content_for :sidebar do %>
46 46 <% form_tag({}, :method => :get) do %>
47 47 <h3><%= l(:label_activity) %></h3>
48 48 <p><% @activity.event_types.each do |t| %>
49 49 <label><%= check_box_tag "show_#{t}", 1, @activity.scope.include?(t) %> <%= l("label_#{t.singularize}_plural")%></label><br />
50 50 <% end %></p>
51 <% if @project && @project.active_children.any? %>
51 <% if @project && @project.descendants.active.any? %>
52 52 <p><label><%= check_box_tag 'with_subprojects', 1, @with_subprojects %> <%=l(:label_subproject_plural)%></label></p>
53 53 <%= hidden_field_tag 'with_subprojects', 0 %>
54 54 <% end %>
55 55 <%= hidden_field_tag('user_id', params[:user_id]) unless params[:user_id].blank? %>
56 56 <p><%= submit_tag l(:button_apply), :class => 'button-small', :name => nil %></p>
57 57 <% end %>
58 58 <% end %>
59 59
60 60 <% html_title(l(:label_activity), @author) -%>
@@ -1,16 +1,16
1 1 <h2><%=l(:label_confirmation)%></h2>
2 2 <div class="warning">
3 3 <p><strong><%=h @project_to_destroy %></strong><br />
4 4 <%=l(:text_project_destroy_confirmation)%>
5 5
6 <% if @project_to_destroy.children.any? %>
7 <br /><%= l(:text_subprojects_destroy_warning, content_tag('strong', h(@project_to_destroy.children.sort.collect{|p| p.to_s}.join(', ')))) %>
6 <% if @project_to_destroy.descendants.any? %>
7 <br /><%= l(:text_subprojects_destroy_warning, content_tag('strong', h(@project_to_destroy.descendants.collect{|p| p.to_s}.join(', ')))) %>
8 8 <% end %>
9 9 </p>
10 10 <p>
11 11 <% form_tag({:controller => 'projects', :action => 'destroy', :id => @project_to_destroy}) do %>
12 12 <label><%= check_box_tag 'confirm', 1 %> <%= l(:general_text_Yes) %></label>
13 13 <%= submit_tag l(:button_delete) %>
14 14 <% end %>
15 15 </p>
16 16 </div>
@@ -1,31 +1,22
1 1 <div class="contextual">
2 2 <%= link_to(l(:label_project_new), {:controller => 'projects', :action => 'add'}, :class => 'icon icon-add') + ' |' if User.current.admin? %>
3 3 <%= link_to l(:label_issue_view_all), { :controller => 'issues' } %> |
4 4 <%= link_to l(:label_overall_activity), { :controller => 'projects', :action => 'activity' }%>
5 5 </div>
6 6
7 7 <h2><%=l(:label_project_plural)%></h2>
8 8
9 <% @project_tree.keys.sort.each do |project| %>
10 <h3><%= link_to h(project.name), {:action => 'show', :id => project}, :class => (User.current.member_of?(project) ? "icon icon-fav" : "") %></h3>
11 <%= textilizable(project.short_description, :project => project) %>
12
13 <% if @project_tree[project].any? %>
14 <p><%= l(:label_subproject_plural) %>:
15 <%= @project_tree[project].sort.collect {|subproject|
16 link_to(h(subproject.name), {:action => 'show', :id => subproject}, :class => (User.current.member_of?(subproject) ? "icon icon-fav" : ""))}.join(', ') %></p>
17 <% end %>
18 <% end %>
9 <%= render_project_hierarchy(@projects)%>
19 10
20 11 <% if User.current.logged? %>
21 12 <p style="text-align:right;">
22 <span class="icon icon-fav"><%= l(:label_my_projects) %></span>
13 <span class="my-project"><%= l(:label_my_projects) %></span>
23 14 </p>
24 15 <% end %>
25 16
26 17 <p class="other-formats">
27 18 <%= l(:label_export_to) %>
28 19 <span><%= link_to 'Atom', {:format => 'atom', :key => User.current.rss_key}, :class => 'feed' %></span>
29 20 </p>
30 21
31 22 <% html_title(l(:label_project_plural)) -%>
@@ -1,80 +1,82
1 1 <h2><%=l(:label_overview)%></h2>
2 2
3 3 <div class="splitcontentleft">
4 4 <%= textilizable @project.description %>
5 5 <ul>
6 6 <% unless @project.homepage.blank? %><li><%=l(:field_homepage)%>: <%= link_to(h(@project.homepage), @project.homepage) %></li><% end %>
7 <% if @subprojects.any? %>
8 <li><%=l(:label_subproject_plural)%>: <%= @subprojects.collect{|p| link_to(h(p.name), :action => 'show', :id => p)}.join(", ") %></li>
9 <% end %>
10 <% if @project.parent %>
11 <li><%=l(:field_parent)%>: <%= link_to h(@project.parent.name), :controller => 'projects', :action => 'show', :id => @project.parent %></li>
7 <% if @subprojects.any? %>
8 <li><%=l(:label_subproject_plural)%>:
9 <%= @subprojects.collect{|p| link_to(h(p), :action => 'show', :id => p)}.join(", ") %></li>
10 <% end %>
11 <% if @ancestors.any? %>
12 <li><%=l(:field_parent)%>:
13 <%= @ancestors.collect {|p| link_to(h(p), :action => 'show', :id => p)}.join(" &#187; ") %></li>
12 14 <% end %>
13 15 <% @project.custom_values.each do |custom_value| %>
14 16 <% if !custom_value.value.empty? %>
15 17 <li><%= custom_value.custom_field.name%>: <%=h show_value(custom_value) %></li>
16 18 <% end %>
17 19 <% end %>
18 20 </ul>
19 21
20 22 <% if User.current.allowed_to?(:view_issues, @project) %>
21 23 <div class="box">
22 24 <h3 class="icon22 icon22-tracker"><%=l(:label_issue_tracking)%></h3>
23 25 <ul>
24 26 <% for tracker in @trackers %>
25 27 <li><%= link_to tracker.name, :controller => 'issues', :action => 'index', :project_id => @project,
26 28 :set_filter => 1,
27 29 "tracker_id" => tracker.id %>:
28 30 <%= @open_issues_by_tracker[tracker] || 0 %> <%= lwr(:label_open_issues, @open_issues_by_tracker[tracker] || 0) %>
29 31 <%= l(:label_on) %> <%= @total_issues_by_tracker[tracker] || 0 %></li>
30 32 <% end %>
31 33 </ul>
32 34 <p><%= link_to l(:label_issue_view_all), :controller => 'issues', :action => 'index', :project_id => @project, :set_filter => 1 %></p>
33 35 </div>
34 36 <% end %>
35 37 </div>
36 38
37 39 <div class="splitcontentright">
38 40 <% if @members_by_role.any? %>
39 41 <div class="box">
40 42 <h3 class="icon22 icon22-users"><%=l(:label_member_plural)%></h3>
41 43 <p><% @members_by_role.keys.sort.each do |role| %>
42 44 <%= role.name %>:
43 45 <%= @members_by_role[role].collect(&:user).sort.collect{|u| link_to_user u}.join(", ") %>
44 46 <br />
45 47 <% end %></p>
46 48 </div>
47 49 <% end %>
48 50
49 51 <% if @news.any? && authorize_for('news', 'index') %>
50 52 <div class="box">
51 53 <h3><%=l(:label_news_latest)%></h3>
52 54 <%= render :partial => 'news/news', :collection => @news %>
53 55 <p><%= link_to l(:label_news_view_all), :controller => 'news', :action => 'index', :project_id => @project %></p>
54 56 </div>
55 57 <% end %>
56 58 </div>
57 59
58 60 <% content_for :sidebar do %>
59 61 <% planning_links = []
60 62 planning_links << link_to_if_authorized(l(:label_calendar), :controller => 'issues', :action => 'calendar', :project_id => @project)
61 63 planning_links << link_to_if_authorized(l(:label_gantt), :controller => 'issues', :action => 'gantt', :project_id => @project)
62 64 planning_links.compact!
63 65 unless planning_links.empty? %>
64 66 <h3><%= l(:label_planning) %></h3>
65 67 <p><%= planning_links.join(' | ') %></p>
66 68 <% end %>
67 69
68 70 <% if @total_hours && User.current.allowed_to?(:view_time_entries, @project) %>
69 71 <h3><%= l(:label_spent_time) %></h3>
70 72 <p><span class="icon icon-time"><%= lwr(:label_f_hour, @total_hours) %></span></p>
71 73 <p><%= link_to(l(:label_details), {:controller => 'timelog', :action => 'details', :project_id => @project}) %> |
72 74 <%= link_to(l(:label_report), {:controller => 'timelog', :action => 'report', :project_id => @project}) %></p>
73 75 <% end %>
74 76 <% end %>
75 77
76 78 <% content_for :header_tags do %>
77 79 <%= auto_discovery_link_tag(:atom, {:action => 'activity', :id => @project, :format => 'atom', :key => User.current.rss_key}) %>
78 80 <% end %>
79 81
80 82 <% html_title(l(:label_overview)) -%>
@@ -1,40 +1,40
1 1 <% if @memberships.any? %>
2 2 <table class="list memberships">
3 3 <thead>
4 4 <th><%= l(:label_project) %></th>
5 5 <th><%= l(:label_role) %></th>
6 6 <th style="width:15%"></th>
7 7 </thead>
8 8 <tbody>
9 9 <% @memberships.each do |membership| %>
10 10 <% next if membership.new_record? %>
11 11 <tr class="<%= cycle 'odd', 'even' %>">
12 12 <td><%=h membership.project %></td>
13 13 <td align="center">
14 14 <% form_tag({ :action => 'edit_membership', :id => @user, :membership_id => membership }) do %>
15 15 <%= select_tag 'membership[role_id]', options_from_collection_for_select(@roles, "id", "name", membership.role_id) %>
16 16 <%= submit_tag l(:button_change), :class => "small" %>
17 17 <% end %>
18 18 </td>
19 19 <td align="center">
20 20 <%= link_to l(:button_delete), {:action => 'destroy_membership', :id => @user, :membership_id => membership }, :method => :post, :class => 'icon icon-del' %>
21 21 </td>
22 22 </tr>
23 23 </tbody>
24 24 <% end; reset_cycle %>
25 25 </table>
26 26 <% else %>
27 27 <p class="nodata"><%= l(:label_no_data) %></p>
28 28 <% end %>
29 29
30 30 <% if @projects.any? %>
31 31 <p>
32 32 <label><%=l(:label_project_new)%></label><br/>
33 33 <% form_tag({ :action => 'edit_membership', :id => @user }) do %>
34 <%= select_tag 'membership[project_id]', projects_options_for_select(@projects) %>
34 <%= select_tag 'membership[project_id]', options_for_membership_project_select(@user, @projects) %>
35 35 <%= l(:label_role) %>:
36 36 <%= select_tag 'membership[role_id]', options_from_collection_for_select(@roles, "id", "name") %>
37 37 <%= submit_tag l(:button_add) %>
38 38 <% end %>
39 39 </p>
40 40 <% end %>
@@ -1,689 +1,701
1 1 body { font-family: Verdana, sans-serif; font-size: 12px; color:#484848; margin: 0; padding: 0; min-width: 900px; }
2 2
3 3 h1, h2, h3, h4 { font-family: "Trebuchet MS", Verdana, sans-serif;}
4 4 h1 {margin:0; padding:0; font-size: 24px;}
5 5 h2, .wiki h1 {font-size: 20px;padding: 2px 10px 1px 0px;margin: 0 0 10px 0; border-bottom: 1px solid #bbbbbb; color: #444;}
6 6 h3, .wiki h2 {font-size: 16px;padding: 2px 10px 1px 0px;margin: 0 0 10px 0; border-bottom: 1px solid #bbbbbb; color: #444;}
7 7 h4, .wiki h3 {font-size: 13px;padding: 2px 10px 1px 0px;margin-bottom: 5px; border-bottom: 1px dotted #bbbbbb; color: #444;}
8 8
9 9 /***** Layout *****/
10 10 #wrapper {background: white;}
11 11
12 12 #top-menu {background: #2C4056; color: #fff; height:1.8em; font-size: 0.8em; padding: 2px 2px 0px 6px;}
13 13 #top-menu ul {margin: 0; padding: 0;}
14 14 #top-menu li {
15 15 float:left;
16 16 list-style-type:none;
17 17 margin: 0px 0px 0px 0px;
18 18 padding: 0px 0px 0px 0px;
19 19 white-space:nowrap;
20 20 }
21 21 #top-menu a {color: #fff; margin-right: 8px; font-weight: bold;}
22 22 #top-menu #loggedas { float: right; margin-right: 0.5em; color: #fff; }
23 23
24 24 #account {float:right;}
25 25
26 26 #header {height:5.3em;margin:0;background-color:#507AAA;color:#f8f8f8; padding: 4px 8px 0px 6px; position:relative;}
27 27 #header a {color:#f8f8f8;}
28 28 #quick-search {float:right;}
29 29
30 30 #main-menu {position: absolute; bottom: 0px; left:6px; margin-right: -500px;}
31 31 #main-menu ul {margin: 0; padding: 0;}
32 32 #main-menu li {
33 33 float:left;
34 34 list-style-type:none;
35 35 margin: 0px 2px 0px 0px;
36 36 padding: 0px 0px 0px 0px;
37 37 white-space:nowrap;
38 38 }
39 39 #main-menu li a {
40 40 display: block;
41 41 color: #fff;
42 42 text-decoration: none;
43 43 font-weight: bold;
44 44 margin: 0;
45 45 padding: 4px 10px 4px 10px;
46 46 }
47 47 #main-menu li a:hover {background:#759FCF; color:#fff;}
48 48 #main-menu li a.selected, #main-menu li a.selected:hover {background:#fff; color:#555;}
49 49
50 50 #main {background-color:#EEEEEE;}
51 51
52 52 #sidebar{ float: right; width: 17%; position: relative; z-index: 9; min-height: 600px; padding: 0; margin: 0;}
53 53 * html #sidebar{ width: 17%; }
54 54 #sidebar h3{ font-size: 14px; margin-top:14px; color: #666; }
55 55 #sidebar hr{ width: 100%; margin: 0 auto; height: 1px; background: #ccc; border: 0; }
56 56 * html #sidebar hr{ width: 95%; position: relative; left: -6px; color: #ccc; }
57 57
58 58 #content { width: 80%; background-color: #fff; margin: 0px; border-right: 1px solid #ddd; padding: 6px 10px 10px 10px; z-index: 10; }
59 59 * html #content{ width: 80%; padding-left: 0; margin-top: 0px; padding: 6px 10px 10px 10px;}
60 60 html>body #content { min-height: 600px; }
61 61 * html body #content { height: 600px; } /* IE */
62 62
63 63 #main.nosidebar #sidebar{ display: none; }
64 64 #main.nosidebar #content{ width: auto; border-right: 0; }
65 65
66 66 #footer {clear: both; border-top: 1px solid #bbb; font-size: 0.9em; color: #aaa; padding: 5px; text-align:center; background:#fff;}
67 67
68 68 #login-form table {margin-top:5em; padding:1em; margin-left: auto; margin-right: auto; border: 2px solid #FDBF3B; background-color:#FFEBC1; }
69 69 #login-form table td {padding: 6px;}
70 70 #login-form label {font-weight: bold;}
71 71
72 72 .clear:after{ content: "."; display: block; height: 0; clear: both; visibility: hidden; }
73 73
74 74 /***** Links *****/
75 75 a, a:link, a:visited{ color: #2A5685; text-decoration: none; }
76 76 a:hover, a:active{ color: #c61a1a; text-decoration: underline;}
77 77 a img{ border: 0; }
78 78
79 79 a.issue.closed { text-decoration: line-through; }
80 80
81 81 /***** Tables *****/
82 82 table.list { border: 1px solid #e4e4e4; border-collapse: collapse; width: 100%; margin-bottom: 4px; }
83 83 table.list th { background-color:#EEEEEE; padding: 4px; white-space:nowrap; }
84 84 table.list td { vertical-align: top; }
85 85 table.list td.id { width: 2%; text-align: center;}
86 86 table.list td.checkbox { width: 15px; padding: 0px;}
87 87
88 tr.project td.name a { padding-left: 16px; white-space:nowrap; }
89 tr.project.parent td.name a { background: url('../images/bullet_toggle_minus.png') no-repeat; }
90
88 91 tr.issue { text-align: center; white-space: nowrap; }
89 92 tr.issue td.subject, tr.issue td.category, td.assigned_to { white-space: normal; }
90 93 tr.issue td.subject { text-align: left; }
91 94 tr.issue td.done_ratio table.progress { margin-left:auto; margin-right: auto;}
92 95
93 96 tr.entry { border: 1px solid #f8f8f8; }
94 97 tr.entry td { white-space: nowrap; }
95 98 tr.entry td.filename { width: 30%; }
96 99 tr.entry td.size { text-align: right; font-size: 90%; }
97 100 tr.entry td.revision, tr.entry td.author { text-align: center; }
98 101 tr.entry td.age { text-align: right; }
99 102
100 103 tr.entry span.expander {background-image: url(../images/bullet_toggle_plus.png); padding-left: 8px; margin-left: 0; cursor: pointer;}
101 104 tr.entry.open span.expander {background-image: url(../images/bullet_toggle_minus.png);}
102 105 tr.entry.file td.filename a { margin-left: 16px; }
103 106
104 107 tr.changeset td.author { text-align: center; width: 15%; }
105 108 tr.changeset td.committed_on { text-align: center; width: 15%; }
106 109
107 110 tr.message { height: 2.6em; }
108 111 tr.message td.last_message { font-size: 80%; }
109 112 tr.message.locked td.subject a { background-image: url(../images/locked.png); }
110 113 tr.message.sticky td.subject a { background-image: url(../images/sticky.png); font-weight: bold; }
111 114
112 115 tr.user td { width:13%; }
113 116 tr.user td.email { width:18%; }
114 117 tr.user td { white-space: nowrap; }
115 118 tr.user.locked, tr.user.registered { color: #aaa; }
116 119 tr.user.locked a, tr.user.registered a { color: #aaa; }
117 120
118 121 tr.time-entry { text-align: center; white-space: nowrap; }
119 122 tr.time-entry td.subject, tr.time-entry td.comments { text-align: left; white-space: normal; }
120 123 td.hours { text-align: right; font-weight: bold; padding-right: 0.5em; }
121 124 td.hours .hours-dec { font-size: 0.9em; }
122 125
123 126 table.plugins td { vertical-align: middle; }
124 127 table.plugins td.configure { text-align: right; padding-right: 1em; }
125 128 table.plugins span.name { font-weight: bold; display: block; margin-bottom: 6px; }
126 129 table.plugins span.description { display: block; font-size: 0.9em; }
127 130 table.plugins span.url { display: block; font-size: 0.9em; }
128 131
129 132 table.list tbody tr:hover { background-color:#ffffdd; }
130 133 table td {padding:2px;}
131 134 table p {margin:0;}
132 135 .odd {background-color:#f6f7f8;}
133 136 .even {background-color: #fff;}
134 137
135 138 .highlight { background-color: #FCFD8D;}
136 139 .highlight.token-1 { background-color: #faa;}
137 140 .highlight.token-2 { background-color: #afa;}
138 141 .highlight.token-3 { background-color: #aaf;}
139 142
140 143 .box{
141 144 padding:6px;
142 145 margin-bottom: 10px;
143 146 background-color:#f6f6f6;
144 147 color:#505050;
145 148 line-height:1.5em;
146 149 border: 1px solid #e4e4e4;
147 150 }
148 151
149 152 div.square {
150 153 border: 1px solid #999;
151 154 float: left;
152 155 margin: .3em .4em 0 .4em;
153 156 overflow: hidden;
154 157 width: .6em; height: .6em;
155 158 }
156 159 .contextual {float:right; white-space: nowrap; line-height:1.4em;margin-top:5px; padding-left: 10px; font-size:0.9em;}
157 160 .contextual input {font-size:0.9em;}
158 161
159 162 .splitcontentleft{float:left; width:49%;}
160 163 .splitcontentright{float:right; width:49%;}
161 164 form {display: inline;}
162 165 input, select {vertical-align: middle; margin-top: 1px; margin-bottom: 1px;}
163 166 fieldset {border: 1px solid #e4e4e4; margin:0;}
164 167 legend {color: #484848;}
165 168 hr { width: 100%; height: 1px; background: #ccc; border: 0;}
166 169 blockquote { font-style: italic; border-left: 3px solid #e0e0e0; padding-left: 0.6em; margin-left: 2.4em;}
167 170 blockquote blockquote { margin-left: 0;}
168 171 textarea.wiki-edit { width: 99%; }
169 172 li p {margin-top: 0;}
170 173 div.issue {background:#ffffdd; padding:6px; margin-bottom:6px;border: 1px solid #d7d7d7;}
171 174 p.breadcrumb { font-size: 0.9em; margin: 4px 0 4px 0;}
172 175 p.subtitle { font-size: 0.9em; margin: -6px 0 12px 0; font-style: italic; }
173 176 p.footnote { font-size: 0.9em; margin-top: 0px; margin-bottom: 0px; }
174 177
175 178 fieldset#filters, fieldset#date-range { padding: 0.7em; margin-bottom: 8px; }
176 179 fieldset#filters p { margin: 1.2em 0 0.8em 2px; }
177 180 fieldset#filters table { border-collapse: collapse; }
178 181 fieldset#filters table td { padding: 0; vertical-align: middle; }
179 182 fieldset#filters tr.filter { height: 2em; }
180 183 fieldset#filters td.add-filter { text-align: right; vertical-align: top; }
181 184 .buttons { font-size: 0.9em; }
182 185
183 186 div#issue-changesets {float:right; width:45%; margin-left: 1em; margin-bottom: 1em; background: #fff; padding-left: 1em; font-size: 90%;}
184 187 div#issue-changesets .changeset { padding: 4px;}
185 188 div#issue-changesets .changeset { border-bottom: 1px solid #ddd; }
186 189 div#issue-changesets p { margin-top: 0; margin-bottom: 1em;}
187 190
188 191 div#activity dl, #search-results { margin-left: 2em; }
189 192 div#activity dd, #search-results dd { margin-bottom: 1em; padding-left: 18px; font-size: 0.9em; }
190 193 div#activity dt, #search-results dt { margin-bottom: 0px; padding-left: 20px; line-height: 18px; background-position: 0 50%; background-repeat: no-repeat; }
191 194 div#activity dt.me .time { border-bottom: 1px solid #999; }
192 195 div#activity dt .time { color: #777; font-size: 80%; }
193 196 div#activity dd .description, #search-results dd .description { font-style: italic; }
194 197 div#activity span.project:after, #search-results span.project:after { content: " -"; }
195 198 div#activity dd span.description, #search-results dd span.description { display:block; }
196 199
197 200 #search-results dd { margin-bottom: 1em; padding-left: 20px; margin-left:0px; }
198 201
199 202 div#search-results-counts {float:right;}
200 203 div#search-results-counts ul { margin-top: 0.5em; }
201 204 div#search-results-counts li { list-style-type:none; float: left; margin-left: 1em; }
202 205
203 206 dt.issue { background-image: url(../images/ticket.png); }
204 207 dt.issue-edit { background-image: url(../images/ticket_edit.png); }
205 208 dt.issue-closed { background-image: url(../images/ticket_checked.png); }
206 209 dt.issue-note { background-image: url(../images/ticket_note.png); }
207 210 dt.changeset { background-image: url(../images/changeset.png); }
208 211 dt.news { background-image: url(../images/news.png); }
209 212 dt.message { background-image: url(../images/message.png); }
210 213 dt.reply { background-image: url(../images/comments.png); }
211 214 dt.wiki-page { background-image: url(../images/wiki_edit.png); }
212 215 dt.attachment { background-image: url(../images/attachment.png); }
213 216 dt.document { background-image: url(../images/document.png); }
214 217 dt.project { background-image: url(../images/projects.png); }
215 218
216 219 #search-results dt.issue.closed { background-image: url(../images/ticket_checked.png); }
217 220
218 221 div#roadmap fieldset.related-issues { margin-bottom: 1em; }
219 222 div#roadmap fieldset.related-issues ul { margin-top: 0.3em; margin-bottom: 0.3em; }
220 223 div#roadmap .wiki h1:first-child { display: none; }
221 224 div#roadmap .wiki h1 { font-size: 120%; }
222 225 div#roadmap .wiki h2 { font-size: 110%; }
223 226
224 227 div#version-summary { float:right; width:380px; margin-left: 16px; margin-bottom: 16px; background-color: #fff; }
225 228 div#version-summary fieldset { margin-bottom: 1em; }
226 229 div#version-summary .total-hours { text-align: right; }
227 230
228 231 table#time-report td.hours, table#time-report th.period, table#time-report th.total { text-align: right; padding-right: 0.5em; }
229 232 table#time-report tbody tr { font-style: italic; color: #777; }
230 233 table#time-report tbody tr.last-level { font-style: normal; color: #555; }
231 234 table#time-report tbody tr.total { font-style: normal; font-weight: bold; color: #555; background-color:#EEEEEE; }
232 235 table#time-report .hours-dec { font-size: 0.9em; }
233 236
234 237 form#issue-form .attributes { margin-bottom: 8px; }
235 238 form#issue-form .attributes p { padding-top: 1px; padding-bottom: 2px; }
236 239 form#issue-form .attributes select { min-width: 30%; }
237 240
241 ul.projects { margin: 0; padding-left: 1em; }
242 ul.projects.root { margin: 0; padding: 0; }
243 ul.projects ul { border-left: 3px solid #e0e0e0; }
244 ul.projects li { list-style-type:none; }
245 ul.projects li.root { margin-bottom: 1em; }
246 ul.projects li.child { margin-top: 1em;}
247 ul.projects div.root a.project { font-family: "Trebuchet MS", Verdana, sans-serif; font-weight: bold; font-size: 16px; margin: 0 0 10px 0; }
248 .my-project { padding-left: 18px; background: url(../images/fav.png) no-repeat 0 50%; }
249
238 250 ul.properties {padding:0; font-size: 0.9em; color: #777;}
239 251 ul.properties li {list-style-type:none;}
240 252 ul.properties li span {font-style:italic;}
241 253
242 254 .total-hours { font-size: 110%; font-weight: bold; }
243 255 .total-hours span.hours-int { font-size: 120%; }
244 256
245 257 .autoscroll {overflow-x: auto; padding:1px; margin-bottom: 1.2em;}
246 258 #user_firstname, #user_lastname, #user_mail, #my_account_form select { width: 90%; }
247 259
248 260 .pagination {font-size: 90%}
249 261 p.pagination {margin-top:8px;}
250 262
251 263 /***** Tabular forms ******/
252 264 .tabular p{
253 265 margin: 0;
254 266 padding: 5px 0 8px 0;
255 267 padding-left: 180px; /*width of left column containing the label elements*/
256 268 height: 1%;
257 269 clear:left;
258 270 }
259 271
260 272 html>body .tabular p {overflow:hidden;}
261 273
262 274 .tabular label{
263 275 font-weight: bold;
264 276 float: left;
265 277 text-align: right;
266 278 margin-left: -180px; /*width of left column*/
267 279 width: 175px; /*width of labels. Should be smaller than left column to create some right
268 280 margin*/
269 281 }
270 282
271 283 .tabular label.floating{
272 284 font-weight: normal;
273 285 margin-left: 0px;
274 286 text-align: left;
275 287 width: 270px;
276 288 }
277 289
278 290 input#time_entry_comments { width: 90%;}
279 291
280 292 #preview fieldset {margin-top: 1em; background: url(../images/draft.png)}
281 293
282 294 .tabular.settings p{ padding-left: 300px; }
283 295 .tabular.settings label{ margin-left: -300px; width: 295px; }
284 296
285 297 .required {color: #bb0000;}
286 298 .summary {font-style: italic;}
287 299
288 300 #attachments_fields input[type=text] {margin-left: 8px; }
289 301
290 302 div.attachments { margin-top: 12px; }
291 303 div.attachments p { margin:4px 0 2px 0; }
292 304 div.attachments img { vertical-align: middle; }
293 305 div.attachments span.author { font-size: 0.9em; color: #888; }
294 306
295 307 p.other-formats { text-align: right; font-size:0.9em; color: #666; }
296 308 .other-formats span + span:before { content: "| "; }
297 309
298 310 a.feed { background: url(../images/feed.png) no-repeat 1px 50%; padding: 2px 0px 3px 16px; }
299 311
300 312 /***** Flash & error messages ****/
301 313 #errorExplanation, div.flash, .nodata, .warning {
302 314 padding: 4px 4px 4px 30px;
303 315 margin-bottom: 12px;
304 316 font-size: 1.1em;
305 317 border: 2px solid;
306 318 }
307 319
308 320 div.flash {margin-top: 8px;}
309 321
310 322 div.flash.error, #errorExplanation {
311 323 background: url(../images/false.png) 8px 5px no-repeat;
312 324 background-color: #ffe3e3;
313 325 border-color: #dd0000;
314 326 color: #550000;
315 327 }
316 328
317 329 div.flash.notice {
318 330 background: url(../images/true.png) 8px 5px no-repeat;
319 331 background-color: #dfffdf;
320 332 border-color: #9fcf9f;
321 333 color: #005f00;
322 334 }
323 335
324 336 div.flash.warning {
325 337 background: url(../images/warning.png) 8px 5px no-repeat;
326 338 background-color: #FFEBC1;
327 339 border-color: #FDBF3B;
328 340 color: #A6750C;
329 341 text-align: left;
330 342 }
331 343
332 344 .nodata, .warning {
333 345 text-align: center;
334 346 background-color: #FFEBC1;
335 347 border-color: #FDBF3B;
336 348 color: #A6750C;
337 349 }
338 350
339 351 #errorExplanation ul { font-size: 0.9em;}
340 352
341 353 /***** Ajax indicator ******/
342 354 #ajax-indicator {
343 355 position: absolute; /* fixed not supported by IE */
344 356 background-color:#eee;
345 357 border: 1px solid #bbb;
346 358 top:35%;
347 359 left:40%;
348 360 width:20%;
349 361 font-weight:bold;
350 362 text-align:center;
351 363 padding:0.6em;
352 364 z-index:100;
353 365 filter:alpha(opacity=50);
354 366 opacity: 0.5;
355 367 }
356 368
357 369 html>body #ajax-indicator { position: fixed; }
358 370
359 371 #ajax-indicator span {
360 372 background-position: 0% 40%;
361 373 background-repeat: no-repeat;
362 374 background-image: url(../images/loading.gif);
363 375 padding-left: 26px;
364 376 vertical-align: bottom;
365 377 }
366 378
367 379 /***** Calendar *****/
368 380 table.cal {border-collapse: collapse; width: 100%; margin: 0px 0 6px 0;border: 1px solid #d7d7d7;}
369 381 table.cal thead th {width: 14%;}
370 382 table.cal tbody tr {height: 100px;}
371 383 table.cal th { background-color:#EEEEEE; padding: 4px; }
372 384 table.cal td {border: 1px solid #d7d7d7; vertical-align: top; font-size: 0.9em;}
373 385 table.cal td p.day-num {font-size: 1.1em; text-align:right;}
374 386 table.cal td.odd p.day-num {color: #bbb;}
375 387 table.cal td.today {background:#ffffdd;}
376 388 table.cal td.today p.day-num {font-weight: bold;}
377 389
378 390 /***** Tooltips ******/
379 391 .tooltip{position:relative;z-index:24;}
380 392 .tooltip:hover{z-index:25;color:#000;}
381 393 .tooltip span.tip{display: none; text-align:left;}
382 394
383 395 div.tooltip:hover span.tip{
384 396 display:block;
385 397 position:absolute;
386 398 top:12px; left:24px; width:270px;
387 399 border:1px solid #555;
388 400 background-color:#fff;
389 401 padding: 4px;
390 402 font-size: 0.8em;
391 403 color:#505050;
392 404 }
393 405
394 406 /***** Progress bar *****/
395 407 table.progress {
396 408 border: 1px solid #D7D7D7;
397 409 border-collapse: collapse;
398 410 border-spacing: 0pt;
399 411 empty-cells: show;
400 412 text-align: center;
401 413 float:left;
402 414 margin: 1px 6px 1px 0px;
403 415 }
404 416
405 417 table.progress td { height: 0.9em; }
406 418 table.progress td.closed { background: #BAE0BA none repeat scroll 0%; }
407 419 table.progress td.done { background: #DEF0DE none repeat scroll 0%; }
408 420 table.progress td.open { background: #FFF none repeat scroll 0%; }
409 421 p.pourcent {font-size: 80%;}
410 422 p.progress-info {clear: left; font-style: italic; font-size: 80%;}
411 423
412 424 /***** Tabs *****/
413 425 #content .tabs {height: 2.6em; border-bottom: 1px solid #bbbbbb; margin-bottom:1.2em; position:relative;}
414 426 #content .tabs ul {margin:0; position:absolute; bottom:-2px; padding-left:1em;}
415 427 #content .tabs>ul { bottom:-1px; } /* others */
416 428 #content .tabs ul li {
417 429 float:left;
418 430 list-style-type:none;
419 431 white-space:nowrap;
420 432 margin-right:8px;
421 433 background:#fff;
422 434 }
423 435 #content .tabs ul li a{
424 436 display:block;
425 437 font-size: 0.9em;
426 438 text-decoration:none;
427 439 line-height:1.3em;
428 440 padding:4px 6px 4px 6px;
429 441 border: 1px solid #ccc;
430 442 border-bottom: 1px solid #bbbbbb;
431 443 background-color: #eeeeee;
432 444 color:#777;
433 445 font-weight:bold;
434 446 }
435 447
436 448 #content .tabs ul li a:hover {
437 449 background-color: #ffffdd;
438 450 text-decoration:none;
439 451 }
440 452
441 453 #content .tabs ul li a.selected {
442 454 background-color: #fff;
443 455 border: 1px solid #bbbbbb;
444 456 border-bottom: 1px solid #fff;
445 457 }
446 458
447 459 #content .tabs ul li a.selected:hover {
448 460 background-color: #fff;
449 461 }
450 462
451 463 /***** Diff *****/
452 464 .diff_out { background: #fcc; }
453 465 .diff_in { background: #cfc; }
454 466
455 467 /***** Wiki *****/
456 468 div.wiki table {
457 469 border: 1px solid #505050;
458 470 border-collapse: collapse;
459 471 margin-bottom: 1em;
460 472 }
461 473
462 474 div.wiki table, div.wiki td, div.wiki th {
463 475 border: 1px solid #bbb;
464 476 padding: 4px;
465 477 }
466 478
467 479 div.wiki .external {
468 480 background-position: 0% 60%;
469 481 background-repeat: no-repeat;
470 482 padding-left: 12px;
471 483 background-image: url(../images/external.png);
472 484 }
473 485
474 486 div.wiki a.new {
475 487 color: #b73535;
476 488 }
477 489
478 490 div.wiki pre {
479 491 margin: 1em 1em 1em 1.6em;
480 492 padding: 2px;
481 493 background-color: #fafafa;
482 494 border: 1px solid #dadada;
483 495 width:95%;
484 496 overflow-x: auto;
485 497 }
486 498
487 499 div.wiki ul.toc {
488 500 background-color: #ffffdd;
489 501 border: 1px solid #e4e4e4;
490 502 padding: 4px;
491 503 line-height: 1.2em;
492 504 margin-bottom: 12px;
493 505 margin-right: 12px;
494 506 margin-left: 0;
495 507 display: table
496 508 }
497 509 * html div.wiki ul.toc { width: 50%; } /* IE6 doesn't autosize div */
498 510
499 511 div.wiki ul.toc.right { float: right; margin-left: 12px; margin-right: 0; width: auto; }
500 512 div.wiki ul.toc.left { float: left; margin-right: 12px; margin-left: 0; width: auto; }
501 513 div.wiki ul.toc li { list-style-type:none;}
502 514 div.wiki ul.toc li.heading2 { margin-left: 6px; }
503 515 div.wiki ul.toc li.heading3 { margin-left: 12px; font-size: 0.8em; }
504 516
505 517 div.wiki ul.toc a {
506 518 font-size: 0.9em;
507 519 font-weight: normal;
508 520 text-decoration: none;
509 521 color: #606060;
510 522 }
511 523 div.wiki ul.toc a:hover { color: #c61a1a; text-decoration: underline;}
512 524
513 525 a.wiki-anchor { display: none; margin-left: 6px; text-decoration: none; }
514 526 a.wiki-anchor:hover { color: #aaa !important; text-decoration: none; }
515 527 h1:hover a.wiki-anchor, h2:hover a.wiki-anchor, h3:hover a.wiki-anchor { display: inline; color: #ddd; }
516 528
517 529 /***** My page layout *****/
518 530 .block-receiver {
519 531 border:1px dashed #c0c0c0;
520 532 margin-bottom: 20px;
521 533 padding: 15px 0 15px 0;
522 534 }
523 535
524 536 .mypage-box {
525 537 margin:0 0 20px 0;
526 538 color:#505050;
527 539 line-height:1.5em;
528 540 }
529 541
530 542 .handle {
531 543 cursor: move;
532 544 }
533 545
534 546 a.close-icon {
535 547 display:block;
536 548 margin-top:3px;
537 549 overflow:hidden;
538 550 width:12px;
539 551 height:12px;
540 552 background-repeat: no-repeat;
541 553 cursor:pointer;
542 554 background-image:url('../images/close.png');
543 555 }
544 556
545 557 a.close-icon:hover {
546 558 background-image:url('../images/close_hl.png');
547 559 }
548 560
549 561 /***** Gantt chart *****/
550 562 .gantt_hdr {
551 563 position:absolute;
552 564 top:0;
553 565 height:16px;
554 566 border-top: 1px solid #c0c0c0;
555 567 border-bottom: 1px solid #c0c0c0;
556 568 border-right: 1px solid #c0c0c0;
557 569 text-align: center;
558 570 overflow: hidden;
559 571 }
560 572
561 573 .task {
562 574 position: absolute;
563 575 height:8px;
564 576 font-size:0.8em;
565 577 color:#888;
566 578 padding:0;
567 579 margin:0;
568 580 line-height:0.8em;
569 581 }
570 582
571 583 .task_late { background:#f66 url(../images/task_late.png); border: 1px solid #f66; }
572 584 .task_done { background:#66f url(../images/task_done.png); border: 1px solid #66f; }
573 585 .task_todo { background:#aaa url(../images/task_todo.png); border: 1px solid #aaa; }
574 586 .milestone { background-image:url(../images/milestone.png); background-repeat: no-repeat; border: 0; }
575 587
576 588 /***** Icons *****/
577 589 .icon {
578 590 background-position: 0% 40%;
579 591 background-repeat: no-repeat;
580 592 padding-left: 20px;
581 593 padding-top: 2px;
582 594 padding-bottom: 3px;
583 595 }
584 596
585 597 .icon22 {
586 598 background-position: 0% 40%;
587 599 background-repeat: no-repeat;
588 600 padding-left: 26px;
589 601 line-height: 22px;
590 602 vertical-align: middle;
591 603 }
592 604
593 605 .icon-add { background-image: url(../images/add.png); }
594 606 .icon-edit { background-image: url(../images/edit.png); }
595 607 .icon-copy { background-image: url(../images/copy.png); }
596 608 .icon-del { background-image: url(../images/delete.png); }
597 609 .icon-move { background-image: url(../images/move.png); }
598 610 .icon-save { background-image: url(../images/save.png); }
599 611 .icon-cancel { background-image: url(../images/cancel.png); }
600 612 .icon-file { background-image: url(../images/file.png); }
601 613 .icon-folder { background-image: url(../images/folder.png); }
602 614 .open .icon-folder { background-image: url(../images/folder_open.png); }
603 615 .icon-package { background-image: url(../images/package.png); }
604 616 .icon-home { background-image: url(../images/home.png); }
605 617 .icon-user { background-image: url(../images/user.png); }
606 618 .icon-mypage { background-image: url(../images/user_page.png); }
607 619 .icon-admin { background-image: url(../images/admin.png); }
608 620 .icon-projects { background-image: url(../images/projects.png); }
609 621 .icon-help { background-image: url(../images/help.png); }
610 622 .icon-attachment { background-image: url(../images/attachment.png); }
611 623 .icon-index { background-image: url(../images/index.png); }
612 624 .icon-history { background-image: url(../images/history.png); }
613 625 .icon-time { background-image: url(../images/time.png); }
614 626 .icon-stats { background-image: url(../images/stats.png); }
615 627 .icon-warning { background-image: url(../images/warning.png); }
616 628 .icon-fav { background-image: url(../images/fav.png); }
617 629 .icon-fav-off { background-image: url(../images/fav_off.png); }
618 630 .icon-reload { background-image: url(../images/reload.png); }
619 631 .icon-lock { background-image: url(../images/locked.png); }
620 632 .icon-unlock { background-image: url(../images/unlock.png); }
621 633 .icon-checked { background-image: url(../images/true.png); }
622 634 .icon-details { background-image: url(../images/zoom_in.png); }
623 635 .icon-report { background-image: url(../images/report.png); }
624 636 .icon-comment { background-image: url(../images/comment.png); }
625 637
626 638 .icon22-projects { background-image: url(../images/22x22/projects.png); }
627 639 .icon22-users { background-image: url(../images/22x22/users.png); }
628 640 .icon22-tracker { background-image: url(../images/22x22/tracker.png); }
629 641 .icon22-role { background-image: url(../images/22x22/role.png); }
630 642 .icon22-workflow { background-image: url(../images/22x22/workflow.png); }
631 643 .icon22-options { background-image: url(../images/22x22/options.png); }
632 644 .icon22-notifications { background-image: url(../images/22x22/notifications.png); }
633 645 .icon22-authent { background-image: url(../images/22x22/authent.png); }
634 646 .icon22-info { background-image: url(../images/22x22/info.png); }
635 647 .icon22-comment { background-image: url(../images/22x22/comment.png); }
636 648 .icon22-package { background-image: url(../images/22x22/package.png); }
637 649 .icon22-settings { background-image: url(../images/22x22/settings.png); }
638 650 .icon22-plugin { background-image: url(../images/22x22/plugin.png); }
639 651
640 652 img.gravatar {
641 653 padding: 2px;
642 654 border: solid 1px #d5d5d5;
643 655 background: #fff;
644 656 }
645 657
646 658 div.issue img.gravatar {
647 659 float: right;
648 660 margin: 0 0 0 1em;
649 661 padding: 5px;
650 662 }
651 663
652 664 div.issue table img.gravatar {
653 665 height: 14px;
654 666 width: 14px;
655 667 padding: 2px;
656 668 float: left;
657 669 margin: 0 0.5em 0 0;
658 670 }
659 671
660 672 #history img.gravatar {
661 673 padding: 3px;
662 674 margin: 0 1.5em 1em 0;
663 675 float: left;
664 676 }
665 677
666 678 td.username img.gravatar {
667 679 float: left;
668 680 margin: 0 1em 0 0;
669 681 }
670 682
671 683 #activity dt img.gravatar {
672 684 float: left;
673 685 margin: 0 1em 1em 0;
674 686 }
675 687
676 688 #activity dt,
677 689 .journal {
678 690 clear: left;
679 691 }
680 692
681 693 h2 img { vertical-align:middle; }
682 694
683 695
684 696 /***** Media print specific styles *****/
685 697 @media print {
686 698 #top-menu, #header, #main-menu, #sidebar, #footer, .contextual, .other-formats { display:none; }
687 699 #main { background: #fff; }
688 700 #content { width: 99%; margin: 0; padding: 0; border: 0; background: #fff; overflow: visible !important;}
689 701 }
@@ -1,57 +1,80
1 1 ---
2 2 projects_001:
3 3 created_on: 2006-07-19 19:13:59 +02:00
4 4 name: eCookbook
5 5 updated_on: 2006-07-19 22:53:01 +02:00
6 6 projects_count: 3
7 7 id: 1
8 8 description: Recipes management application
9 9 homepage: http://ecookbook.somenet.foo/
10 10 is_public: true
11 11 identifier: ecookbook
12 12 parent_id:
13 lft: 1
14 rgt: 10
13 15 projects_002:
14 16 created_on: 2006-07-19 19:14:19 +02:00
15 17 name: OnlineStore
16 18 updated_on: 2006-07-19 19:14:19 +02:00
17 19 projects_count: 0
18 20 id: 2
19 21 description: E-commerce web site
20 22 homepage: ""
21 23 is_public: false
22 24 identifier: onlinestore
23 25 parent_id:
26 lft: 11
27 rgt: 12
24 28 projects_003:
25 29 created_on: 2006-07-19 19:15:21 +02:00
26 30 name: eCookbook Subproject 1
27 31 updated_on: 2006-07-19 19:18:12 +02:00
28 32 projects_count: 0
29 33 id: 3
30 34 description: eCookBook Subproject 1
31 35 homepage: ""
32 36 is_public: true
33 37 identifier: subproject1
34 38 parent_id: 1
39 lft: 6
40 rgt: 7
35 41 projects_004:
36 42 created_on: 2006-07-19 19:15:51 +02:00
37 43 name: eCookbook Subproject 2
38 44 updated_on: 2006-07-19 19:17:07 +02:00
39 45 projects_count: 0
40 46 id: 4
41 47 description: eCookbook Subproject 2
42 48 homepage: ""
43 49 is_public: true
44 50 identifier: subproject2
45 51 parent_id: 1
52 lft: 8
53 rgt: 9
46 54 projects_005:
47 55 created_on: 2006-07-19 19:15:51 +02:00
48 56 name: Private child of eCookbook
49 57 updated_on: 2006-07-19 19:17:07 +02:00
50 58 projects_count: 0
51 59 id: 5
52 60 description: This is a private subproject of a public project
53 61 homepage: ""
54 62 is_public: false
55 identifier: private_child
63 identifier: private-child
56 64 parent_id: 1
65 lft: 2
66 rgt: 5
67 projects_006:
68 created_on: 2006-07-19 19:15:51 +02:00
69 name: Child of private child
70 updated_on: 2006-07-19 19:17:07 +02:00
71 projects_count: 0
72 id: 6
73 description: This is a public subproject of a private project
74 homepage: ""
75 is_public: true
76 identifier: project6
77 parent_id: 5
78 lft: 3
79 rgt: 4
57 80 No newline at end of file
@@ -1,357 +1,364
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2008 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.dirname(__FILE__) + '/../test_helper'
19 19 require 'projects_controller'
20 20
21 21 # Re-raise errors caught by the controller.
22 22 class ProjectsController; def rescue_action(e) raise e end; end
23 23
24 24 class ProjectsControllerTest < Test::Unit::TestCase
25 25 fixtures :projects, :versions, :users, :roles, :members, :issues, :journals, :journal_details,
26 26 :trackers, :projects_trackers, :issue_statuses, :enabled_modules, :enumerations, :boards, :messages,
27 27 :attachments
28 28
29 29 def setup
30 30 @controller = ProjectsController.new
31 31 @request = ActionController::TestRequest.new
32 32 @response = ActionController::TestResponse.new
33 33 @request.session[:user_id] = nil
34 34 Setting.default_language = 'en'
35 35 end
36 36
37 37 def test_index
38 38 get :index
39 39 assert_response :success
40 40 assert_template 'index'
41 assert_not_nil assigns(:project_tree)
42 # Root project as hash key
43 assert assigns(:project_tree).keys.include?(Project.find(1))
44 # Subproject in corresponding value
45 assert assigns(:project_tree)[Project.find(1)].include?(Project.find(3))
41 assert_not_nil assigns(:projects)
42
43 assert_tag :ul, :child => {:tag => 'li',
44 :descendant => {:tag => 'a', :content => 'eCookbook'},
45 :child => { :tag => 'ul',
46 :descendant => { :tag => 'a',
47 :content => 'Child of private child'
48 }
49 }
50 }
51
52 assert_no_tag :a, :content => /Private child of eCookbook/
46 53 end
47 54
48 55 def test_index_atom
49 56 get :index, :format => 'atom'
50 57 assert_response :success
51 58 assert_template 'common/feed.atom.rxml'
52 59 assert_select 'feed>title', :text => 'Redmine: Latest projects'
53 60 assert_select 'feed>entry', :count => Project.count(:conditions => Project.visible_by(User.current))
54 61 end
55 62
56 63 def test_show_by_id
57 64 get :show, :id => 1
58 65 assert_response :success
59 66 assert_template 'show'
60 67 assert_not_nil assigns(:project)
61 68 end
62 69
63 70 def test_show_by_identifier
64 71 get :show, :id => 'ecookbook'
65 72 assert_response :success
66 73 assert_template 'show'
67 74 assert_not_nil assigns(:project)
68 75 assert_equal Project.find_by_identifier('ecookbook'), assigns(:project)
69 76 end
70 77
71 78 def test_private_subprojects_hidden
72 79 get :show, :id => 'ecookbook'
73 80 assert_response :success
74 81 assert_template 'show'
75 82 assert_no_tag :tag => 'a', :content => /Private child/
76 83 end
77 84
78 85 def test_private_subprojects_visible
79 86 @request.session[:user_id] = 2 # manager who is a member of the private subproject
80 87 get :show, :id => 'ecookbook'
81 88 assert_response :success
82 89 assert_template 'show'
83 90 assert_tag :tag => 'a', :content => /Private child/
84 91 end
85 92
86 93 def test_settings
87 94 @request.session[:user_id] = 2 # manager
88 95 get :settings, :id => 1
89 96 assert_response :success
90 97 assert_template 'settings'
91 98 end
92 99
93 100 def test_edit
94 101 @request.session[:user_id] = 2 # manager
95 102 post :edit, :id => 1, :project => {:name => 'Test changed name',
96 103 :issue_custom_field_ids => ['']}
97 104 assert_redirected_to 'projects/settings/ecookbook'
98 105 project = Project.find(1)
99 106 assert_equal 'Test changed name', project.name
100 107 end
101 108
102 109 def test_get_destroy
103 110 @request.session[:user_id] = 1 # admin
104 111 get :destroy, :id => 1
105 112 assert_response :success
106 113 assert_template 'destroy'
107 114 assert_not_nil Project.find_by_id(1)
108 115 end
109 116
110 117 def test_post_destroy
111 118 @request.session[:user_id] = 1 # admin
112 119 post :destroy, :id => 1, :confirm => 1
113 120 assert_redirected_to 'admin/projects'
114 121 assert_nil Project.find_by_id(1)
115 122 end
116 123
117 124 def test_add_file
118 125 set_tmp_attachments_directory
119 126 @request.session[:user_id] = 2
120 127 Setting.notified_events = ['file_added']
121 128 ActionMailer::Base.deliveries.clear
122 129
123 130 assert_difference 'Attachment.count' do
124 131 post :add_file, :id => 1, :version_id => '',
125 132 :attachments => {'1' => {'file' => test_uploaded_file('testfile.txt', 'text/plain')}}
126 133 end
127 134 assert_redirected_to 'projects/list_files/ecookbook'
128 135 a = Attachment.find(:first, :order => 'created_on DESC')
129 136 assert_equal 'testfile.txt', a.filename
130 137 assert_equal Project.find(1), a.container
131 138
132 139 mail = ActionMailer::Base.deliveries.last
133 140 assert_kind_of TMail::Mail, mail
134 141 assert_equal "[eCookbook] New file", mail.subject
135 142 assert mail.body.include?('testfile.txt')
136 143 end
137 144
138 145 def test_add_version_file
139 146 set_tmp_attachments_directory
140 147 @request.session[:user_id] = 2
141 148 Setting.notified_events = ['file_added']
142 149
143 150 assert_difference 'Attachment.count' do
144 151 post :add_file, :id => 1, :version_id => '2',
145 152 :attachments => {'1' => {'file' => test_uploaded_file('testfile.txt', 'text/plain')}}
146 153 end
147 154 assert_redirected_to 'projects/list_files/ecookbook'
148 155 a = Attachment.find(:first, :order => 'created_on DESC')
149 156 assert_equal 'testfile.txt', a.filename
150 157 assert_equal Version.find(2), a.container
151 158 end
152 159
153 160 def test_list_files
154 161 get :list_files, :id => 1
155 162 assert_response :success
156 163 assert_template 'list_files'
157 164 assert_not_nil assigns(:containers)
158 165
159 166 # file attached to the project
160 167 assert_tag :a, :content => 'project_file.zip',
161 168 :attributes => { :href => '/attachments/download/8/project_file.zip' }
162 169
163 170 # file attached to a project's version
164 171 assert_tag :a, :content => 'version_file.zip',
165 172 :attributes => { :href => '/attachments/download/9/version_file.zip' }
166 173 end
167 174
168 175 def test_changelog
169 176 get :changelog, :id => 1
170 177 assert_response :success
171 178 assert_template 'changelog'
172 179 assert_not_nil assigns(:versions)
173 180 end
174 181
175 182 def test_roadmap
176 183 get :roadmap, :id => 1
177 184 assert_response :success
178 185 assert_template 'roadmap'
179 186 assert_not_nil assigns(:versions)
180 187 # Version with no date set appears
181 188 assert assigns(:versions).include?(Version.find(3))
182 189 # Completed version doesn't appear
183 190 assert !assigns(:versions).include?(Version.find(1))
184 191 end
185 192
186 193 def test_roadmap_with_completed_versions
187 194 get :roadmap, :id => 1, :completed => 1
188 195 assert_response :success
189 196 assert_template 'roadmap'
190 197 assert_not_nil assigns(:versions)
191 198 # Version with no date set appears
192 199 assert assigns(:versions).include?(Version.find(3))
193 200 # Completed version appears
194 201 assert assigns(:versions).include?(Version.find(1))
195 202 end
196 203
197 204 def test_project_activity
198 205 get :activity, :id => 1, :with_subprojects => 0
199 206 assert_response :success
200 207 assert_template 'activity'
201 208 assert_not_nil assigns(:events_by_day)
202 209
203 210 assert_tag :tag => "h3",
204 211 :content => /#{2.days.ago.to_date.day}/,
205 212 :sibling => { :tag => "dl",
206 213 :child => { :tag => "dt",
207 214 :attributes => { :class => /issue-edit/ },
208 215 :child => { :tag => "a",
209 216 :content => /(#{IssueStatus.find(2).name})/,
210 217 }
211 218 }
212 219 }
213 220 end
214 221
215 222 def test_previous_project_activity
216 223 get :activity, :id => 1, :from => 3.days.ago.to_date
217 224 assert_response :success
218 225 assert_template 'activity'
219 226 assert_not_nil assigns(:events_by_day)
220 227
221 228 assert_tag :tag => "h3",
222 229 :content => /#{3.day.ago.to_date.day}/,
223 230 :sibling => { :tag => "dl",
224 231 :child => { :tag => "dt",
225 232 :attributes => { :class => /issue/ },
226 233 :child => { :tag => "a",
227 234 :content => /#{Issue.find(1).subject}/,
228 235 }
229 236 }
230 237 }
231 238 end
232 239
233 240 def test_global_activity
234 241 get :activity
235 242 assert_response :success
236 243 assert_template 'activity'
237 244 assert_not_nil assigns(:events_by_day)
238 245
239 246 assert_tag :tag => "h3",
240 247 :content => /#{5.day.ago.to_date.day}/,
241 248 :sibling => { :tag => "dl",
242 249 :child => { :tag => "dt",
243 250 :attributes => { :class => /issue/ },
244 251 :child => { :tag => "a",
245 252 :content => /#{Issue.find(5).subject}/,
246 253 }
247 254 }
248 255 }
249 256 end
250 257
251 258 def test_user_activity
252 259 get :activity, :user_id => 2
253 260 assert_response :success
254 261 assert_template 'activity'
255 262 assert_not_nil assigns(:events_by_day)
256 263
257 264 assert_tag :tag => "h3",
258 265 :content => /#{3.day.ago.to_date.day}/,
259 266 :sibling => { :tag => "dl",
260 267 :child => { :tag => "dt",
261 268 :attributes => { :class => /issue/ },
262 269 :child => { :tag => "a",
263 270 :content => /#{Issue.find(1).subject}/,
264 271 }
265 272 }
266 273 }
267 274 end
268 275
269 276 def test_activity_atom_feed
270 277 get :activity, :format => 'atom'
271 278 assert_response :success
272 279 assert_template 'common/feed.atom.rxml'
273 280 end
274 281
275 282 def test_archive
276 283 @request.session[:user_id] = 1 # admin
277 284 post :archive, :id => 1
278 285 assert_redirected_to 'admin/projects'
279 286 assert !Project.find(1).active?
280 287 end
281 288
282 289 def test_unarchive
283 290 @request.session[:user_id] = 1 # admin
284 291 Project.find(1).archive
285 292 post :unarchive, :id => 1
286 293 assert_redirected_to 'admin/projects'
287 294 assert Project.find(1).active?
288 295 end
289 296
290 297 def test_jump_should_redirect_to_active_tab
291 298 get :show, :id => 1, :jump => 'issues'
292 299 assert_redirected_to 'projects/ecookbook/issues'
293 300 end
294 301
295 302 def test_jump_should_not_redirect_to_inactive_tab
296 303 get :show, :id => 3, :jump => 'documents'
297 304 assert_response :success
298 305 assert_template 'show'
299 306 end
300 307
301 308 def test_jump_should_not_redirect_to_unknown_tab
302 309 get :show, :id => 3, :jump => 'foobar'
303 310 assert_response :success
304 311 assert_template 'show'
305 312 end
306 313
307 314 def test_project_menu
308 315 assert_no_difference 'Redmine::MenuManager.items(:project_menu).size' do
309 316 Redmine::MenuManager.map :project_menu do |menu|
310 317 menu.push :foo, { :controller => 'projects', :action => 'show' }, :cation => 'Foo'
311 318 menu.push :bar, { :controller => 'projects', :action => 'show' }, :before => :activity
312 319 menu.push :hello, { :controller => 'projects', :action => 'show' }, :caption => Proc.new {|p| p.name.upcase }, :after => :bar
313 320 end
314 321
315 322 get :show, :id => 1
316 323 assert_tag :div, :attributes => { :id => 'main-menu' },
317 324 :descendant => { :tag => 'li', :child => { :tag => 'a', :content => 'Foo',
318 325 :attributes => { :class => 'foo' } } }
319 326
320 327 assert_tag :div, :attributes => { :id => 'main-menu' },
321 328 :descendant => { :tag => 'li', :child => { :tag => 'a', :content => 'Bar',
322 329 :attributes => { :class => 'bar' } },
323 330 :before => { :tag => 'li', :child => { :tag => 'a', :content => 'ECOOKBOOK' } } }
324 331
325 332 assert_tag :div, :attributes => { :id => 'main-menu' },
326 333 :descendant => { :tag => 'li', :child => { :tag => 'a', :content => 'ECOOKBOOK',
327 334 :attributes => { :class => 'hello' } },
328 335 :before => { :tag => 'li', :child => { :tag => 'a', :content => 'Activity' } } }
329 336
330 337 # Remove the menu items
331 338 Redmine::MenuManager.map :project_menu do |menu|
332 339 menu.delete :foo
333 340 menu.delete :bar
334 341 menu.delete :hello
335 342 end
336 343 end
337 344 end
338 345
339 346 # A hook that is manually registered later
340 347 class ProjectBasedTemplate < Redmine::Hook::ViewListener
341 348 def view_layouts_base_html_head(context)
342 349 # Adds a project stylesheet
343 350 stylesheet_link_tag(context[:project].identifier) if context[:project]
344 351 end
345 352 end
346 353 # Don't use this hook now
347 354 Redmine::Hook.clear_listeners
348 355
349 356 def test_hook_response
350 357 Redmine::Hook.add_listener(ProjectBasedTemplate)
351 358 get :show, :id => 1
352 359 assert_tag :tag => 'link', :attributes => {:href => '/stylesheets/ecookbook.css'},
353 360 :parent => {:tag => 'head'}
354 361
355 362 Redmine::Hook.clear_listeners
356 363 end
357 364 end
@@ -1,144 +1,211
1 1 # redMine - project management software
2 2 # Copyright (C) 2006-2007 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 require File.dirname(__FILE__) + '/../test_helper'
19 19
20 20 class ProjectTest < Test::Unit::TestCase
21 21 fixtures :projects, :issues, :issue_statuses, :journals, :journal_details, :users, :members, :roles, :projects_trackers, :trackers, :boards
22 22
23 23 def setup
24 24 @ecookbook = Project.find(1)
25 25 @ecookbook_sub1 = Project.find(3)
26 26 end
27 27
28 28 def test_truth
29 29 assert_kind_of Project, @ecookbook
30 30 assert_equal "eCookbook", @ecookbook.name
31 31 end
32 32
33 33 def test_update
34 34 assert_equal "eCookbook", @ecookbook.name
35 35 @ecookbook.name = "eCook"
36 36 assert @ecookbook.save, @ecookbook.errors.full_messages.join("; ")
37 37 @ecookbook.reload
38 38 assert_equal "eCook", @ecookbook.name
39 39 end
40 40
41 41 def test_validate
42 42 @ecookbook.name = ""
43 43 assert !@ecookbook.save
44 44 assert_equal 1, @ecookbook.errors.count
45 45 assert_equal "activerecord_error_blank", @ecookbook.errors.on(:name)
46 46 end
47 47
48 def test_public_projects
49 public_projects = Project.find(:all, :conditions => ["is_public=?", true])
50 assert_equal 3, public_projects.length
51 assert_equal true, public_projects[0].is_public?
52 end
53
54 48 def test_archive
55 49 user = @ecookbook.members.first.user
56 50 @ecookbook.archive
57 51 @ecookbook.reload
58 52
59 53 assert !@ecookbook.active?
60 54 assert !user.projects.include?(@ecookbook)
61 55 # Subproject are also archived
62 56 assert !@ecookbook.children.empty?
63 assert @ecookbook.active_children.empty?
57 assert @ecookbook.descendants.active.empty?
64 58 end
65 59
66 60 def test_unarchive
67 61 user = @ecookbook.members.first.user
68 62 @ecookbook.archive
69 63 # A subproject of an archived project can not be unarchived
70 64 assert !@ecookbook_sub1.unarchive
71 65
72 66 # Unarchive project
73 67 assert @ecookbook.unarchive
74 68 @ecookbook.reload
75 69 assert @ecookbook.active?
76 70 assert user.projects.include?(@ecookbook)
77 71 # Subproject can now be unarchived
78 72 @ecookbook_sub1.reload
79 73 assert @ecookbook_sub1.unarchive
80 74 end
81 75
82 76 def test_destroy
83 77 # 2 active members
84 78 assert_equal 2, @ecookbook.members.size
85 79 # and 1 is locked
86 80 assert_equal 3, Member.find(:all, :conditions => ['project_id = ?', @ecookbook.id]).size
87 81 # some boards
88 82 assert @ecookbook.boards.any?
89 83
90 84 @ecookbook.destroy
91 85 # make sure that the project non longer exists
92 86 assert_raise(ActiveRecord::RecordNotFound) { Project.find(@ecookbook.id) }
93 87 # make sure related data was removed
94 88 assert Member.find(:all, :conditions => ['project_id = ?', @ecookbook.id]).empty?
95 89 assert Board.find(:all, :conditions => ['project_id = ?', @ecookbook.id]).empty?
96 90 end
97 91
98 def test_subproject_ok
92 def test_move_an_orphan_project_to_a_root_project
99 93 sub = Project.find(2)
100 sub.parent = @ecookbook
101 assert sub.save
94 sub.set_parent! @ecookbook
102 95 assert_equal @ecookbook.id, sub.parent.id
103 96 @ecookbook.reload
104 97 assert_equal 4, @ecookbook.children.size
105 98 end
106 99
107 def test_subproject_invalid
100 def test_move_an_orphan_project_to_a_subproject
108 101 sub = Project.find(2)
109 sub.parent = @ecookbook_sub1
110 assert !sub.save
102 assert sub.set_parent!(@ecookbook_sub1)
103 end
104
105 def test_move_a_root_project_to_a_project
106 sub = @ecookbook
107 assert sub.set_parent!(Project.find(2))
111 108 end
112 109
113 def test_subproject_invalid_2
110 def test_should_not_move_a_project_to_its_children
114 111 sub = @ecookbook
115 sub.parent = Project.find(2)
116 assert !sub.save
112 assert !(sub.set_parent!(Project.find(3)))
113 end
114
115 def test_set_parent_should_add_roots_in_alphabetical_order
116 ProjectCustomField.delete_all
117 Project.delete_all
118 Project.create!(:name => 'Project C', :identifier => 'project-c').set_parent!(nil)
119 Project.create!(:name => 'Project B', :identifier => 'project-b').set_parent!(nil)
120 Project.create!(:name => 'Project D', :identifier => 'project-d').set_parent!(nil)
121 Project.create!(:name => 'Project A', :identifier => 'project-a').set_parent!(nil)
122
123 assert_equal 4, Project.count
124 assert_equal Project.all.sort_by(&:name), Project.all.sort_by(&:lft)
125 end
126
127 def test_set_parent_should_add_children_in_alphabetical_order
128 ProjectCustomField.delete_all
129 parent = Project.create!(:name => 'Parent', :identifier => 'parent')
130 Project.create!(:name => 'Project C', :identifier => 'project-c').set_parent!(parent)
131 Project.create!(:name => 'Project B', :identifier => 'project-b').set_parent!(parent)
132 Project.create!(:name => 'Project D', :identifier => 'project-d').set_parent!(parent)
133 Project.create!(:name => 'Project A', :identifier => 'project-a').set_parent!(parent)
134
135 parent.reload
136 assert_equal 4, parent.children.size
137 assert_equal parent.children.sort_by(&:name), parent.children
138 end
139
140 def test_rebuild_should_sort_children_alphabetically
141 ProjectCustomField.delete_all
142 parent = Project.create!(:name => 'Parent', :identifier => 'parent')
143 Project.create!(:name => 'Project C', :identifier => 'project-c').move_to_child_of(parent)
144 Project.create!(:name => 'Project B', :identifier => 'project-b').move_to_child_of(parent)
145 Project.create!(:name => 'Project D', :identifier => 'project-d').move_to_child_of(parent)
146 Project.create!(:name => 'Project A', :identifier => 'project-a').move_to_child_of(parent)
147
148 Project.update_all("lft = NULL, rgt = NULL")
149 Project.rebuild!
150
151 parent.reload
152 assert_equal 4, parent.children.size
153 assert_equal parent.children.sort_by(&:name), parent.children
154 end
155
156 def test_parent
157 p = Project.find(6).parent
158 assert p.is_a?(Project)
159 assert_equal 5, p.id
160 end
161
162 def test_ancestors
163 a = Project.find(6).ancestors
164 assert a.first.is_a?(Project)
165 assert_equal [1, 5], a.collect(&:id)
166 end
167
168 def test_root
169 r = Project.find(6).root
170 assert r.is_a?(Project)
171 assert_equal 1, r.id
172 end
173
174 def test_children
175 c = Project.find(1).children
176 assert c.first.is_a?(Project)
177 assert_equal [5, 3, 4], c.collect(&:id)
178 end
179
180 def test_descendants
181 d = Project.find(1).descendants
182 assert d.first.is_a?(Project)
183 assert_equal [5, 6, 3, 4], d.collect(&:id)
117 184 end
118 185
119 186 def test_rolled_up_trackers
120 187 parent = Project.find(1)
121 188 parent.trackers = Tracker.find([1,2])
122 189 child = parent.children.find(3)
123 190
124 191 assert_equal [1, 2], parent.tracker_ids
125 192 assert_equal [2, 3], child.tracker_ids
126 193
127 194 assert_kind_of Tracker, parent.rolled_up_trackers.first
128 195 assert_equal Tracker.find(1), parent.rolled_up_trackers.first
129 196
130 197 assert_equal [1, 2, 3], parent.rolled_up_trackers.collect(&:id)
131 198 assert_equal [2, 3], child.rolled_up_trackers.collect(&:id)
132 199 end
133 200
134 201 def test_next_identifier
135 202 ProjectCustomField.delete_all
136 203 Project.create!(:name => 'last', :identifier => 'p2008040')
137 204 assert_equal 'p2008041', Project.next_identifier
138 205 end
139 206
140 207 def test_next_identifier_first_project
141 208 Project.delete_all
142 209 assert_nil Project.next_identifier
143 210 end
144 211 end
1 NO CONTENT: file was removed
General Comments 0
You need to be logged in to leave comments. Login now